Setup CodeActions and add quickfix for missing inputs
This commit is contained in:
Executable
+10
@@ -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
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice";
|
import { documentLinks, getCodeActions, hover, validate, ValidationConfig } from "@actions/languageservice";
|
||||||
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
|
import { registerLogger, setLogLevel } from "@actions/languageservice/log";
|
||||||
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
|
import { clearCache, clearCacheEntry } from "@actions/languageservice/utils/workflow-cache";
|
||||||
import {Octokit} from "@octokit/rest";
|
import { Octokit } from "@octokit/rest";
|
||||||
import {
|
import {
|
||||||
|
CodeAction,
|
||||||
|
CodeActionKind,
|
||||||
|
CodeActionParams,
|
||||||
CompletionItem,
|
CompletionItem,
|
||||||
Connection,
|
Connection,
|
||||||
DocumentLink,
|
DocumentLink,
|
||||||
@@ -17,19 +20,19 @@ import {
|
|||||||
TextDocuments,
|
TextDocuments,
|
||||||
TextDocumentSyncKind
|
TextDocumentSyncKind
|
||||||
} from "vscode-languageserver";
|
} from "vscode-languageserver";
|
||||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
import { TextDocument } from "vscode-languageserver-textdocument";
|
||||||
import {getClient} from "./client";
|
import { getClient } from "./client";
|
||||||
import {Commands} from "./commands";
|
import { Commands } from "./commands";
|
||||||
import {contextProviders} from "./context-providers";
|
import { contextProviders } from "./context-providers";
|
||||||
import {descriptionProvider} from "./description-provider";
|
import { descriptionProvider } from "./description-provider";
|
||||||
import {getFileProvider} from "./file-provider";
|
import { getFileProvider } from "./file-provider";
|
||||||
import {InitializationOptions, RepositoryContext} from "./initializationOptions";
|
import { InitializationOptions, RepositoryContext } from "./initializationOptions";
|
||||||
import {onCompletion} from "./on-completion";
|
import { onCompletion } from "./on-completion";
|
||||||
import {ReadFileRequest, Requests} from "./request";
|
import { ReadFileRequest, Requests } from "./request";
|
||||||
import {getActionsMetadataProvider} from "./utils/action-metadata";
|
import { getActionsMetadataProvider } from "./utils/action-metadata";
|
||||||
import {TTLCache} from "./utils/cache";
|
import { TTLCache } from "./utils/cache";
|
||||||
import {timeOperation} from "./utils/timer";
|
import { timeOperation } from "./utils/timer";
|
||||||
import {valueProviders} from "./value-providers";
|
import { valueProviders } from "./value-providers";
|
||||||
|
|
||||||
export function initConnection(connection: Connection) {
|
export function initConnection(connection: Connection) {
|
||||||
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
|
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
|
||||||
@@ -72,6 +75,9 @@ export function initConnection(connection: Connection) {
|
|||||||
hoverProvider: true,
|
hoverProvider: true,
|
||||||
documentLinkProvider: {
|
documentLinkProvider: {
|
||||||
resolveProvider: false
|
resolveProvider: false
|
||||||
|
},
|
||||||
|
codeActionProvider: {
|
||||||
|
codeActionKinds: [CodeActionKind.QuickFix]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -110,15 +116,15 @@ export function initConnection(connection: Connection) {
|
|||||||
contextProviderConfig: contextProviders(client, repoContext, cache),
|
contextProviderConfig: contextProviders(client, repoContext, cache),
|
||||||
actionsMetadataProvider: getActionsMetadataProvider(client, cache),
|
actionsMetadataProvider: getActionsMetadataProvider(client, cache),
|
||||||
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
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);
|
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<CompletionItem[]> => {
|
connection.onCompletion(async ({ position, textDocument }: TextDocumentPositionParams): Promise<CompletionItem[]> => {
|
||||||
return timeOperation(
|
return timeOperation(
|
||||||
"completion",
|
"completion",
|
||||||
async () =>
|
async () =>
|
||||||
@@ -133,14 +139,14 @@ export function initConnection(connection: Connection) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
connection.onHover(async ({position, textDocument}: HoverParams): Promise<Hover | null> => {
|
connection.onHover(async ({ position, textDocument }: HoverParams): Promise<Hover | null> => {
|
||||||
return timeOperation("hover", async () => {
|
return timeOperation("hover", async () => {
|
||||||
const repoContext = repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri));
|
const repoContext = repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri));
|
||||||
return await hover(getDocument(documents, textDocument), position, {
|
return await hover(getDocument(documents, textDocument), position, {
|
||||||
descriptionProvider: descriptionProvider(client, cache),
|
descriptionProvider: descriptionProvider(client, cache),
|
||||||
contextProviderConfig: repoContext && contextProviders(client, repoContext, cache),
|
contextProviderConfig: repoContext && contextProviders(client, repoContext, cache),
|
||||||
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
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<DocumentLink[] | null> => {
|
connection.onDocumentLinks(async ({ textDocument }: DocumentLinkParams): Promise<DocumentLink[] | null> => {
|
||||||
const repoContext = repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri));
|
const repoContext = repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri));
|
||||||
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
|
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
connection.onCodeAction(async (params: CodeActionParams): Promise<CodeAction[]> => {
|
||||||
|
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
|
// Make the text document manager listen on the connection
|
||||||
// for open, change and close text document events
|
// for open, change and close text document events
|
||||||
documents.listen(connection);
|
documents.listen(connection);
|
||||||
|
|||||||
@@ -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<string, CodeActionProvider[]> = 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";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { CodeActionProvider } from "../types";
|
||||||
|
import { addMissingInputsProvider } from "./add-missing-inputs";
|
||||||
|
|
||||||
|
export const quickfixProviders: CodeActionProvider[] = [
|
||||||
|
addMissingInputsProvider,
|
||||||
|
];
|
||||||
@@ -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<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": {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<TestResult> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/cache@v1
|
||||||
|
with:
|
||||||
|
restore-keys: ${{ runner.os }}-
|
||||||
|
path: ""
|
||||||
|
key: ""
|
||||||
+8
@@ -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 }}-
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
export {complete} from "./complete";
|
export { complete } from "./complete";
|
||||||
export {ContextProviderConfig} from "./context-providers/config";
|
export { ContextProviderConfig } from "./context-providers/config";
|
||||||
export {documentLinks} from "./document-links";
|
export { documentLinks } from "./document-links";
|
||||||
export {hover} from "./hover";
|
export { hover } from "./hover";
|
||||||
export {Logger, LogLevel, registerLogger, setLogLevel} from "./log";
|
export { Logger, LogLevel, registerLogger, setLogLevel } from "./log";
|
||||||
export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate";
|
export { validate, ValidationConfig, ActionsMetadataProvider } from "./validate";
|
||||||
export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
|
export { ValueProviderConfig, ValueProviderKind } from "./value-providers/config";
|
||||||
|
export { getCodeActions } from "./code-actions";
|
||||||
|
|||||||
@@ -1,12 +1,30 @@
|
|||||||
import {isMapping} from "@actions/workflow-parser";
|
import { isMapping } from "@actions/workflow-parser";
|
||||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
import { isActionStep } from "@actions/workflow-parser/model/type-guards";
|
||||||
import {Step} from "@actions/workflow-parser/model/workflow-template";
|
import { Step } from "@actions/workflow-parser/model/workflow-template";
|
||||||
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
|
import { ScalarToken } from "@actions/workflow-parser/templates/tokens/scalar-token";
|
||||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
import { TemplateToken } from "@actions/workflow-parser/templates/tokens/template-token";
|
||||||
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
|
import { Diagnostic, DiagnosticSeverity } from "vscode-languageserver-types";
|
||||||
import {parseActionReference} from "./action";
|
import { ActionReference, parseActionReference } from "./action";
|
||||||
import {mapRange} from "./utils/range";
|
import { mapRange } from "./utils/range";
|
||||||
import {ValidationConfig} from "./validate";
|
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(
|
export async function validateAction(
|
||||||
diagnostics: Diagnostic[],
|
diagnostics: Diagnostic[],
|
||||||
@@ -35,7 +53,7 @@ export async function validateAction(
|
|||||||
|
|
||||||
let withKey: ScalarToken | undefined;
|
let withKey: ScalarToken | undefined;
|
||||||
let withToken: TemplateToken | undefined;
|
let withToken: TemplateToken | undefined;
|
||||||
for (const {key, value} of stepToken) {
|
for (const { key, value } of stepToken) {
|
||||||
if (key.toString() === "with") {
|
if (key.toString() === "with") {
|
||||||
withKey = key;
|
withKey = key;
|
||||||
withToken = value;
|
withToken = value;
|
||||||
@@ -45,7 +63,7 @@ export async function validateAction(
|
|||||||
|
|
||||||
const stepInputs = new Map<string, ScalarToken>();
|
const stepInputs = new Map<string, ScalarToken>();
|
||||||
if (withToken && isMapping(withToken)) {
|
if (withToken && isMapping(withToken)) {
|
||||||
for (const {key} of withToken) {
|
for (const { key } of withToken) {
|
||||||
stepInputs.set(key.toString(), key);
|
stepInputs.set(key.toString(), key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,10 +101,32 @@ export async function validateAction(
|
|||||||
missingRequiredInputs.length === 1
|
missingRequiredInputs.length === 1
|
||||||
? `Missing required input \`${missingRequiredInputs[0][0]}\``
|
? `Missing required input \`${missingRequiredInputs[0][0]}\``
|
||||||
: `Missing required inputs: ${missingRequiredInputs.map(input => `\`${input[0]}\``).join(", ")}`;
|
: `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({
|
diagnostics.push({
|
||||||
severity: DiagnosticSeverity.Error,
|
severity: DiagnosticSeverity.Error,
|
||||||
range: mapRange((withKey || stepToken).range), // Highlight the whole step if we don't have a with key
|
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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {DiagnosticSeverity} from "vscode-languageserver-types";
|
import { DiagnosticSeverity } from "vscode-languageserver-types";
|
||||||
import {ActionMetadata, ActionReference} from "./action";
|
import { ActionMetadata, ActionReference } from "./action";
|
||||||
import {registerLogger} from "./log";
|
import { registerLogger } from "./log";
|
||||||
import {createDocument} from "./test-utils/document";
|
import { createDocument } from "./test-utils/document";
|
||||||
import {TestLogger} from "./test-utils/logger";
|
import { TestLogger } from "./test-utils/logger";
|
||||||
import {validate, ValidationConfig} from "./validate";
|
import { validate, ValidationConfig } from "./validate";
|
||||||
import {ValueProviderKind} from "./value-providers/config";
|
import { ValueProviderKind } from "./value-providers/config";
|
||||||
import {clearCache} from "./utils/workflow-cache";
|
import { clearCache } from "./utils/workflow-cache";
|
||||||
|
|
||||||
registerLogger(new TestLogger());
|
registerLogger(new TestLogger());
|
||||||
|
|
||||||
@@ -249,7 +249,28 @@ jobs:
|
|||||||
line: 7
|
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
|
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
|
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": {
|
"step-with": {
|
||||||
kind: ValueProviderKind.AllowedValues,
|
kind: ValueProviderKind.AllowedValues,
|
||||||
get: () => {
|
get: () => {
|
||||||
return Promise.resolve([{label: "repository", description: "Repository name with owner."}]);
|
return Promise.resolve([{ label: "repository", description: "Repository name with owner." }]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user