Skip to content

Commit 0fe31c6

Browse files
rentziasssalmanmkc
andauthored
Setup CodeActions and add quickfix for missing inputs (#254)
* Setup CodeActions and add quickfix for missing inputs * PR feedback * Update languageservice/src/code-actions/quickfix/add-missing-inputs.ts Co-authored-by: Salman Chishti <salmanmkc@GitHub.com> * Fix indentSize detection for code actions after rebase - Add indentSize to MissingInputsDiagnosticData interface - Pass indentSize parameter from validate.ts to validateActionReference - Detect indentSize from workflow structure (jobs key to first child) - Fall back to detecting from with: block children when available * update typescript * formatting * linting * Gate missing inputs quickfix behind feature flag * Address PR review: rename files, move position calculation to quickfix - Rename index.ts files to follow repo patterns: - code-actions/index.ts → code-actions/code-actions.ts - code-actions/quickfix/index.ts → quickfix/quickfix-providers.ts - Move position calculation from validation to quickfix: - MissingInputsDiagnosticData now passes raw token ranges - Quickfix computes insertion position and indentation at code action time - detectIndentSize moved from validate.ts to validate-action-reference.ts * wip * Remove pointless comment --------- Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
1 parent 67dd4fb commit 0fe31c6

19 files changed

+841
-9
lines changed

languageserver/src/connection.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
1-
import {documentLinks, getInlayHints, hover, validate, ValidationConfig} from "@actions/languageservice";
1+
import {
2+
documentLinks,
3+
getCodeActions,
4+
getInlayHints,
5+
hover,
6+
validate,
7+
ValidationConfig
8+
} from "@actions/languageservice";
29
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
310
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
411
import {Octokit} from "@octokit/rest";
512
import {
13+
CodeAction,
14+
CodeActionKind,
15+
CodeActionParams,
616
CompletionItem,
717
Connection,
818
DocumentLink,
@@ -79,7 +89,10 @@ export function initConnection(connection: Connection) {
7989
documentLinkProvider: {
8090
resolveProvider: false
8191
},
82-
inlayHintProvider: true
92+
inlayHintProvider: true,
93+
codeActionProvider: {
94+
codeActionKinds: [CodeActionKind.QuickFix]
95+
}
8396
}
8497
};
8598

@@ -177,6 +190,17 @@ export function initConnection(connection: Connection) {
177190
});
178191
});
179192

193+
connection.onCodeAction((params: CodeActionParams): CodeAction[] => {
194+
const document = getDocument(documents, params.textDocument);
195+
return getCodeActions({
196+
uri: params.textDocument.uri,
197+
documentContent: document.getText(),
198+
diagnostics: params.context.diagnostics,
199+
only: params.context.only,
200+
featureFlags
201+
});
202+
});
203+
180204
// Make the text document manager listen on the connection
181205
// for open, change and close text document events
182206
documents.listen(connection);
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import {FeatureFlags} from "@actions/expressions";
2+
import {CodeAction, CodeActionKind, Diagnostic} from "vscode-languageserver-types";
3+
import {CodeActionContext, CodeActionProvider} from "./types.js";
4+
import {getQuickfixProviders} from "./quickfix/quickfix-providers.js";
5+
6+
export interface CodeActionParams {
7+
uri: string;
8+
documentContent: string;
9+
diagnostics: Diagnostic[];
10+
only?: string[];
11+
featureFlags?: FeatureFlags;
12+
}
13+
14+
export function getCodeActions(params: CodeActionParams): CodeAction[] {
15+
const actions: CodeAction[] = [];
16+
const context: CodeActionContext = {
17+
uri: params.uri,
18+
documentContent: params.documentContent,
19+
featureFlags: params.featureFlags
20+
};
21+
22+
// Build providers map based on feature flags
23+
const providersByKind: Map<string, CodeActionProvider[]> = new Map([
24+
[CodeActionKind.QuickFix, getQuickfixProviders(params.featureFlags)]
25+
// [CodeActionKind.Refactor, getRefactorProviders(params.featureFlags)],
26+
// [CodeActionKind.Source, getSourceProviders(params.featureFlags)],
27+
// etc
28+
]);
29+
30+
// Filter to requested kinds, or use all if none specified
31+
const requestedKinds = params.only;
32+
const kindsToCheck = requestedKinds
33+
? [...providersByKind.keys()].filter(kind => requestedKinds.some(requested => kind.startsWith(requested)))
34+
: [...providersByKind.keys()];
35+
36+
for (const diagnostic of params.diagnostics) {
37+
for (const kind of kindsToCheck) {
38+
const providers = providersByKind.get(kind) ?? [];
39+
for (const provider of providers) {
40+
if (provider.diagnosticCodes.includes(diagnostic.code)) {
41+
const action = provider.createCodeAction(context, diagnostic);
42+
if (action) {
43+
action.kind = kind;
44+
action.diagnostics = [diagnostic];
45+
actions.push(action);
46+
}
47+
}
48+
}
49+
}
50+
}
51+
52+
return actions;
53+
}
54+
55+
export type {CodeActionContext, CodeActionProvider} from "./types.js";
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import {isMapping} from "@actions/workflow-parser";
2+
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
3+
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
4+
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
5+
import {CodeAction, Position, TextEdit} from "vscode-languageserver-types";
6+
import {error} from "../../log.js";
7+
import {findToken} from "../../utils/find-token.js";
8+
import {getOrParseWorkflow} from "../../utils/workflow-cache.js";
9+
import {DiagnosticCode, MissingInputsDiagnosticData} from "../../validate-action-reference.js";
10+
import {CodeActionContext, CodeActionProvider} from "../types.js";
11+
12+
/**
13+
* Information extracted from a step token needed to generate edits
14+
*/
15+
interface StepInfo {
16+
/** Column where step keys start (1-indexed), e.g., the column of "uses:" */
17+
stepKeyColumn: number;
18+
/** End line of the step (1-indexed) */
19+
stepEndLine: number;
20+
/** Detected indent size (spaces per level) */
21+
indentSize: number;
22+
/** Information about existing with: block, if present */
23+
withInfo?: {
24+
keyColumn: number;
25+
keyEndLine: number;
26+
valueEndLine: number;
27+
hasChildren: boolean;
28+
/** Column of first child input (1-indexed), for indentation detection */
29+
firstChildColumn?: number;
30+
};
31+
}
32+
33+
export const addMissingInputsProvider: CodeActionProvider = {
34+
diagnosticCodes: [DiagnosticCode.MissingRequiredInputs],
35+
36+
createCodeAction(context: CodeActionContext, diagnostic): CodeAction | undefined {
37+
const data = diagnostic.data as MissingInputsDiagnosticData | undefined;
38+
if (!data) {
39+
return undefined;
40+
}
41+
42+
// Parse the document to get the step token
43+
const stepInfo = getStepInfo(context, diagnostic.range.start);
44+
if (!stepInfo) {
45+
return undefined;
46+
}
47+
48+
const edits = createInputEdits(data.missingInputs, stepInfo);
49+
if (!edits || edits.length === 0) {
50+
return undefined;
51+
}
52+
53+
const inputNames = data.missingInputs.map(i => i.name).join(", ");
54+
55+
return {
56+
title: `Add missing input${data.missingInputs.length > 1 ? "s" : ""}: ${inputNames}`,
57+
edit: {
58+
changes: {
59+
[context.uri]: edits
60+
}
61+
}
62+
};
63+
}
64+
};
65+
66+
/**
67+
* Parse the document and extract step information needed for generating edits.
68+
* Returns undefined if parsing fails or the step token cannot be found.
69+
*/
70+
function getStepInfo(context: CodeActionContext, diagnosticPosition: Position): StepInfo | undefined {
71+
// Parse the document (uses cache if available from validation)
72+
const file = {name: context.uri, content: context.documentContent};
73+
const parseResult = getOrParseWorkflow(file, context.uri);
74+
75+
if (!parseResult.value) {
76+
error("Failed to parse workflow for missing inputs quickfix");
77+
return undefined;
78+
}
79+
80+
// Find the token at the diagnostic position
81+
const {path} = findToken(diagnosticPosition, parseResult.value);
82+
83+
// Walk up the path to find the step token (regular-step)
84+
const stepToken = findStepInPath(path);
85+
if (!stepToken) {
86+
error("Could not find step token for missing inputs quickfix");
87+
return undefined;
88+
}
89+
90+
return extractStepInfo(stepToken);
91+
}
92+
93+
/**
94+
* Find the step token (regular-step) in the token path
95+
*/
96+
function findStepInPath(path: TemplateToken[]): MappingToken | undefined {
97+
// Walk backwards through path to find the step
98+
for (let i = path.length - 1; i >= 0; i--) {
99+
if (path[i].definition?.key === "regular-step" && isMapping(path[i])) {
100+
return path[i] as MappingToken;
101+
}
102+
}
103+
return undefined;
104+
}
105+
106+
/**
107+
* Extract position and indentation info from a step token
108+
*/
109+
function extractStepInfo(stepToken: MappingToken): StepInfo | undefined {
110+
if (!stepToken.range) {
111+
return undefined;
112+
}
113+
114+
// Get the column of the first key in the step
115+
let stepKeyColumn = stepToken.range.start.column;
116+
if (stepToken.count > 0) {
117+
const firstEntry = stepToken.get(0);
118+
if (firstEntry?.key.range) {
119+
stepKeyColumn = firstEntry.key.range.start.column;
120+
}
121+
}
122+
123+
// Find the with: block if present
124+
let withKey: ScalarToken | undefined;
125+
let withToken: TemplateToken | undefined;
126+
for (const {key, value} of stepToken) {
127+
if (key.toString() === "with") {
128+
withKey = key;
129+
withToken = value;
130+
break;
131+
}
132+
}
133+
134+
// Calculate indent size
135+
let indentSize = 2; // Default
136+
let withInfo: StepInfo["withInfo"];
137+
138+
if (withKey?.range && withToken?.range) {
139+
// Has with: block - extract its info
140+
const hasChildren = isMapping(withToken) && withToken.count > 0;
141+
let firstChildColumn: number | undefined;
142+
143+
if (hasChildren) {
144+
const firstChild = (withToken as MappingToken).get(0);
145+
if (firstChild?.key.range) {
146+
firstChildColumn = firstChild.key.range.start.column;
147+
// Detect indent size from with: children
148+
indentSize = firstChildColumn - withKey.range.start.column;
149+
}
150+
}
151+
152+
withInfo = {
153+
keyColumn: withKey.range.start.column,
154+
keyEndLine: withKey.range.end.line,
155+
valueEndLine: withToken.range.end.line,
156+
hasChildren,
157+
firstChildColumn
158+
};
159+
} else {
160+
// No with: block - detect indent size using heuristics
161+
// Based on the step key column position, estimate indent size
162+
// 2-space indent files typically have step keys at column 7
163+
// 4-space indent files typically have step keys at column 15
164+
const zeroIndexedCol = stepKeyColumn - 1;
165+
if (zeroIndexedCol >= 10) {
166+
indentSize = 4;
167+
}
168+
}
169+
170+
return {
171+
stepKeyColumn,
172+
stepEndLine: stepToken.range.end.line,
173+
indentSize,
174+
withInfo
175+
};
176+
}
177+
178+
/**
179+
* Generate text edits to add missing inputs
180+
*/
181+
function createInputEdits(missingInputs: MissingInputsDiagnosticData["missingInputs"], stepInfo: StepInfo): TextEdit[] {
182+
const formatInputLines = (indent: string) =>
183+
missingInputs.map(input => {
184+
const value = input.default ?? '""';
185+
return `${indent}${input.name}: ${value}`;
186+
});
187+
188+
if (stepInfo.withInfo) {
189+
// `with:` exists - add inputs to existing block
190+
const withIndent = stepInfo.withInfo.keyColumn - 1; // 0-indexed
191+
const inputIndentSize = stepInfo.withInfo.firstChildColumn
192+
? stepInfo.withInfo.firstChildColumn - stepInfo.withInfo.keyColumn
193+
: stepInfo.indentSize;
194+
195+
const inputIndent = " ".repeat(withIndent + inputIndentSize);
196+
const inputLines = formatInputLines(inputIndent);
197+
198+
// Calculate insert position
199+
let insertLine: number;
200+
if (stepInfo.withInfo.hasChildren) {
201+
// Insert after the last child (at end of with: block)
202+
// valueEndLine is 1-indexed, we want 0-indexed for Position
203+
insertLine = stepInfo.withInfo.valueEndLine - 1;
204+
} else {
205+
// Empty with: block - insert on the next line after with:
206+
// keyEndLine is 1-indexed, convert to 0-indexed and go to next line
207+
insertLine = stepInfo.withInfo.keyEndLine;
208+
}
209+
210+
const insertPosition: Position = {
211+
line: insertLine,
212+
character: 0
213+
};
214+
215+
return [
216+
{
217+
range: {start: insertPosition, end: insertPosition},
218+
newText: inputLines.map(line => line + "\n").join("")
219+
}
220+
];
221+
} else {
222+
// No `with:` key - add `with:` at the same level as other step keys
223+
const withKeyIndent = stepInfo.stepKeyColumn - 1; // 0-indexed (columns are 1-based)
224+
225+
const withIndent = " ".repeat(withKeyIndent);
226+
const inputIndent = " ".repeat(withKeyIndent + stepInfo.indentSize);
227+
const inputLines = formatInputLines(inputIndent);
228+
229+
const newText = `${withIndent}with:\n` + inputLines.map(line => `${line}\n`).join("");
230+
231+
// Insert at end of step
232+
// stepEndLine is 1-indexed, we want 0-indexed and insert before the line after
233+
const insertPosition: Position = {
234+
line: stepInfo.stepEndLine - 1,
235+
character: 0
236+
};
237+
238+
return [
239+
{
240+
range: {start: insertPosition, end: insertPosition},
241+
newText
242+
}
243+
];
244+
}
245+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import {FeatureFlags} from "@actions/expressions";
2+
import {CodeActionProvider} from "../types.js";
3+
import {addMissingInputsProvider} from "./add-missing-inputs.js";
4+
5+
export function getQuickfixProviders(featureFlags?: FeatureFlags): CodeActionProvider[] {
6+
const providers: CodeActionProvider[] = [];
7+
8+
if (featureFlags?.isEnabled("missingInputsQuickfix")) {
9+
providers.push(addMissingInputsProvider);
10+
}
11+
12+
return providers;
13+
}

0 commit comments

Comments
 (0)