Skip to content

Commit b6bd0ad

Browse files
committed
Setup CodeActions and add quickfix for missing inputs
1 parent 47ec2dc commit b6bd0ad

16 files changed

+662
-6
lines changed

languageserver/src/connection.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice";
1+
import {documentLinks, getCodeActions, hover, validate, ValidationConfig} from "@actions/languageservice";
22
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
33
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
44
import {Octokit} from "@octokit/rest";
55
import {
6+
CodeAction,
7+
CodeActionKind,
8+
CodeActionParams,
69
CompletionItem,
710
Connection,
811
DocumentLink,
@@ -72,6 +75,9 @@ export function initConnection(connection: Connection) {
7275
hoverProvider: true,
7376
documentLinkProvider: {
7477
resolveProvider: false
78+
},
79+
codeActionProvider: {
80+
codeActionKinds: [CodeActionKind.QuickFix]
7581
}
7682
}
7783
};
@@ -158,6 +164,16 @@ export function initConnection(connection: Connection) {
158164
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
159165
});
160166

167+
connection.onCodeAction(async (params: CodeActionParams): Promise<CodeAction[]> => {
168+
return timeOperation("codeAction", async () => {
169+
return getCodeActions({
170+
uri: params.textDocument.uri,
171+
diagnostics: params.context.diagnostics,
172+
only: params.context.only
173+
});
174+
});
175+
});
176+
161177
// Make the text document manager listen on the connection
162178
// for open, change and close text document events
163179
documents.listen(connection);
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {CodeAction, CodeActionKind, Diagnostic} from "vscode-languageserver-types";
2+
import {CodeActionContext, CodeActionProvider} from "./types";
3+
import {quickfixProviders} from "./quickfix";
4+
5+
// Aggregate all providers by kind
6+
const providersByKind: Map<string, CodeActionProvider[]> = new Map([
7+
[CodeActionKind.QuickFix, quickfixProviders]
8+
// [CodeActionKind.Refactor, refactorProviders],
9+
// [CodeActionKind.Source, sourceProviders],
10+
// etc
11+
]);
12+
13+
export interface CodeActionConfig {
14+
// TODO: actionsMetadataProvider, fileProvider, etc.
15+
}
16+
17+
export interface CodeActionParams {
18+
uri: string;
19+
diagnostics: Diagnostic[];
20+
only?: string[];
21+
}
22+
23+
export function getCodeActions(params: CodeActionParams, config?: CodeActionConfig): CodeAction[] {
24+
const actions: CodeAction[] = [];
25+
const context: CodeActionContext = {
26+
uri: params.uri
27+
};
28+
29+
// Filter to requested kinds, or use all if none specified
30+
const requestedKinds = params.only;
31+
const kindsToCheck = requestedKinds
32+
? [...providersByKind.keys()].filter(kind => requestedKinds.some(requested => kind.startsWith(requested)))
33+
: [...providersByKind.keys()];
34+
35+
for (const diagnostic of params.diagnostics) {
36+
for (const kind of kindsToCheck) {
37+
const providers = providersByKind.get(kind) ?? [];
38+
for (const provider of providers) {
39+
if (provider.diagnosticCodes.includes(diagnostic.code)) {
40+
const action = provider.createCodeAction(context, diagnostic);
41+
if (action) {
42+
action.kind = kind;
43+
action.diagnostics = [diagnostic];
44+
actions.push(action);
45+
}
46+
}
47+
}
48+
}
49+
}
50+
51+
return actions;
52+
}
53+
54+
export type {CodeActionContext, CodeActionProvider} from "./types";
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import {CodeAction, TextEdit} from "vscode-languageserver-types";
2+
import {CodeActionContext, CodeActionProvider} from "../types";
3+
import {DiagnosticCode, MissingInputsDiagnosticData} from "../../validate-action";
4+
5+
export const addMissingInputsProvider: CodeActionProvider = {
6+
diagnosticCodes: [DiagnosticCode.MissingRequiredInputs],
7+
8+
createCodeAction(context, diagnostic): CodeAction | undefined {
9+
const data = diagnostic.data as MissingInputsDiagnosticData | undefined;
10+
if (!data) {
11+
return undefined;
12+
}
13+
14+
const edits = createInputEdits(data);
15+
if (!edits) {
16+
return undefined;
17+
}
18+
19+
const inputNames = data.missingInputs.map(i => i.name).join(", ");
20+
21+
return {
22+
title: `Add missing input${data.missingInputs.length > 1 ? "s" : ""}: ${inputNames}`,
23+
edit: {
24+
changes: {
25+
[context.uri]: edits
26+
}
27+
}
28+
};
29+
}
30+
};
31+
32+
function createInputEdits(data: MissingInputsDiagnosticData): TextEdit[] | undefined {
33+
const edits: TextEdit[] = [];
34+
35+
if (data.hasWithKey && data.withIndent !== undefined) {
36+
// `with:` exists - use its indentation + 2 for inputs
37+
const inputIndent = " ".repeat(data.withIndent + 2);
38+
39+
const inputLines = data.missingInputs.map(input => {
40+
const value = input.default !== undefined ? input.default : '""';
41+
return `${inputIndent}${input.name}: ${value}`;
42+
});
43+
44+
edits.push({
45+
range: {start: data.insertPosition, end: data.insertPosition},
46+
newText: inputLines.map(line => line + "\n").join("")
47+
});
48+
} else {
49+
// No `with:` key - `with:` at step indentation, inputs at step indentation + 2
50+
const withIndent = " ".repeat(data.stepIndent);
51+
const inputIndent = " ".repeat(data.stepIndent + 2);
52+
53+
const inputLines = data.missingInputs.map(input => {
54+
const value = input.default !== undefined ? input.default : '""';
55+
return `${inputIndent}${input.name}: ${value}`;
56+
});
57+
58+
const newText = [`${withIndent}with:\n`, ...inputLines.map(line => `${line}\n`)].join("");
59+
60+
edits.push({
61+
range: {start: data.insertPosition, end: data.insertPosition},
62+
newText
63+
});
64+
}
65+
66+
return edits;
67+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import {CodeActionProvider} from "../types";
2+
import {addMissingInputsProvider} from "./add-missing-inputs";
3+
4+
export const quickfixProviders: CodeActionProvider[] = [addMissingInputsProvider];
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import * as path from "path";
2+
import {fileURLToPath} from "url";
3+
import {loadTestCases, runTestCase} from "./runner";
4+
import {ValidationConfig} from "../../validate";
5+
import {ActionMetadata, ActionReference} from "../../action";
6+
import {clearCache} from "../../utils/workflow-cache";
7+
8+
// ESM-compatible __dirname
9+
const __filename = fileURLToPath(import.meta.url);
10+
const __dirname = path.dirname(__filename);
11+
12+
// Mock action metadata provider for tests
13+
const validationConfig: ValidationConfig = {
14+
actionsMetadataProvider: {
15+
fetchActionMetadata: (ref: ActionReference): Promise<ActionMetadata | undefined> => {
16+
const key = `${ref.owner}/${ref.name}@${ref.ref}`;
17+
18+
const metadata: Record<string, ActionMetadata> = {
19+
"actions/cache@v1": {
20+
name: "Cache",
21+
description: "Cache dependencies",
22+
inputs: {
23+
path: {
24+
description: "A list of files to cache",
25+
required: true
26+
},
27+
key: {
28+
description: "Cache key",
29+
required: true
30+
},
31+
"restore-keys": {
32+
description: "Restore keys",
33+
required: false
34+
}
35+
}
36+
},
37+
"actions/setup-node@v3": {
38+
name: "Setup Node",
39+
description: "Setup Node. js",
40+
inputs: {
41+
"node-version": {
42+
description: "Node version",
43+
required: true,
44+
default: "16"
45+
}
46+
}
47+
}
48+
};
49+
50+
return Promise.resolve(metadata[key]);
51+
}
52+
}
53+
};
54+
55+
// Point to the source testdata directory
56+
const testdataDir = path.join(__dirname, "testdata");
57+
58+
beforeEach(() => {
59+
clearCache();
60+
});
61+
62+
describe("code action golden tests", () => {
63+
const testCases = loadTestCases(testdataDir);
64+
65+
if (testCases.length === 0) {
66+
it.todo("no test cases found - add . yml files to testdata/");
67+
return;
68+
}
69+
70+
for (const testCase of testCases) {
71+
it(testCase.name, async () => {
72+
const result = await runTestCase(testCase, validationConfig);
73+
74+
if (!result.passed) {
75+
let errorMessage = result.error || "Test failed";
76+
77+
if (result.expected !== undefined && result.actual !== undefined) {
78+
errorMessage += "\n\n";
79+
errorMessage += "=== EXPECTED (golden file) ===\n";
80+
errorMessage += result.expected;
81+
errorMessage += "\n\n";
82+
errorMessage += "=== ACTUAL ===\n";
83+
errorMessage += result.actual;
84+
}
85+
86+
throw new Error(errorMessage);
87+
}
88+
});
89+
}
90+
});

0 commit comments

Comments
 (0)