From e5800c8843cf3d0ba5e16f48b3ec76f455e6d8cc Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Fri, 28 Nov 2025 14:56:01 +0000 Subject: [PATCH] Setup CodeActions and add quickfix for missing inputs --- languageserver/script/esbuild | 10 + languageserver/src/connection.ts | 62 +++-- languageservice/src/code-actions/index.ts | 55 +++++ .../quickfix/add-missing-inputs.ts | 67 +++++ .../src/code-actions/quickfix/index.ts | 6 + .../src/code-actions/tests/runner.test.ts | 90 +++++++ .../src/code-actions/tests/runner.ts | 232 ++++++++++++++++++ .../quickfix/existing-with-key.golden.yml | 10 + .../testdata/quickfix/existing-with-key.yml | 8 + languageservice/src/code-actions/types.ts | 21 ++ languageservice/src/index.ts | 15 +- languageservice/src/validate-action.ts | 64 ++++- languageservice/src/validate.actions.test.ts | 95 ++++++- 13 files changed, 681 insertions(+), 54 deletions(-) create mode 100755 languageserver/script/esbuild create mode 100644 languageservice/src/code-actions/index.ts create mode 100644 languageservice/src/code-actions/quickfix/add-missing-inputs.ts create mode 100644 languageservice/src/code-actions/quickfix/index.ts create mode 100644 languageservice/src/code-actions/tests/runner.test.ts create mode 100644 languageservice/src/code-actions/tests/runner.ts create mode 100644 languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key.golden.yml create mode 100644 languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key.yml create mode 100644 languageservice/src/code-actions/types.ts diff --git a/languageserver/script/esbuild b/languageserver/script/esbuild new file mode 100755 index 0000000..79a9bae --- /dev/null +++ b/languageserver/script/esbuild @@ -0,0 +1,10 @@ +#!/bin/bash + +npx esbuild src/index.ts \ + --bundle \ + --platform=node \ + --target=node18 \ + --format=cjs \ + --outfile=dist/server-bundled.cjs \ + --external:vscode \ + --loader:.json=json diff --git a/languageserver/src/connection.ts b/languageserver/src/connection.ts index 90b139b..846d696 100644 --- a/languageserver/src/connection.ts +++ b/languageserver/src/connection.ts @@ -1,8 +1,11 @@ -import {documentLinks, 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 { documentLinks, getCodeActions, 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, @@ -17,19 +20,19 @@ import { TextDocuments, TextDocumentSyncKind } from "vscode-languageserver"; -import {TextDocument} from "vscode-languageserver-textdocument"; -import {getClient} from "./client"; -import {Commands} from "./commands"; -import {contextProviders} from "./context-providers"; -import {descriptionProvider} from "./description-provider"; -import {getFileProvider} from "./file-provider"; -import {InitializationOptions, RepositoryContext} from "./initializationOptions"; -import {onCompletion} from "./on-completion"; -import {ReadFileRequest, Requests} from "./request"; -import {getActionsMetadataProvider} from "./utils/action-metadata"; -import {TTLCache} from "./utils/cache"; -import {timeOperation} from "./utils/timer"; -import {valueProviders} from "./value-providers"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { getClient } from "./client"; +import { Commands } from "./commands"; +import { contextProviders } from "./context-providers"; +import { descriptionProvider } from "./description-provider"; +import { getFileProvider } from "./file-provider"; +import { InitializationOptions, RepositoryContext } from "./initializationOptions"; +import { onCompletion } from "./on-completion"; +import { ReadFileRequest, Requests } from "./request"; +import { getActionsMetadataProvider } from "./utils/action-metadata"; +import { TTLCache } from "./utils/cache"; +import { timeOperation } from "./utils/timer"; +import { valueProviders } from "./value-providers"; export function initConnection(connection: Connection) { const documents: TextDocuments = new TextDocuments(TextDocument); @@ -72,6 +75,9 @@ export function initConnection(connection: Connection) { hoverProvider: true, documentLinkProvider: { resolveProvider: false + }, + codeActionProvider: { + codeActionKinds: [CodeActionKind.QuickFix] } } }; @@ -110,15 +116,15 @@ export function initConnection(connection: Connection) { contextProviderConfig: contextProviders(client, repoContext, cache), actionsMetadataProvider: getActionsMetadataProvider(client, cache), fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => { - return await connection.sendRequest(Requests.ReadFile, {path} satisfies ReadFileRequest); + return await connection.sendRequest(Requests.ReadFile, { path } satisfies ReadFileRequest); }) }; const result = await validate(textDocument, config); - await connection.sendDiagnostics({uri: textDocument.uri, diagnostics: result}); + await connection.sendDiagnostics({ uri: textDocument.uri, diagnostics: result }); } - connection.onCompletion(async ({position, textDocument}: TextDocumentPositionParams): Promise => { + connection.onCompletion(async ({ position, textDocument }: TextDocumentPositionParams): Promise => { return timeOperation( "completion", async () => @@ -133,14 +139,14 @@ export function initConnection(connection: Connection) { ); }); - connection.onHover(async ({position, textDocument}: HoverParams): Promise => { + connection.onHover(async ({ position, textDocument }: HoverParams): Promise => { return timeOperation("hover", async () => { const repoContext = repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri)); return await hover(getDocument(documents, textDocument), position, { descriptionProvider: descriptionProvider(client, cache), contextProviderConfig: repoContext && contextProviders(client, repoContext, cache), fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => { - return await connection.sendRequest(Requests.ReadFile, {path}); + return await connection.sendRequest(Requests.ReadFile, { path }); }) }); }); @@ -153,11 +159,21 @@ export function initConnection(connection: Connection) { } }); - connection.onDocumentLinks(async ({textDocument}: DocumentLinkParams): Promise => { + connection.onDocumentLinks(async ({ textDocument }: DocumentLinkParams): Promise => { const repoContext = repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri)); return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri); }); + connection.onCodeAction(async (params: CodeActionParams): Promise => { + return timeOperation("codeAction", async () => { + return getCodeActions({ + uri: params.textDocument.uri, + diagnostics: params.context.diagnostics, + only: params.context.only, + }); + }); + }); + // 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/index.ts b/languageservice/src/code-actions/index.ts new file mode 100644 index 0000000..77fb2ec --- /dev/null +++ b/languageservice/src/code-actions/index.ts @@ -0,0 +1,55 @@ +import { CodeAction, CodeActionKind, Diagnostic } from "vscode-languageserver-types"; +import { CodeActionContext, CodeActionProvider } from "./types"; +import { quickfixProviders } from "./quickfix"; + +// Aggregate all providers by kind +const providersByKind: Map = new Map([ + [CodeActionKind.QuickFix, quickfixProviders], + // [CodeActionKind. Refactor, refactorProviders], + // [CodeActionKind.Source, sourceProviders], + // etc +]); + +export interface CodeActionConfig { + // TODO: actionsMetadataProvider, fileProvider, etc. +} + +export interface CodeActionParams { + uri: string; + diagnostics: Diagnostic[]; + only?: string[]; +} + +export function getCodeActions(params: CodeActionParams, config?: CodeActionConfig): CodeAction[] { + const actions: CodeAction[] = []; + const context: CodeActionContext = { + uri: params.uri, + }; + + // 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"; 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 0000000..4aa4cfc --- /dev/null +++ b/languageservice/src/code-actions/quickfix/add-missing-inputs.ts @@ -0,0 +1,67 @@ +import { CodeAction, TextEdit } from "vscode-languageserver-types"; +import { CodeActionContext, CodeActionProvider } from "../types"; +import { DiagnosticCode, MissingInputsDiagnosticData } from "../../validate-action"; + +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[] | undefined { + const edits: TextEdit[] = []; + + if (data.hasWithKey && data.withIndent !== undefined) { + // `with:` exists - use its indentation + 2 for inputs + const inputIndent = " ".repeat(data.withIndent + 2); + + const inputLines = data.missingInputs.map(input => { + const value = input.default !== undefined ? input.default : '""'; + return `${inputIndent}${input.name}: ${value}`; + }); + + edits.push({ + range: { start: data.insertPosition, end: data.insertPosition }, + newText: inputLines.map(line => line + "\n").join(""), + }); + } else { + // No `with:` key - use step indentation for `with:`, +2 for inputs + const withIndent = " ".repeat(data.stepIndent + 2); + const inputIndent = " ".repeat(data.stepIndent + 4); + + const inputLines = data.missingInputs.map(input => { + const value = input.default !== undefined ? input.default : '""'; + return `${inputIndent}${input.name}: ${value}`; + }); + + const newText = [`${withIndent}with:\n`, ...inputLines.map(line => `${line}\n`)].join(""); + + edits.push({ + range: { start: data.insertPosition, end: data.insertPosition }, + newText, + }); + } + + return edits; +} diff --git a/languageservice/src/code-actions/quickfix/index.ts b/languageservice/src/code-actions/quickfix/index.ts new file mode 100644 index 0000000..6895e4c --- /dev/null +++ b/languageservice/src/code-actions/quickfix/index.ts @@ -0,0 +1,6 @@ +import { CodeActionProvider } from "../types"; +import { addMissingInputsProvider } from "./add-missing-inputs"; + +export const quickfixProviders: CodeActionProvider[] = [ + addMissingInputsProvider, +]; 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 0000000..ecab34b --- /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"; +import { ValidationConfig } from "../../validate"; +import { ActionMetadata, ActionReference } from "../../action"; +import { clearCache } from "../../utils/workflow-cache"; + +// 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 0000000..410413c --- /dev/null +++ b/languageservice/src/code-actions/tests/runner.ts @@ -0,0 +1,232 @@ +import * as fs from "fs"; +import * as path from "path"; +import { TextEdit } from "vscode-languageserver-types"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { validate, ValidationConfig } from "../../validate"; +import { getCodeActions, CodeActionParams } from "../index"; + +// 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, + diagnostics: [diagnostic], + }; + + const actions = getCodeActions(params); + 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.golden.yml b/languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key.golden.yml new file mode 100644 index 0000000..81bca13 --- /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 0000000..a8f993d --- /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/types.ts b/languageservice/src/code-actions/types.ts new file mode 100644 index 0000000..1abde6e --- /dev/null +++ b/languageservice/src/code-actions/types.ts @@ -0,0 +1,21 @@ +import { CodeAction, Diagnostic } from "vscode-languageserver-types"; + +export interface CodeActionContext { + uri: string; + // TODO: add things like workflow template, parsed content, etc. +} + +/** + * 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 c96eb20..cce432d 100644 --- a/languageservice/src/index.ts +++ b/languageservice/src/index.ts @@ -1,7 +1,8 @@ -export {complete} from "./complete"; -export {ContextProviderConfig} from "./context-providers/config"; -export {documentLinks} from "./document-links"; -export {hover} from "./hover"; -export {Logger, LogLevel, registerLogger, setLogLevel} from "./log"; -export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate"; -export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config"; +export { complete } from "./complete"; +export { ContextProviderConfig } from "./context-providers/config"; +export { documentLinks } from "./document-links"; +export { hover } from "./hover"; +export { Logger, LogLevel, registerLogger, setLogLevel } from "./log"; +export { validate, ValidationConfig, ActionsMetadataProvider } from "./validate"; +export { ValueProviderConfig, ValueProviderKind } from "./value-providers/config"; +export { getCodeActions } from "./code-actions"; diff --git a/languageservice/src/validate-action.ts b/languageservice/src/validate-action.ts index c065434..147a656 100644 --- a/languageservice/src/validate-action.ts +++ b/languageservice/src/validate-action.ts @@ -1,12 +1,30 @@ -import {isMapping} from "@actions/workflow-parser"; -import {isActionStep} from "@actions/workflow-parser/model/type-guards"; -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"; -import {mapRange} from "./utils/range"; -import {ValidationConfig} from "./validate"; +import { isMapping } from "@actions/workflow-parser"; +import { isActionStep } from "@actions/workflow-parser/model/type-guards"; +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 { ActionReference, parseActionReference } from "./action"; +import { mapRange } from "./utils/range"; +import { ValidationConfig } from "./validate"; + +export const DiagnosticCode = { + MissingRequiredInputs: "missing-required-inputs", +} as const; + +export interface MissingInputsDiagnosticData { + action: ActionReference; + missingInputs: Array<{ + name: string; + default?: string; + }>; + hasWithKey: boolean; + // Indentation of the `with:` key if present, or the step's base indentation + withIndent?: number; + stepIndent: number; + // Position where new content should be inserted + insertPosition: { line: number; character: number }; +} export async function validateAction( diagnostics: Diagnostic[], @@ -35,7 +53,7 @@ export async function validateAction( let withKey: ScalarToken | undefined; let withToken: TemplateToken | undefined; - for (const {key, value} of stepToken) { + for (const { key, value } of stepToken) { if (key.toString() === "with") { withKey = key; withToken = value; @@ -45,7 +63,7 @@ export async function validateAction( const stepInputs = new Map(); if (withToken && isMapping(withToken)) { - for (const {key} of withToken) { + for (const { key } of withToken) { stepInputs.set(key.toString(), key); } } @@ -83,10 +101,32 @@ export async function validateAction( missingRequiredInputs.length === 1 ? `Missing required input \`${missingRequiredInputs[0][0]}\`` : `Missing required inputs: ${missingRequiredInputs.map(input => `\`${input[0]}\``).join(", ")}`; + + const stepIndent = stepToken.range ? stepToken.range.start.column - 1 : 0; // 0-indexed + const withIndent = withKey?.range ? withKey.range.start.column - 1 : undefined; + + const diagnosticData: MissingInputsDiagnosticData = { + action, + missingInputs: missingRequiredInputs.map(([name, input]) => ({ + name, + default: input.default, + })), + hasWithKey: withKey !== undefined, + withIndent, + stepIndent, + insertPosition: withToken?.range + ? { line: withToken.range.end.line - 1, character: 0 } + : stepToken.range + ? { line: stepToken.range.end.line - 1, character: 0 } + : { line: 0, character: 0 }, + }; + diagnostics.push({ severity: DiagnosticSeverity.Error, range: mapRange((withKey || stepToken).range), // Highlight the whole step if we don't have a with key - message: message + message: message, + code: DiagnosticCode.MissingRequiredInputs, + data: diagnosticData }); } } diff --git a/languageservice/src/validate.actions.test.ts b/languageservice/src/validate.actions.test.ts index c8fb4bf..2074cd6 100644 --- a/languageservice/src/validate.actions.test.ts +++ b/languageservice/src/validate.actions.test.ts @@ -1,11 +1,11 @@ -import {DiagnosticSeverity} from "vscode-languageserver-types"; -import {ActionMetadata, ActionReference} from "./action"; -import {registerLogger} from "./log"; -import {createDocument} from "./test-utils/document"; -import {TestLogger} from "./test-utils/logger"; -import {validate, ValidationConfig} from "./validate"; -import {ValueProviderKind} from "./value-providers/config"; -import {clearCache} from "./utils/workflow-cache"; +import { DiagnosticSeverity } from "vscode-languageserver-types"; +import { ActionMetadata, ActionReference } from "./action"; +import { registerLogger } from "./log"; +import { createDocument } from "./test-utils/document"; +import { TestLogger } from "./test-utils/logger"; +import { validate, ValidationConfig } from "./validate"; +import { ValueProviderKind } from "./value-providers/config"; +import { clearCache } from "./utils/workflow-cache"; registerLogger(new TestLogger()); @@ -249,7 +249,28 @@ jobs: line: 7 } }, - severity: DiagnosticSeverity.Error + severity: DiagnosticSeverity.Error, + code: "missing-required-inputs", + data: { + action: { + name: "cache", + owner: "actions", + ref: "v1" + }, + hasWithKey: true, + insertPosition: { + character: 0, + line: 9 + }, + missingInputs: [ + { + default: undefined, + name: "path" + } + ], + stepIndent: 6, + withIndent: 6 + } } ]); }); @@ -294,7 +315,32 @@ jobs: line: 7 } }, - severity: DiagnosticSeverity.Error + severity: DiagnosticSeverity.Error, + code: "missing-required-inputs", + data: { + action: { + name: "cache", + owner: "actions", + ref: "v1" + }, + hasWithKey: true, + insertPosition: { + character: 0, + line: 9 + }, + missingInputs: [ + { + default: undefined, + name: "path" + }, + { + default: undefined, + name: "key" + } + ], + stepIndent: 6, + withIndent: 6 + } } ]); }); @@ -323,7 +369,32 @@ jobs: line: 6 } }, - severity: DiagnosticSeverity.Error + severity: DiagnosticSeverity.Error, + code: "missing-required-inputs", + data: { + action: { + name: "cache", + owner: "actions", + ref: "v1" + }, + hasWithKey: false, + insertPosition: { + character: 0, + line: 7 + }, + missingInputs: [ + { + default: undefined, + name: "path" + }, + { + default: undefined, + name: "key" + } + ], + stepIndent: 6, + withIndent: undefined + } } ]); }); @@ -344,7 +415,7 @@ jobs: "step-with": { kind: ValueProviderKind.AllowedValues, get: () => { - return Promise.resolve([{label: "repository", description: "Repository name with owner."}]); + return Promise.resolve([{ label: "repository", description: "Repository name with owner." }]); } } };