diff --git a/languageserver/src/connection.ts b/languageserver/src/connection.ts index d0a7ede3..8d19019e 100644 --- a/languageserver/src/connection.ts +++ b/languageserver/src/connection.ts @@ -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, @@ -79,7 +89,10 @@ export function initConnection(connection: Connection) { documentLinkProvider: { resolveProvider: false }, - inlayHintProvider: true + inlayHintProvider: true, + codeActionProvider: { + codeActionKinds: [CodeActionKind.QuickFix] + } } }; @@ -176,6 +189,17 @@ export function initConnection(connection: Connection) { }); }); + connection.onCodeAction((params: CodeActionParams): CodeAction[] => { + const document = getDocument(documents, params.textDocument); + return getCodeActions({ + uri: params.textDocument.uri, + documentContent: document.getText(), + 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); diff --git a/languageservice/src/code-actions/code-actions.ts b/languageservice/src/code-actions/code-actions.ts new file mode 100644 index 00000000..6e9f4330 --- /dev/null +++ b/languageservice/src/code-actions/code-actions.ts @@ -0,0 +1,55 @@ +import {FeatureFlags} from "@actions/expressions"; +import {CodeAction, CodeActionKind, Diagnostic} from "vscode-languageserver-types"; +import {CodeActionContext, CodeActionProvider} from "./types.js"; +import {getQuickfixProviders} from "./quickfix/quickfix-providers.js"; + +export interface CodeActionParams { + uri: string; + documentContent: string; + diagnostics: Diagnostic[]; + only?: string[]; + featureFlags?: FeatureFlags; +} + +export function getCodeActions(params: CodeActionParams): CodeAction[] { + const actions: CodeAction[] = []; + const context: CodeActionContext = { + uri: params.uri, + documentContent: params.documentContent, + featureFlags: params.featureFlags + }; + + // Build providers map based on feature flags + const providersByKind: Map = 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"; diff --git a/languageservice/src/code-actions/quickfix/add-missing-inputs.ts b/languageservice/src/code-actions/quickfix/add-missing-inputs.ts new file mode 100644 index 00000000..c416cccd --- /dev/null +++ b/languageservice/src/code-actions/quickfix/add-missing-inputs.ts @@ -0,0 +1,245 @@ +import {isMapping} from "@actions/workflow-parser"; +import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token"; +import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token"; +import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token"; +import {CodeAction, Position, TextEdit} from "vscode-languageserver-types"; +import {error} from "../../log.js"; +import {findToken} from "../../utils/find-token.js"; +import {getOrParseWorkflow} from "../../utils/workflow-cache.js"; +import {DiagnosticCode, MissingInputsDiagnosticData} from "../../validate-action-reference.js"; +import {CodeActionContext, CodeActionProvider} from "../types.js"; + +/** + * Information extracted from a step token needed to generate edits + */ +interface StepInfo { + /** Column where step keys start (1-indexed), e.g., the column of "uses:" */ + stepKeyColumn: number; + /** End line of the step (1-indexed) */ + stepEndLine: number; + /** Detected indent size (spaces per level) */ + indentSize: number; + /** Information about existing with: block, if present */ + withInfo?: { + keyColumn: number; + keyEndLine: number; + valueEndLine: number; + hasChildren: boolean; + /** Column of first child input (1-indexed), for indentation detection */ + firstChildColumn?: number; + }; +} + +export const addMissingInputsProvider: CodeActionProvider = { + diagnosticCodes: [DiagnosticCode.MissingRequiredInputs], + + createCodeAction(context: CodeActionContext, diagnostic): CodeAction | undefined { + const data = diagnostic.data as MissingInputsDiagnosticData | undefined; + if (!data) { + return undefined; + } + + // Parse the document to get the step token + const stepInfo = getStepInfo(context, diagnostic.range.start); + if (!stepInfo) { + return undefined; + } + + const edits = createInputEdits(data.missingInputs, stepInfo); + if (!edits || edits.length === 0) { + 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 + } + } + }; + } +}; + +/** + * Parse the document and extract step information needed for generating edits. + * Returns undefined if parsing fails or the step token cannot be found. + */ +function getStepInfo(context: CodeActionContext, diagnosticPosition: Position): StepInfo | undefined { + // Parse the document (uses cache if available from validation) + const file = {name: context.uri, content: context.documentContent}; + const parseResult = getOrParseWorkflow(file, context.uri); + + if (!parseResult.value) { + error("Failed to parse workflow for missing inputs quickfix"); + return undefined; + } + + // Find the token at the diagnostic position + const {path} = findToken(diagnosticPosition, parseResult.value); + + // Walk up the path to find the step token (regular-step) + const stepToken = findStepInPath(path); + if (!stepToken) { + error("Could not find step token for missing inputs quickfix"); + return undefined; + } + + return extractStepInfo(stepToken); +} + +/** + * Find the step token (regular-step) in the token path + */ +function findStepInPath(path: TemplateToken[]): MappingToken | undefined { + // Walk backwards through path to find the step + for (let i = path.length - 1; i >= 0; i--) { + if (path[i].definition?.key === "regular-step" && isMapping(path[i])) { + return path[i] as MappingToken; + } + } + return undefined; +} + +/** + * Extract position and indentation info from a step token + */ +function extractStepInfo(stepToken: MappingToken): StepInfo | undefined { + if (!stepToken.range) { + return undefined; + } + + // Get the column of the first key in the step + let stepKeyColumn = stepToken.range.start.column; + if (stepToken.count > 0) { + const firstEntry = stepToken.get(0); + if (firstEntry?.key.range) { + stepKeyColumn = firstEntry.key.range.start.column; + } + } + + // Find the with: block if present + let withKey: ScalarToken | undefined; + let withToken: TemplateToken | undefined; + for (const {key, value} of stepToken) { + if (key.toString() === "with") { + withKey = key; + withToken = value; + break; + } + } + + // Calculate indent size + let indentSize = 2; // Default + let withInfo: StepInfo["withInfo"]; + + if (withKey?.range && withToken?.range) { + // Has with: block - extract its info + const hasChildren = isMapping(withToken) && withToken.count > 0; + let firstChildColumn: number | undefined; + + if (hasChildren) { + const firstChild = (withToken as MappingToken).get(0); + if (firstChild?.key.range) { + firstChildColumn = firstChild.key.range.start.column; + // Detect indent size from with: children + indentSize = firstChildColumn - withKey.range.start.column; + } + } + + withInfo = { + keyColumn: withKey.range.start.column, + keyEndLine: withKey.range.end.line, + valueEndLine: withToken.range.end.line, + hasChildren, + firstChildColumn + }; + } else { + // No with: block - detect indent size using heuristics + // Based on the step key column position, estimate indent size + // 2-space indent files typically have step keys at column 7 + // 4-space indent files typically have step keys at column 15 + const zeroIndexedCol = stepKeyColumn - 1; + if (zeroIndexedCol >= 10) { + indentSize = 4; + } + } + + return { + stepKeyColumn, + stepEndLine: stepToken.range.end.line, + indentSize, + withInfo + }; +} + +/** + * Generate text edits to add missing inputs + */ +function createInputEdits(missingInputs: MissingInputsDiagnosticData["missingInputs"], stepInfo: StepInfo): TextEdit[] { + const formatInputLines = (indent: string) => + missingInputs.map(input => { + const value = input.default ?? '""'; + return `${indent}${input.name}: ${value}`; + }); + + if (stepInfo.withInfo) { + // `with:` exists - add inputs to existing block + const withIndent = stepInfo.withInfo.keyColumn - 1; // 0-indexed + const inputIndentSize = stepInfo.withInfo.firstChildColumn + ? stepInfo.withInfo.firstChildColumn - stepInfo.withInfo.keyColumn + : stepInfo.indentSize; + + const inputIndent = " ".repeat(withIndent + inputIndentSize); + const inputLines = formatInputLines(inputIndent); + + // Calculate insert position + let insertLine: number; + if (stepInfo.withInfo.hasChildren) { + // Insert after the last child (at end of with: block) + // valueEndLine is 1-indexed, we want 0-indexed for Position + insertLine = stepInfo.withInfo.valueEndLine - 1; + } else { + // Empty with: block - insert on the next line after with: + // keyEndLine is 1-indexed, convert to 0-indexed and go to next line + insertLine = stepInfo.withInfo.keyEndLine; + } + + const insertPosition: Position = { + line: insertLine, + character: 0 + }; + + return [ + { + range: {start: insertPosition, end: insertPosition}, + newText: inputLines.map(line => line + "\n").join("") + } + ]; + } else { + // No `with:` key - add `with:` at the same level as other step keys + const withKeyIndent = stepInfo.stepKeyColumn - 1; // 0-indexed (columns are 1-based) + + const withIndent = " ".repeat(withKeyIndent); + const inputIndent = " ".repeat(withKeyIndent + stepInfo.indentSize); + const inputLines = formatInputLines(inputIndent); + + const newText = `${withIndent}with:\n` + inputLines.map(line => `${line}\n`).join(""); + + // Insert at end of step + // stepEndLine is 1-indexed, we want 0-indexed and insert before the line after + const insertPosition: Position = { + line: stepInfo.stepEndLine - 1, + character: 0 + }; + + return [ + { + range: {start: insertPosition, end: insertPosition}, + newText + } + ]; + } +} diff --git a/languageservice/src/code-actions/quickfix/quickfix-providers.ts b/languageservice/src/code-actions/quickfix/quickfix-providers.ts new file mode 100644 index 00000000..726cbac8 --- /dev/null +++ b/languageservice/src/code-actions/quickfix/quickfix-providers.ts @@ -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; +} diff --git a/languageservice/src/code-actions/tests/runner.test.ts b/languageservice/src/code-actions/tests/runner.test.ts new file mode 100644 index 00000000..595515e3 --- /dev/null +++ b/languageservice/src/code-actions/tests/runner.test.ts @@ -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 => { + const key = `${ref.owner}/${ref.name}@${ref.ref}`; + + const metadata: Record = { + "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" + } + } + } + }; + + 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); + } + }); + } +}); diff --git a/languageservice/src/code-actions/tests/runner.ts b/languageservice/src/code-actions/tests/runner.ts new file mode 100644 index 00000000..06a1598d --- /dev/null +++ b/languageservice/src/code-actions/tests/runner.ts @@ -0,0 +1,231 @@ +import * as fs from "fs"; +import * as path from "path"; +import {TextEdit} from "vscode-languageserver-types"; +import {TextDocument} from "vscode-languageserver-textdocument"; +import {FeatureFlags} from "@actions/expressions"; +import {validate, ValidationConfig} from "../../validate.js"; +import {getCodeActions, CodeActionParams} from "../code-actions.js"; + +// Marker pattern: # want "diagnostic message" fix="code-action-name" +const MARKER_PATTERN = /#\s*want\s+"([^"]+)"(?:\s+fix="([^"]+)")?/; + +export interface TestCase { + name: string; + inputPath: string; + goldenPath: string; + input: string; + golden: string; + markers: Marker[]; +} + +export interface Marker { + line: number; + message: string; + fix?: string; +} + +export interface TestResult { + name: string; + passed: boolean; + error?: string; + expected?: string; + actual?: string; +} + +/** + * Parse markers from input file content + */ +export function parseMarkers(content: string): Marker[] { + const lines = content.split("\n"); + const markers: Marker[] = []; + + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(MARKER_PATTERN); + if (match) { + markers.push({ + line: i, + message: match[1], + fix: match[2] + }); + } + } + + return markers; +} + +/** + * Strip markers from content (for processing) + */ +export function stripMarkers(content: string): string { + return content + .split("\n") + .map(line => line.replace(MARKER_PATTERN, "").trimEnd()) + .join("\n"); +} + +/** + * Load all test cases from a testdata directory + */ +export function loadTestCases(testdataDir: string): TestCase[] { + const testCases: TestCase[] = []; + + function walkDir(dir: string) { + const entries = fs.readdirSync(dir, {withFileTypes: true}); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + walkDir(fullPath); + } else if (entry.isFile() && entry.name.endsWith(".yml") && !entry.name.endsWith(".golden.yml")) { + const goldenPath = fullPath.replace(".yml", ".golden.yml"); + + if (fs.existsSync(goldenPath)) { + const input = fs.readFileSync(fullPath, "utf-8"); + const golden = fs.readFileSync(goldenPath, "utf-8"); + + testCases.push({ + name: path.relative(testdataDir, fullPath), + inputPath: fullPath, + goldenPath, + input, + golden, + markers: parseMarkers(input) + }); + } + } + } + } + + walkDir(testdataDir); + return testCases; +} + +/** + * Apply text edits to a document + */ +export function applyEdits(content: string, edits: TextEdit[]): string { + // Sort edits in reverse order by position to apply from bottom to top + const sortedEdits = [...edits].sort((a, b) => { + if (b.range.start.line !== a.range.start.line) { + return b.range.start.line - a.range.start.line; + } + return b.range.start.character - a.range.start.character; + }); + + const lines = content.split("\n"); + + for (const edit of sortedEdits) { + const startLine = edit.range.start.line; + const startChar = edit.range.start.character; + const endLine = edit.range.end.line; + const endChar = edit.range.end.character; + + const before = lines[startLine].slice(0, startChar); + const after = lines[endLine].slice(endChar); + + const newLines = edit.newText.split("\n"); + newLines[0] = before + newLines[0]; + newLines[newLines.length - 1] = newLines[newLines.length - 1] + after; + + lines.splice(startLine, endLine - startLine + 1, ...newLines); + } + + return lines.join("\n"); +} + +/** + * Run a single test case + */ +export async function runTestCase(testCase: TestCase, validationConfig: ValidationConfig): Promise { + const strippedInput = stripMarkers(testCase.input); + const document = TextDocument.create("file:///test.yml", "yaml", 1, strippedInput); + + // 1. Validate and get diagnostics + const diagnostics = await validate(document, validationConfig); + + // 2. Verify all expected diagnostics are present + const missingDiagnostics: string[] = []; + for (const marker of testCase.markers) { + const found = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message)); + if (!found) { + missingDiagnostics.push(`line ${marker.line}: "${marker.message}"`); + } + } + + if (missingDiagnostics.length > 0) { + return { + name: testCase.name, + passed: false, + error: `Missing expected diagnostics:\n ${missingDiagnostics.join( + "\n " + )}\n\nActual diagnostics:\n ${diagnostics.map(d => `line ${d.range.start.line}: "${d.message}"`).join("\n ")}` + }; + } + + // 3. Collect all edits from all matching code actions + const allEdits: TextEdit[] = []; + + for (const marker of testCase.markers) { + if (!marker.fix) { + continue; + } + + const diagnostic = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message)); + + if (!diagnostic) { + continue; // Already reported above + } + + const params: CodeActionParams = { + uri: document.uri, + documentContent: strippedInput, + diagnostics: [diagnostic], + featureFlags: new FeatureFlags({all: true}) + }; + + const actions = getCodeActions(params); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- marker.fix is checked at the start of the loop + const matchingAction = actions.find(a => a.title.toLowerCase().includes(marker.fix!.toLowerCase())); + + if (!matchingAction) { + return { + name: testCase.name, + passed: false, + error: `Code action "${marker.fix}" not found for diagnostic on line ${marker.line}.\nAvailable actions: ${ + actions.map(a => a.title).join(", ") || "(none)" + }` + }; + } + + if (!matchingAction.edit?.changes) { + return { + name: testCase.name, + passed: false, + error: `Code action "${marker.fix}" has no edits` + }; + } + + const edits = matchingAction.edit.changes[document.uri] || []; + allEdits.push(...edits); + } + + // 4. Apply all edits and compare to golden file + const actualOutput = applyEdits(strippedInput, allEdits); + const expectedOutput = testCase.golden; + + if (actualOutput.trim() !== expectedOutput.trim()) { + return { + name: testCase.name, + passed: false, + error: "Output does not match golden file", + expected: expectedOutput, + actual: actualOutput + }; + } + + return { + name: testCase.name, + passed: true + }; +} diff --git a/languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key-without-inputs.golden.yml b/languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key-without-inputs.golden.yml new file mode 100644 index 00000000..c6f6411b --- /dev/null +++ b/languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key-without-inputs.golden.yml @@ -0,0 +1,9 @@ +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/cache@v1 + with: + path: "" + key: "" diff --git a/languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key-without-inputs.yml b/languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key-without-inputs.yml new file mode 100644 index 00000000..50fa2f12 --- /dev/null +++ b/languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key-without-inputs.yml @@ -0,0 +1,7 @@ +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/cache@v1 + with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key" diff --git a/languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key.golden.yml b/languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key.golden.yml new file mode 100644 index 00000000..81bca135 --- /dev/null +++ b/languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key.golden.yml @@ -0,0 +1,10 @@ +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/cache@v1 + with: + restore-keys: ${{ runner.os }}- + path: "" + key: "" diff --git a/languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key.yml b/languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key.yml new file mode 100644 index 00000000..a8f993d5 --- /dev/null +++ b/languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key.yml @@ -0,0 +1,8 @@ +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/cache@v1 + with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key" + restore-keys: ${{ runner.os }}- diff --git a/languageservice/src/code-actions/tests/testdata/quickfix/four-space-indent.golden.yml b/languageservice/src/code-actions/tests/testdata/quickfix/four-space-indent.golden.yml new file mode 100644 index 00000000..fd6ca855 --- /dev/null +++ b/languageservice/src/code-actions/tests/testdata/quickfix/four-space-indent.golden.yml @@ -0,0 +1,9 @@ +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/cache@v1 + with: + path: "" + key: "" diff --git a/languageservice/src/code-actions/tests/testdata/quickfix/four-space-indent.yml b/languageservice/src/code-actions/tests/testdata/quickfix/four-space-indent.yml new file mode 100644 index 00000000..9fb635da --- /dev/null +++ b/languageservice/src/code-actions/tests/testdata/quickfix/four-space-indent.yml @@ -0,0 +1,6 @@ +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/cache@v1 # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key" diff --git a/languageservice/src/code-actions/tests/testdata/quickfix/no-with-key.golden.yml b/languageservice/src/code-actions/tests/testdata/quickfix/no-with-key.golden.yml new file mode 100644 index 00000000..c6f6411b --- /dev/null +++ b/languageservice/src/code-actions/tests/testdata/quickfix/no-with-key.golden.yml @@ -0,0 +1,9 @@ +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/cache@v1 + with: + path: "" + key: "" diff --git a/languageservice/src/code-actions/tests/testdata/quickfix/no-with-key.yml b/languageservice/src/code-actions/tests/testdata/quickfix/no-with-key.yml new file mode 100644 index 00000000..bf3fff23 --- /dev/null +++ b/languageservice/src/code-actions/tests/testdata/quickfix/no-with-key.yml @@ -0,0 +1,6 @@ +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/cache@v1 # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key" diff --git a/languageservice/src/code-actions/types.ts b/languageservice/src/code-actions/types.ts new file mode 100644 index 00000000..98fd351b --- /dev/null +++ b/languageservice/src/code-actions/types.ts @@ -0,0 +1,23 @@ +import {FeatureFlags} from "@actions/expressions"; +import {CodeAction, Diagnostic} from "vscode-languageserver-types"; + +export interface CodeActionContext { + uri: string; + documentContent: string; + featureFlags?: FeatureFlags; +} + +/** + * A provider that can produce a code action for a given diagnostic + */ +export interface CodeActionProvider { + /** + * The diagnostic codes this provider handles + */ + diagnosticCodes: (string | number | undefined)[]; + + /** + * Create a code action for the diagnostic, if applicable + */ + createCodeAction(context: CodeActionContext, diagnostic: Diagnostic): CodeAction | undefined; +} diff --git a/languageservice/src/index.ts b/languageservice/src/index.ts index 20c5c76d..c3f252c3 100644 --- a/languageservice/src/index.ts +++ b/languageservice/src/index.ts @@ -6,3 +6,4 @@ export {getInlayHints} from "./inlay-hints.js"; export {Logger, LogLevel, registerLogger, setLogLevel} from "./log.js"; export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate.js"; export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js"; +export {getCodeActions, CodeActionParams} from "./code-actions/code-actions.js"; diff --git a/languageservice/src/validate-action-reference.test.ts b/languageservice/src/validate-action-reference.test.ts index 46a39a90..1be5f478 100644 --- a/languageservice/src/validate-action-reference.test.ts +++ b/languageservice/src/validate-action-reference.test.ts @@ -249,7 +249,21 @@ jobs: line: 7 } }, - severity: DiagnosticSeverity.Error + severity: DiagnosticSeverity.Error, + code: "missing-required-inputs", + data: { + action: { + name: "cache", + owner: "actions", + ref: "v1" + }, + missingInputs: [ + { + default: undefined, + name: "path" + } + ] + } } ]); }); @@ -294,7 +308,25 @@ jobs: line: 7 } }, - severity: DiagnosticSeverity.Error + severity: DiagnosticSeverity.Error, + code: "missing-required-inputs", + data: { + action: { + name: "cache", + owner: "actions", + ref: "v1" + }, + missingInputs: [ + { + default: undefined, + name: "path" + }, + { + default: undefined, + name: "key" + } + ] + } } ]); }); @@ -323,7 +355,25 @@ jobs: line: 6 } }, - severity: DiagnosticSeverity.Error + severity: DiagnosticSeverity.Error, + code: "missing-required-inputs", + data: { + action: { + name: "cache", + owner: "actions", + ref: "v1" + }, + missingInputs: [ + { + default: undefined, + name: "path" + }, + { + default: undefined, + name: "key" + } + ] + } } ]); }); diff --git a/languageservice/src/validate-action-reference.ts b/languageservice/src/validate-action-reference.ts index fd35e58f..7c106587 100644 --- a/languageservice/src/validate-action-reference.ts +++ b/languageservice/src/validate-action-reference.ts @@ -4,10 +4,22 @@ import {Step} from "@actions/workflow-parser/model/workflow-template"; import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token"; import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token"; import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types"; -import {parseActionReference} from "./action.js"; +import {ActionReference, parseActionReference} from "./action.js"; import {mapRange} from "./utils/range.js"; import {ValidationConfig} from "./validate.js"; +export const DiagnosticCode = { + MissingRequiredInputs: "missing-required-inputs" +} as const; + +export interface MissingInputsDiagnosticData { + action: ActionReference; + missingInputs: Array<{ + name: string; + default?: string; + }>; +} + /** * Validates action references in workflow steps, checking for valid inputs and required inputs. */ @@ -94,10 +106,22 @@ export async function validateActionReference( missingRequiredInputs.length === 1 ? `Missing required input \`${missingRequiredInputs[0][0]}\`` : `Missing required inputs: ${missingRequiredInputs.map(input => `\`${input[0]}\``).join(", ")}`; + + // Build minimal diagnostic data - position calculation happens in the quickfix + const diagnosticData: MissingInputsDiagnosticData = { + action, + missingInputs: missingRequiredInputs.map(([name, input]) => ({ + name, + default: input.default + })) + }; + diagnostics.push({ severity: DiagnosticSeverity.Error, - range: mapRange((withKey || stepToken).range), // Highlight the whole step if we don't have a with key - message: message + range: mapRange((withKey || stepToken).range), + message: message, + code: DiagnosticCode.MissingRequiredInputs, + data: diagnosticData }); } } diff --git a/package-lock.json b/package-lock.json index d3df8286..f7a1f2b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -480,6 +480,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.5.tgz", "integrity": "sha512-vvmsN0r7rguA+FySiCsbaTTobSftpIDIpPW81trAmsv9TGxg3YCujAxRYp/Uy8xmDgYCzzgulG62H7KYUFmeIg==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", @@ -1276,6 +1277,7 @@ "version": "7.20.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.1.0", "@babel/code-frame": "^7.18.6", @@ -4118,6 +4120,7 @@ "integrity": "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -4645,6 +4648,7 @@ "integrity": "sha512-sn1OZmBxUsgxMmR8a8U5QM/Wl+tyqlH//jTqCg8daTAmhAk26L2PFhcqPLlYBhYUJMZJK276qLXlHN3a83o2cg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.56.0", "@typescript-eslint/types": "5.56.0", @@ -4881,6 +4885,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5321,6 +5326,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6522,6 +6528,7 @@ "version": "7.32.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.3", @@ -8390,6 +8397,7 @@ "version": "29.3.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.3.1", "@jest/types": "^29.3.1", @@ -10270,6 +10278,7 @@ "version": "2.6.7", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -10625,6 +10634,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@napi-rs/wasm-runtime": "0.2.4", "@yarnpkg/lockfile": "^1.1.0", @@ -11262,6 +11272,7 @@ "version": "2.8.3", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -12740,7 +12751,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsutils": { "version": "3.21.0",