Skip to content
Open
26 changes: 24 additions & 2 deletions languageserver/src/connection.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import {documentLinks, getInlayHints, hover, validate, ValidationConfig} from "@actions/languageservice";
import {
documentLinks,
getCodeActions,
getInlayHints,
hover,
validate,
ValidationConfig
} from "@actions/languageservice";
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
import {Octokit} from "@octokit/rest";
import {
CodeAction,
CodeActionKind,
CodeActionParams,
CompletionItem,
Connection,
DocumentLink,
Expand Down Expand Up @@ -79,7 +89,10 @@ export function initConnection(connection: Connection) {
documentLinkProvider: {
resolveProvider: false
},
inlayHintProvider: true
inlayHintProvider: true,
codeActionProvider: {
codeActionKinds: [CodeActionKind.QuickFix]
}
}
};

Expand Down Expand Up @@ -176,6 +189,15 @@ export function initConnection(connection: Connection) {
});
});

connection.onCodeAction((params: CodeActionParams): CodeAction[] => {
return getCodeActions({
uri: params.textDocument.uri,
diagnostics: params.context.diagnostics,
only: params.context.only,
featureFlags
});
});

// Make the text document manager listen on the connection
// for open, change and close text document events
documents.listen(connection);
Expand Down
53 changes: 53 additions & 0 deletions languageservice/src/code-actions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {FeatureFlags} from "@actions/expressions";
import {CodeAction, CodeActionKind, Diagnostic} from "vscode-languageserver-types";
import {CodeActionContext, CodeActionProvider} from "./types.js";
import {getQuickfixProviders} from "./quickfix/index.js";

export interface CodeActionParams {
uri: string;
diagnostics: Diagnostic[];
only?: string[];
featureFlags?: FeatureFlags;
}

export function getCodeActions(params: CodeActionParams): CodeAction[] {
const actions: CodeAction[] = [];
const context: CodeActionContext = {
uri: params.uri,
featureFlags: params.featureFlags
};

// Build providers map based on feature flags
const providersByKind: Map<string, CodeActionProvider[]> = new Map([
[CodeActionKind.QuickFix, getQuickfixProviders(params.featureFlags)]
// [CodeActionKind.Refactor, getRefactorProviders(params.featureFlags)],
// [CodeActionKind.Source, getSourceProviders(params.featureFlags)],
// etc
]);

// Filter to requested kinds, or use all if none specified
const requestedKinds = params.only;
const kindsToCheck = requestedKinds
? [...providersByKind.keys()].filter(kind => requestedKinds.some(requested => kind.startsWith(requested)))
: [...providersByKind.keys()];

for (const diagnostic of params.diagnostics) {
for (const kind of kindsToCheck) {
const providers = providersByKind.get(kind) ?? [];
for (const provider of providers) {
if (provider.diagnosticCodes.includes(diagnostic.code)) {
const action = provider.createCodeAction(context, diagnostic);
if (action) {
action.kind = kind;
action.diagnostics = [diagnostic];
actions.push(action);
}
}
}
}
}

return actions;
}

export type {CodeActionContext, CodeActionProvider} from "./types.js";
65 changes: 65 additions & 0 deletions languageservice/src/code-actions/quickfix/add-missing-inputs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {CodeAction, TextEdit} from "vscode-languageserver-types";
import {CodeActionProvider} from "../types.js";
import {DiagnosticCode, MissingInputsDiagnosticData} from "../../validate-action-reference.js";

export const addMissingInputsProvider: CodeActionProvider = {
diagnosticCodes: [DiagnosticCode.MissingRequiredInputs],

createCodeAction(context, diagnostic): CodeAction | undefined {
const data = diagnostic.data as MissingInputsDiagnosticData | undefined;
if (!data) {
return undefined;
}

const edits = createInputEdits(data);
if (!edits) {
return undefined;
}

const inputNames = data.missingInputs.map(i => i.name).join(", ");

return {
title: `Add missing input${data.missingInputs.length > 1 ? "s" : ""}: ${inputNames}`,
edit: {
changes: {
[context.uri]: edits
}
}
};
}
};

function createInputEdits(data: MissingInputsDiagnosticData): TextEdit[] {
const edits: TextEdit[] = [];

const formatInputLines = (indent: string) =>
data.missingInputs.map(input => {
const value = input.default ?? '""';
return `${indent}${input.name}: ${value}`;
});

if (data.hasWithKey && data.withIndent !== undefined) {
// `with:` exists - use its indentation + 2 for inputs
const inputIndent = " ".repeat(data.withIndent + data.indentSize);
const inputLines = formatInputLines(inputIndent);

edits.push({
range: {start: data.insertPosition, end: data.insertPosition},
newText: inputLines.map(line => line + "\n").join("")
});
} else {
// No `with:` key - `with:` at step indentation, inputs at step indentation + 2
const withIndent = " ".repeat(data.stepIndent);
const inputIndent = " ".repeat(data.stepIndent + data.indentSize);
const inputLines = formatInputLines(inputIndent);

const newText = `${withIndent}with:\n` + inputLines.map(line => `${line}\n`).join("");

edits.push({
range: {start: data.insertPosition, end: data.insertPosition},
newText
});
}

return edits;
}
13 changes: 13 additions & 0 deletions languageservice/src/code-actions/quickfix/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {FeatureFlags} from "@actions/expressions";
import {CodeActionProvider} from "../types.js";
import {addMissingInputsProvider} from "./add-missing-inputs.js";

export function getQuickfixProviders(featureFlags?: FeatureFlags): CodeActionProvider[] {
const providers: CodeActionProvider[] = [];

if (featureFlags?.isEnabled("missingInputsQuickfix")) {
providers.push(addMissingInputsProvider);
}

return providers;
}
90 changes: 90 additions & 0 deletions languageservice/src/code-actions/tests/runner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import * as path from "path";
import {fileURLToPath} from "url";
import {loadTestCases, runTestCase} from "./runner.js";
import {ValidationConfig} from "../../validate.js";
import {ActionMetadata, ActionReference} from "../../action.js";
import {clearCache} from "../../utils/workflow-cache.js";

// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// Mock action metadata provider for tests
const validationConfig: ValidationConfig = {
actionsMetadataProvider: {
fetchActionMetadata: (ref: ActionReference): Promise<ActionMetadata | undefined> => {
const key = `${ref.owner}/${ref.name}@${ref.ref}`;

const metadata: Record<string, ActionMetadata> = {
"actions/cache@v1": {
name: "Cache",
description: "Cache dependencies",
inputs: {
path: {
description: "A list of files to cache",
required: true
},
key: {
description: "Cache key",
required: true
},
"restore-keys": {
description: "Restore keys",
required: false
}
}
},
"actions/setup-node@v3": {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use the latest version?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are just static actions that are part of test data (also added in this PR):

const metadata: Record<string, ActionMetadata> = {
"actions/cache@v1": {
name: "Cache",
description: "Cache dependencies",
inputs: {
path: {
description: "A list of files to cache",
required: true
},
key: {
description: "Cache key",
required: true
},
"restore-keys": {
description: "Restore keys",
required: false
}
}
},
"actions/setup-node@v3": {
name: "Setup Node",
description: "Setup Node.js",
inputs: {
"node-version": {
description: "Node version",
required: true,
default: "16"
}
}
}

name: "Setup Node",
description: "Setup Node.js",
inputs: {
"node-version": {
description: "Node version",
required: true,
default: "16"
}
}
}
};

return Promise.resolve(metadata[key]);
}
}
};

// Point to the source testdata directory
const testdataDir = path.join(__dirname, "testdata");

beforeEach(() => {
clearCache();
});

describe("code action golden tests", () => {
const testCases = loadTestCases(testdataDir);

if (testCases.length === 0) {
it.todo("no test cases found - add .yml files to testdata/");
return;
}

for (const testCase of testCases) {
it(testCase.name, async () => {
const result = await runTestCase(testCase, validationConfig);

if (!result.passed) {
let errorMessage = result.error || "Test failed";

if (result.expected !== undefined && result.actual !== undefined) {
errorMessage += "\n\n";
errorMessage += "=== EXPECTED (golden file) ===\n";
errorMessage += result.expected;
errorMessage += "\n\n";
errorMessage += "=== ACTUAL ===\n";
errorMessage += result.actual;
}

throw new Error(errorMessage);
}
});
}
});
Loading