Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f84e42c1f1 | |||
| 08c78d2a73 | |||
| 26f3969cde | |||
| 61a6fc54f2 | |||
| 6511be5ab4 | |||
| a06ceee92b | |||
| efd53330a3 | |||
| 86888cf4c8 | |||
| ed4c2ce44c | |||
| 9bb4c76612 | |||
| 8b86b48961 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.26",
|
||||
"version": "0.3.29",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.26",
|
||||
"version": "0.3.29",
|
||||
"description": "Language server for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -48,8 +48,8 @@
|
||||
"actions-languageserver": "./bin/actions-languageserver"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.26",
|
||||
"@actions/workflow-parser": "^0.3.26",
|
||||
"@actions/languageservice": "^0.3.29",
|
||||
"@actions/workflow-parser": "^0.3.29",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice";
|
||||
import {documentLinks, 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";
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
HoverParams,
|
||||
InitializeParams,
|
||||
InitializeResult,
|
||||
InlayHint,
|
||||
InlayHintParams,
|
||||
TextDocumentIdentifier,
|
||||
TextDocumentPositionParams,
|
||||
TextDocuments,
|
||||
@@ -72,7 +74,8 @@ export function initConnection(connection: Connection) {
|
||||
hoverProvider: true,
|
||||
documentLinkProvider: {
|
||||
resolveProvider: false
|
||||
}
|
||||
},
|
||||
inlayHintProvider: true
|
||||
}
|
||||
};
|
||||
|
||||
@@ -158,6 +161,12 @@ export function initConnection(connection: Connection) {
|
||||
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
|
||||
});
|
||||
|
||||
connection.languages.inlayHint.on(async ({textDocument}: InlayHintParams): Promise<InlayHint[] | null> => {
|
||||
return timeOperation("inlayHints", () => {
|
||||
return getInlayHints(getDocument(documents, textDocument));
|
||||
});
|
||||
});
|
||||
|
||||
// Make the text document manager listen on the connection
|
||||
// for open, change and close text document events
|
||||
documents.listen(connection);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.26",
|
||||
"version": "0.3.29",
|
||||
"description": "Language service for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -47,8 +47,8 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.26",
|
||||
"@actions/workflow-parser": "^0.3.26",
|
||||
"@actions/expressions": "^0.3.29",
|
||||
"@actions/workflow-parser": "^0.3.29",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
"vscode-uri": "^3.0.8",
|
||||
|
||||
@@ -1110,7 +1110,7 @@ jobs:
|
||||
`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
expect(result.map(x => x.label)).toEqual(["container", "services", "status"]);
|
||||
expect(result.map(x => x.label)).toEqual(["check_run_id", "container", "services", "status"]);
|
||||
});
|
||||
|
||||
it("job context is suggested within a job output", async () => {
|
||||
|
||||
@@ -19,9 +19,12 @@ describe("completion", () => {
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result.length).toEqual(12);
|
||||
// 12 runner labels + 2 escape hatches (switch to list, switch to full syntax)
|
||||
expect(result.length).toEqual(14);
|
||||
const labels = result.map(x => x.label);
|
||||
expect(labels).toContain("macos-latest");
|
||||
expect(labels).toContain("(switch to list)");
|
||||
expect(labels).toContain("(switch to mapping)");
|
||||
});
|
||||
|
||||
it("needs", async () => {
|
||||
@@ -44,7 +47,7 @@ jobs:
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result.length).toEqual(9);
|
||||
expect(result.length).toEqual(13);
|
||||
expect(result[0].label).toEqual("concurrency");
|
||||
});
|
||||
|
||||
@@ -70,7 +73,7 @@ jobs:
|
||||
|`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result.length).toEqual(21);
|
||||
expect(result.length).toEqual(30);
|
||||
});
|
||||
|
||||
it("string definition completion in sequence", async () => {
|
||||
@@ -95,6 +98,7 @@ jobs:
|
||||
release:
|
||||
types: |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
// Expect string values plus escape hatch to switch to list form
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"created",
|
||||
"deleted",
|
||||
@@ -102,7 +106,8 @@ jobs:
|
||||
"prereleased",
|
||||
"published",
|
||||
"released",
|
||||
"unpublished"
|
||||
"unpublished",
|
||||
"(switch to list)"
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -190,8 +195,11 @@ jobs:
|
||||
const result = await complete(...getPositionFromCursor(input), {valueProviderConfig: config});
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result.length).toEqual(1);
|
||||
// Custom value plus escape hatches for list and full syntax
|
||||
expect(result.length).toEqual(3);
|
||||
expect(result[0].label).toEqual("my-custom-label");
|
||||
expect(result.map(x => x.label)).toContain("(switch to list)");
|
||||
expect(result.map(x => x.label)).toContain("(switch to mapping)");
|
||||
});
|
||||
|
||||
it("custom value providers for sequences", async () => {
|
||||
@@ -212,7 +220,9 @@ jobs:
|
||||
expect(result[0].label).toEqual("my-custom-label");
|
||||
});
|
||||
|
||||
it("does not show parent mapping sibling keys", async () => {
|
||||
it("does not show mapping keys or parent sibling keys in Key mode", async () => {
|
||||
// At `container: |`, the scalar form is a string with no constants.
|
||||
// Mapping keys should NOT be shown inline - but escape hatch to full syntax IS shown.
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
@@ -220,20 +230,21 @@ jobs:
|
||||
runs-on: ubuntu-latest`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result.length).toEqual(6);
|
||||
// Should not contain other top-level job keys like `if` and `runs-on`
|
||||
expect(result.map(x => x.label)).not.toContain("if");
|
||||
expect(result.map(x => x.label)).not.toContain("runs-on");
|
||||
// Only escape hatch to full syntax (container has mapping form but no sequence)
|
||||
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
|
||||
});
|
||||
|
||||
it("shows mapping keys within a new map ", async () => {
|
||||
it("does not show mapping keys in Key mode when structure is uncommitted", async () => {
|
||||
// At `concurrency: |`, user is in Key mode but hasn't committed to a structure.
|
||||
// The scalar form is a string with no constants, so no scalar completions.
|
||||
// But escape hatch to full syntax IS shown as a way out.
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
concurrency: |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result.map(x => x.label).sort()).toEqual(["cancel-in-progress", "group"]);
|
||||
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
|
||||
});
|
||||
|
||||
it("job key", async () => {
|
||||
@@ -243,7 +254,7 @@ jobs:
|
||||
runs-|`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result).toHaveLength(21);
|
||||
expect(result).toHaveLength(30);
|
||||
});
|
||||
|
||||
it("job key with comment afterwards", async () => {
|
||||
@@ -254,7 +265,7 @@ jobs:
|
||||
#`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result).toHaveLength(21);
|
||||
expect(result).toHaveLength(30);
|
||||
});
|
||||
|
||||
it("job key with other values afterwards", async () => {
|
||||
@@ -266,7 +277,10 @@ jobs:
|
||||
concurrency: 'group-name'`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result).toHaveLength(20);
|
||||
// Verify we get job-level completions, but concurrency is already present so excluded
|
||||
expect(result.length).toBeGreaterThan(20);
|
||||
expect(result.some(x => x.label === "runs-on")).toBe(true);
|
||||
expect(result.some(x => x.label === "concurrency")).toBe(false);
|
||||
});
|
||||
|
||||
it("step key without space after colon", async () => {
|
||||
@@ -335,7 +349,9 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).toHaveLength(17);
|
||||
// Verify we get job-level completions including runs-on variants
|
||||
expect(result.length).toBeGreaterThan(20);
|
||||
expect(result.some(x => x.label === "steps")).toBe(true);
|
||||
});
|
||||
|
||||
it("complete from behind a colon will replace it", async () => {
|
||||
@@ -348,7 +364,8 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).toHaveLength(17);
|
||||
// Verify we get job-level completions
|
||||
expect(result.length).toBeGreaterThan(20);
|
||||
const textEdit = result[0].textEdit as TextEdit;
|
||||
expect(textEdit.range).toEqual({
|
||||
start: {line: 5, character: 4},
|
||||
@@ -447,8 +464,9 @@ jobs:
|
||||
"timeout-minutes: "
|
||||
]);
|
||||
|
||||
// One-of
|
||||
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency: "]);
|
||||
// One-of (scalar variant)
|
||||
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.detail === undefined);
|
||||
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
|
||||
});
|
||||
|
||||
it("custom indentation", async () => {
|
||||
@@ -470,20 +488,21 @@ jobs:
|
||||
"timeout-minutes: "
|
||||
]);
|
||||
|
||||
// One-of
|
||||
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency: "]);
|
||||
// One-of (scalar variant)
|
||||
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.detail === undefined);
|
||||
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
|
||||
});
|
||||
});
|
||||
|
||||
it("adds a new line and indentation for mapping keys when the key is given", async () => {
|
||||
it("does not show mapping keys in Key mode for one-of with mapping variant", async () => {
|
||||
// At `concurrency: |`, mapping keys should NOT be shown.
|
||||
// Users who want the mapping form should use `concurrency (full syntax)` at parent level.
|
||||
const input = "concurrency: |";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.filter(x => x.label === "cancel-in-progress").map(x => x.textEdit?.newText)).toEqual([
|
||||
"\n cancel-in-progress: "
|
||||
]);
|
||||
expect(result.filter(x => x.label === "group").map(x => x.textEdit?.newText)).toEqual(["\n group: "]);
|
||||
expect(result.filter(x => x.label === "cancel-in-progress")).toEqual([]);
|
||||
expect(result.filter(x => x.label === "group")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not add new line if no key in line", async () => {
|
||||
@@ -494,12 +513,15 @@ jobs:
|
||||
expect(result.filter(x => x.label === "run-name").map(x => x.textEdit?.newText)).toEqual(["run-name: "]);
|
||||
});
|
||||
|
||||
it("adds new line for nested mapping", async () => {
|
||||
it("does not show mapping keys when user has started typing a scalar value", async () => {
|
||||
// User typed `workflow_dispatch: in` - they've committed to a scalar value
|
||||
// Should not show mapping keys like `inputs`
|
||||
const input = "on:\n workflow_dispatch: in|";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.filter(x => x.label === "inputs").map(x => x.textEdit?.newText)).toEqual(["\n inputs:\n "]);
|
||||
// No mapping keys should be shown since user started typing a scalar
|
||||
expect(result.filter(x => x.label === "inputs")).toEqual([]);
|
||||
});
|
||||
|
||||
it("adds : for one-of", async () => {
|
||||
@@ -507,30 +529,300 @@ jobs:
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["types: "]);
|
||||
// Scalar variant inserts "types: "
|
||||
const scalarVariant = result.find(x => x.label === "types" && x.detail === undefined);
|
||||
expect(scalarVariant?.textEdit?.newText).toEqual("types: ");
|
||||
});
|
||||
|
||||
it("adds newline and indentation for one-of in key mode", async () => {
|
||||
it("does not show mapping keys for one-of when user has typed a scalar value", async () => {
|
||||
// User typed `check_run: ty` - they've committed to scalar form
|
||||
// The only valid value for check_run scalar is null, so no completions
|
||||
const input = "on:\n check_run: ty|";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// When completing a one-of property in key mode (after colon on same line),
|
||||
// insert newline + indentation + key + colon to create valid YAML structure
|
||||
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["\n types: "]);
|
||||
// check_run's scalar form only accepts null, so typing anything should show no completions
|
||||
// (we don't show mapping keys like `types` anymore - user should use check_run with detail "full syntax" instead)
|
||||
expect(result.filter(x => x.label === "types")).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles mixed string and mapping completions for one-of in key mode", async () => {
|
||||
it("shows only scalar options for one-of in Key mode when user hasn't committed to a type", async () => {
|
||||
// At `permissions: |` user hasn't typed anything yet - show only scalar options
|
||||
// Mapping keys are NOT shown because they would require a newline
|
||||
// Users who want the mapping form can use `permissions (full syntax)` at the parent level
|
||||
const input = "on: push\npermissions: |";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// String values (read-all, write-all) should insert directly without newline
|
||||
// String values (read-all, write-all) should be available
|
||||
expect(result.filter(x => x.label === "read-all").map(x => x.textEdit?.newText)).toEqual(["read-all"]);
|
||||
expect(result.filter(x => x.label === "write-all").map(x => x.textEdit?.newText)).toEqual(["write-all"]);
|
||||
|
||||
// Mapping keys with one-of types should insert with newline and indentation
|
||||
expect(result.filter(x => x.label === "actions").map(x => x.textEdit?.newText)).toEqual(["\n actions: "]);
|
||||
expect(result.filter(x => x.label === "contents").map(x => x.textEdit?.newText)).toEqual(["\n contents: "]);
|
||||
// Mapping keys should NOT be shown - they require a newline which is confusing inline
|
||||
expect(result.filter(x => x.label === "actions")).toEqual([]);
|
||||
expect(result.filter(x => x.label === "contents")).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters to scalar options when user has started typing a scalar", async () => {
|
||||
// User typed `permissions: r` - they've committed to scalar form
|
||||
const input = "on: push\npermissions: r|";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// Only scalar values should be shown (filtering on 'r')
|
||||
expect(result.some(x => x.label === "read-all")).toBe(true);
|
||||
// Mapping keys should NOT be shown
|
||||
expect(result.filter(x => x.label === "actions")).toEqual([]);
|
||||
expect(result.filter(x => x.label === "contents")).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows both simple and full syntax for null+mapping one-of", async () => {
|
||||
// check_run is a one-of: [null, mapping]. Show both:
|
||||
// - check_run (simple, just the key with colon)
|
||||
// - check_run with detail "full syntax" (ready to add mapping keys)
|
||||
const input = "on:\n |";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// Should have both check_run (scalar) and check_run with detail "full syntax"
|
||||
const checkRunVariants = result.filter(x => x.label === "check_run");
|
||||
expect(checkRunVariants.some(x => x.detail === undefined)).toBe(true);
|
||||
expect(checkRunVariants.some(x => x.detail === "full syntax")).toBe(true);
|
||||
});
|
||||
|
||||
it("shows all three variants for scalar+sequence+mapping one-of", async () => {
|
||||
// runs-on is a one-of: [string, sequence, mapping]
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
|`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// Should have runs-on (scalar), runs-on with detail "list", and runs-on with detail "full syntax"
|
||||
const runsOnVariants = result.filter(x => x.label === "runs-on");
|
||||
expect(runsOnVariants.length).toBe(3);
|
||||
expect(runsOnVariants.some(x => x.detail === undefined)).toBe(true);
|
||||
expect(runsOnVariants.some(x => x.detail === "list")).toBe(true);
|
||||
expect(runsOnVariants.some(x => x.detail === "full syntax")).toBe(true);
|
||||
});
|
||||
|
||||
it("generates correct insertText for one-of variants in parent mode", async () => {
|
||||
// runs-on is a one-of: [string, sequence, mapping]
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
|`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
const runsOnVariants = result.filter(x => x.label === "runs-on");
|
||||
|
||||
// Scalar: just key with colon and space
|
||||
expect(runsOnVariants.find(x => x.detail === undefined)?.textEdit?.newText).toEqual("runs-on: ");
|
||||
|
||||
// Sequence: key with colon, newline, and list item
|
||||
expect(runsOnVariants.find(x => x.detail === "list")?.textEdit?.newText).toEqual("runs-on:\n - ");
|
||||
|
||||
// Mapping: key with colon, newline, and indentation for nested keys
|
||||
expect(runsOnVariants.find(x => x.detail === "full syntax")?.textEdit?.newText).toEqual("runs-on:\n ");
|
||||
});
|
||||
|
||||
it("generates correct insertText for one-of variants in parent mode", async () => {
|
||||
// concurrency is a one-of: [string, mapping] - testing parent mode (inside mapping)
|
||||
// At `concurrency:\n |`, user HAS committed to mapping structure, so mapping keys are shown
|
||||
const input = "concurrency:\n |";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// In parent mode: just key + colon + space (no leading newline)
|
||||
expect(result.find(x => x.label === "group")?.textEdit?.newText).toEqual("group: ");
|
||||
|
||||
// Boolean in parent mode (cancel-in-progress): key + colon + space
|
||||
expect(result.find(x => x.label === "cancel-in-progress")?.textEdit?.newText).toEqual("cancel-in-progress: ");
|
||||
});
|
||||
|
||||
it("uses sortText for ordering qualified one-of variants", async () => {
|
||||
// runs-on has multiple structural types, so variants need sorting
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
|`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
const runsOnVariants = result.filter(x => x.label === "runs-on");
|
||||
|
||||
// Scalar: no sortText needed (sorts naturally first)
|
||||
expect(runsOnVariants.find(x => x.detail === undefined)?.sortText).toBeUndefined();
|
||||
|
||||
// Sequence and mapping: sortText controls ordering
|
||||
expect(runsOnVariants.find(x => x.detail === "list")?.sortText).toEqual("runs-on 1");
|
||||
expect(runsOnVariants.find(x => x.detail === "full syntax")?.sortText).toEqual("runs-on 2");
|
||||
});
|
||||
|
||||
it("scalar event completion inserts inline without newline", async () => {
|
||||
// At `on: |` user is completing the value for 'on' key
|
||||
// Scalar events like `push`, `check_run` should insert inline
|
||||
const input = "on: |";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// Scalar forms should NOT have newline - they insert inline
|
||||
const push = result.find(x => x.label === "push");
|
||||
expect(push?.textEdit?.newText).toEqual("push");
|
||||
|
||||
const checkRun = result.find(x => x.label === "check_run" && x.detail === undefined);
|
||||
expect(checkRun?.textEdit?.newText).toEqual("check_run");
|
||||
|
||||
// Full syntax form should NOT be shown in Key mode - it requires a newline
|
||||
// which is confusing when typing inline. Users who want the mapping form
|
||||
// can use `on (full syntax)` at the parent level.
|
||||
expect(result.find(x => x.label === "check_run" && x.detail === "full syntax")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("filters to sequence options when user has started a sequence", async () => {
|
||||
// User started a sequence with `- ` syntax - they've committed to sequence form
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on:
|
||||
- |`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// Should show runner labels (sequence item values)
|
||||
expect(result.some(x => x.label === "ubuntu-latest")).toBe(true);
|
||||
expect(result.some(x => x.label === "macos-latest")).toBe(true);
|
||||
|
||||
// Should NOT show mapping keys like `group` or `labels` (those are for full syntax)
|
||||
expect(result.filter(x => x.label === "group")).toEqual([]);
|
||||
expect(result.filter(x => x.label === "labels")).toEqual([]);
|
||||
});
|
||||
|
||||
describe("escape hatch completions", () => {
|
||||
it("runs-on shows switch to list and full syntax", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// Should have escape hatches at the end
|
||||
const switchToList = result.find(x => x.label === "(switch to list)");
|
||||
const switchToFull = result.find(x => x.label === "(switch to mapping)");
|
||||
|
||||
expect(switchToList).toBeDefined();
|
||||
expect(switchToFull).toBeDefined();
|
||||
|
||||
// Escape hatches should sort last
|
||||
expect(switchToList!.sortText).toEqual("zzz_switch_1");
|
||||
expect(switchToFull!.sortText).toEqual("zzz_switch_2");
|
||||
|
||||
// Escape hatches should have textEdit that restructures the YAML
|
||||
const listEdit = switchToList!.textEdit as TextEdit;
|
||||
const fullEdit = switchToFull!.textEdit as TextEdit;
|
||||
|
||||
expect(listEdit.newText).toEqual("runs-on:\n - ");
|
||||
expect(fullEdit.newText).toEqual("runs-on:\n ");
|
||||
|
||||
// TextEdit range should cover from key start to cursor position
|
||||
expect(listEdit.range.start).toEqual({line: 3, character: 4});
|
||||
expect(fullEdit.range.start).toEqual({line: 3, character: 4});
|
||||
});
|
||||
|
||||
it("permissions shows only switch to full syntax (no sequence form)", async () => {
|
||||
const input = `on: push
|
||||
permissions: |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// Should have full syntax escape hatch but NOT list (permissions has no sequence form)
|
||||
expect(result.some(x => x.label === "(switch to mapping)")).toBe(true);
|
||||
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
|
||||
});
|
||||
|
||||
it("escape hatches are not shown when value is non-empty", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-|`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// User has started typing a scalar value, no escape hatches
|
||||
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
|
||||
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
|
||||
});
|
||||
|
||||
it("escape hatches are not shown when inside a sequence", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on:
|
||||
- |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// User is already in sequence form, no escape hatches
|
||||
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
|
||||
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
|
||||
});
|
||||
|
||||
it("escape hatches are not shown when inside a mapping", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on:
|
||||
group: |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// User is in mapping form completing a value, no escape hatches for the parent
|
||||
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
|
||||
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
|
||||
});
|
||||
|
||||
it("escape hatches ARE shown even when no scalar completions exist", async () => {
|
||||
// concurrency: | has no scalar constants, but escape hatch provides a way out
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
concurrency: |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// Escape hatch to mapping should be available even with no scalar completions
|
||||
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
|
||||
});
|
||||
|
||||
it("pure mapping type (strategy) shows switch to mapping", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
strategy: |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.some(x => x.label === "(switch to mapping)")).toBe(true);
|
||||
});
|
||||
|
||||
it("pure sequence type (steps) shows switch to list", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
steps: |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.some(x => x.label === "(switch to list)")).toBe(true);
|
||||
});
|
||||
|
||||
it("selecting switch to list restructures YAML", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
const switchToList = result.find(x => x.label === "(switch to list)");
|
||||
const textEdit = switchToList!.textEdit as TextEdit;
|
||||
|
||||
// Applying this edit to "runs-on: " should produce "runs-on:\n - "
|
||||
expect(textEdit.newText).toEqual("runs-on:\n - ");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import {complete as completeExpression, DescriptionDictionary} from "@actions/ex
|
||||
import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion";
|
||||
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
|
||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {DefinitionType} from "@actions/workflow-parser/templates/schema/definition-type";
|
||||
import {OneOfDefinition} from "@actions/workflow-parser/templates/schema/one-of-definition";
|
||||
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
@@ -9,6 +11,7 @@ import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range"
|
||||
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
|
||||
import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||
import {getWorkflowSchema} from "@actions/workflow-parser/workflows/workflow-schema";
|
||||
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
|
||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
||||
@@ -24,7 +27,7 @@ import {isPlaceholder, transform} from "./utils/transform.js";
|
||||
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
import {Value, ValueProviderConfig} from "./value-providers/config.js";
|
||||
import {defaultValueProviders} from "./value-providers/default.js";
|
||||
import {DefinitionValueMode, definitionValues} from "./value-providers/definition.js";
|
||||
import {DefinitionValueMode, definitionValues, TokenStructure} from "./value-providers/definition.js";
|
||||
|
||||
export function getExpressionInput(input: string, pos: number): string {
|
||||
// Find start marker around the cursor position
|
||||
@@ -100,8 +103,17 @@ export async function complete(
|
||||
|
||||
const values = await getValues(token, keyToken, parent, config?.valueProviderConfig, workflowContext, indentString);
|
||||
|
||||
// Add escape hatch completions when completing an empty scalar value for a one-of field.
|
||||
// These provide a way out of "dead end" situations where no scalar completions exist
|
||||
// but alternative structural forms (list, mapping) are available.
|
||||
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos);
|
||||
values.push(...escapeHatches);
|
||||
|
||||
// Figure out what text to replace when the user picks a completion.
|
||||
// For example, if they typed `runs-|` and pick `runs-on`, we need to replace `runs-`.
|
||||
let replaceRange: Range | undefined;
|
||||
if (token?.range) {
|
||||
// Prefer the token's range since it accounts for YAML syntax like quotes
|
||||
replaceRange = mapRange(token.range);
|
||||
} else if (!token) {
|
||||
// Not a valid token, create a range from the current position
|
||||
@@ -127,20 +139,44 @@ export async function complete(
|
||||
return values.map(value => {
|
||||
const newText = value.insertText || value.label;
|
||||
|
||||
// Escape hatches provide their own textEdit to restructure the YAML
|
||||
let textEdit: TextEdit;
|
||||
if (value.textEdit) {
|
||||
textEdit = TextEdit.replace(value.textEdit.range, value.textEdit.newText);
|
||||
} else if (replaceRange) {
|
||||
textEdit = TextEdit.replace(replaceRange, newText);
|
||||
} else {
|
||||
textEdit = TextEdit.insert(position, newText);
|
||||
}
|
||||
|
||||
const item: CompletionItem = {
|
||||
label: value.label,
|
||||
detail: value.detail,
|
||||
filterText: value.filterText,
|
||||
sortText: value.sortText,
|
||||
documentation: value.description && {
|
||||
kind: "markdown",
|
||||
value: value.description
|
||||
},
|
||||
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
|
||||
textEdit: replaceRange ? TextEdit.replace(replaceRange, newText) : TextEdit.insert(position, newText)
|
||||
textEdit
|
||||
};
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves completion values for a token based on value providers and definitions.
|
||||
*
|
||||
* This function determines which values to suggest for auto-completion by:
|
||||
* 1. First checking for custom value providers configured for the token's definition key
|
||||
* 2. Then checking for default value providers for the token's definition key
|
||||
* 3. Finally falling back to values derived from the token's schema definition
|
||||
*
|
||||
* The results are filtered to exclude duplicates (e.g., keys already defined in a mapping
|
||||
* or values already present in a sequence) and sorted alphabetically.
|
||||
*/
|
||||
async function getValues(
|
||||
token: TemplateToken | null,
|
||||
keyToken: TemplateToken | null,
|
||||
@@ -180,10 +216,181 @@ async function getValues(
|
||||
return [];
|
||||
}
|
||||
|
||||
const values = definitionValues(def, indentation, keyToken ? DefinitionValueMode.Key : DefinitionValueMode.Parent);
|
||||
// When a schema allows multiple formats (e.g., `runs-on` can be a string OR a mapping),
|
||||
// only suggest completions that match what the user has already started typing.
|
||||
// For example, if they've started a mapping, don't suggest string values.
|
||||
const tokenStructure = getTokenStructure(token);
|
||||
const values = definitionValues(
|
||||
def,
|
||||
indentation,
|
||||
keyToken ? DefinitionValueMode.Key : DefinitionValueMode.Parent,
|
||||
tokenStructure
|
||||
);
|
||||
return filterAndSortCompletionOptions(values, existingValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines what YAML structure the user has committed to, if any.
|
||||
*
|
||||
* Returns:
|
||||
* - "mapping" if the user has started a key-value structure (e.g., `runs-on:\n group: |`)
|
||||
* - "sequence" if the user has started a list (e.g., `runs-on:\n - |`)
|
||||
* - "scalar" if the user has started typing a plain value (e.g., `runs-on: ubuntu-|`)
|
||||
* - undefined if the user hasn't committed yet (e.g., `runs-on: |` with nothing typed)
|
||||
*/
|
||||
function getTokenStructure(token: TemplateToken | null): TokenStructure {
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (token.templateTokenType) {
|
||||
case TokenType.Mapping:
|
||||
return "mapping";
|
||||
case TokenType.Sequence:
|
||||
return "sequence";
|
||||
case TokenType.Null:
|
||||
// Null means `key: ` with nothing - user hasn't committed to a type yet
|
||||
return undefined;
|
||||
case TokenType.String: {
|
||||
// Empty string means `key: |` - user hasn't committed yet
|
||||
// Non-empty string means user has started typing a scalar value
|
||||
const stringToken = token.assertString("getTokenStructure expected string token");
|
||||
if (stringToken.value === "") {
|
||||
return undefined;
|
||||
}
|
||||
return "scalar";
|
||||
}
|
||||
case TokenType.Boolean:
|
||||
case TokenType.Number:
|
||||
return "scalar";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates escape hatch completions that allow switching from scalar form to
|
||||
* alternative structural forms (sequence or mapping) when the value is empty.
|
||||
*
|
||||
* For example, at `runs-on: |`, this adds "(switch to list)" and "(switch to full syntax)"
|
||||
* completions that restructure the YAML to `runs-on:\n - |` or `runs-on:\n |`.
|
||||
*
|
||||
* Only shown when:
|
||||
* - Completing in value position (keyToken exists)
|
||||
* - Value is empty (user hasn't committed to a structure yet)
|
||||
* - Definition allows sequence or mapping structure
|
||||
*/
|
||||
function getEscapeHatchCompletions(
|
||||
token: TemplateToken | null,
|
||||
keyToken: TemplateToken | null,
|
||||
indentation: string,
|
||||
position: Position
|
||||
): Value[] {
|
||||
// Only show escape hatches when value is empty
|
||||
const tokenStructure = getTokenStructure(token);
|
||||
if (tokenStructure !== undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Need a key token with a definition
|
||||
if (!keyToken?.definition) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Determine which structural types are available from the definition
|
||||
const def = keyToken.definition;
|
||||
const schema = getWorkflowSchema();
|
||||
const buckets = {
|
||||
sequence: false,
|
||||
mapping: false
|
||||
};
|
||||
|
||||
if (def instanceof OneOfDefinition) {
|
||||
// OneOf: check each variant
|
||||
for (const variantKey of def.oneOf) {
|
||||
const variantDef = schema.definitions[variantKey];
|
||||
if (variantDef) {
|
||||
switch (variantDef.definitionType) {
|
||||
case DefinitionType.Sequence:
|
||||
buckets.sequence = true;
|
||||
break;
|
||||
case DefinitionType.Mapping:
|
||||
buckets.mapping = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single definition type
|
||||
switch (def.definitionType) {
|
||||
case DefinitionType.Sequence:
|
||||
buckets.sequence = true;
|
||||
break;
|
||||
case DefinitionType.Mapping:
|
||||
buckets.mapping = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const results: Value[] = [];
|
||||
const keyName = isString(keyToken) ? keyToken.value : "";
|
||||
const keyRange = keyToken.range;
|
||||
|
||||
if (!keyRange || !keyName) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Calculate the range from key start to current position
|
||||
// This covers "key: " so we can replace it with "key:\n - " or "key:\n "
|
||||
const editRange = {
|
||||
start: {line: keyRange.start.line - 1, character: keyRange.start.column - 1},
|
||||
end: {line: position.line, character: position.character}
|
||||
};
|
||||
|
||||
if (buckets.sequence) {
|
||||
results.push({
|
||||
label: "(switch to list)",
|
||||
sortText: "zzz_switch_1",
|
||||
filterText: keyName, // Allow filtering by key name
|
||||
textEdit: {
|
||||
range: editRange,
|
||||
newText: `${keyName}:\n${indentation}- `
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (buckets.mapping) {
|
||||
results.push({
|
||||
label: "(switch to mapping)",
|
||||
sortText: "zzz_switch_2",
|
||||
filterText: keyName, // Allow filtering by key name
|
||||
textEdit: {
|
||||
range: editRange,
|
||||
newText: `${keyName}:\n${indentation}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects values that are already present in the current context, so they can be
|
||||
* excluded from completion suggestions.
|
||||
*
|
||||
* For sequences (lists), returns all existing items. For example, if the user has:
|
||||
* labels:
|
||||
* - bug
|
||||
* - |
|
||||
* This returns {"bug"} so we don't suggest "bug" again.
|
||||
*
|
||||
* For mappings, returns all existing keys. For example, if the user has:
|
||||
* jobs:
|
||||
* build:
|
||||
* runs-on: ubuntu-latest
|
||||
* |
|
||||
* This returns {"runs-on"} so we don't suggest "runs-on" again.
|
||||
*/
|
||||
export function getExistingValues(token: TemplateToken | null, parent: TemplateToken) {
|
||||
// For incomplete YAML, we may only have a parent token
|
||||
if (token) {
|
||||
@@ -253,7 +460,7 @@ function getExpressionCompletionItems(
|
||||
|
||||
function filterAndSortCompletionOptions(options: Value[], existingValues?: Set<string>) {
|
||||
options = options.filter(x => !existingValues?.has(x.label));
|
||||
options.sort((a, b) => a.label.localeCompare(b.label));
|
||||
options.sort((a, b) => (a.sortText ?? a.label).localeCompare(b.sortText ?? b.label));
|
||||
return options;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ export function getJobContext(workflowContext: WorkflowContext): DescriptionDict
|
||||
// Status
|
||||
jobContext.add("status", new data.Null());
|
||||
|
||||
// Check run ID
|
||||
jobContext.add("check_run_id", new data.Null());
|
||||
|
||||
return jobContext;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,17 +21,21 @@ describe("end-to-end", () => {
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result.length).toEqual(9);
|
||||
const labels = result.map(x => x.label);
|
||||
expect(labels).toEqual([
|
||||
expect(result.length).toEqual(13);
|
||||
const labelsWithDetails = result.map(x => (x.detail ? `${x.label} (${x.detail})` : x.label));
|
||||
expect(labelsWithDetails).toEqual([
|
||||
"concurrency",
|
||||
"concurrency (full syntax)",
|
||||
"defaults",
|
||||
"description",
|
||||
"env",
|
||||
"jobs",
|
||||
"name",
|
||||
"on",
|
||||
"on (list)",
|
||||
"on (full syntax)",
|
||||
"permissions",
|
||||
"permissions (full syntax)",
|
||||
"run-name"
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ export {complete} from "./complete.js";
|
||||
export {ContextProviderConfig} from "./context-providers/config.js";
|
||||
export {documentLinks} from "./document-links.js";
|
||||
export {hover} from "./hover.js";
|
||||
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";
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import {InlayHintKind} from "vscode-languageserver-types";
|
||||
import {getInlayHints} from "./inlay-hints.js";
|
||||
import {registerLogger} from "./log.js";
|
||||
import {createDocument} from "./test-utils/document.js";
|
||||
import {TestLogger} from "./test-utils/logger.js";
|
||||
import {clearCache} from "./utils/workflow-cache.js";
|
||||
|
||||
registerLogger(new TestLogger());
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("inlay-hints", () => {
|
||||
describe("cron expressions", () => {
|
||||
it("returns inlay hint for valid cron expression", () => {
|
||||
const input = `on:
|
||||
schedule:
|
||||
- cron: '0 * * * *'
|
||||
`;
|
||||
const document = createDocument("test.yaml", input);
|
||||
const hints = getInlayHints(document);
|
||||
|
||||
expect(hints).toHaveLength(1);
|
||||
expect(hints[0].label).toBe("→ Runs every hour");
|
||||
expect(hints[0].kind).toBe(InlayHintKind.Parameter);
|
||||
expect(hints[0].paddingLeft).toBe(true);
|
||||
});
|
||||
|
||||
it("returns correct position at end of cron value", () => {
|
||||
const input = `on:
|
||||
schedule:
|
||||
- cron: '0 3 * * 1'
|
||||
`;
|
||||
const document = createDocument("test.yaml", input);
|
||||
const hints = getInlayHints(document);
|
||||
|
||||
expect(hints).toHaveLength(1);
|
||||
// Position should be at the end of the cron string value (after the closing quote)
|
||||
// Line 3 (0-indexed: 2), end of '0 3 * * 1'
|
||||
expect(hints[0].position.line).toBe(2);
|
||||
});
|
||||
|
||||
it("returns no hint for invalid cron expression", () => {
|
||||
const input = `on:
|
||||
schedule:
|
||||
- cron: 'invalid cron'
|
||||
`;
|
||||
const document = createDocument("test.yaml", input);
|
||||
const hints = getInlayHints(document);
|
||||
|
||||
expect(hints).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns multiple hints for multiple cron expressions", () => {
|
||||
const input = `on:
|
||||
schedule:
|
||||
- cron: '0 * * * *'
|
||||
- cron: '0 0 * * *'
|
||||
`;
|
||||
const document = createDocument("test.yaml", input);
|
||||
const hints = getInlayHints(document);
|
||||
|
||||
expect(hints).toHaveLength(2);
|
||||
expect(hints[0].label).toBe("→ Runs every hour");
|
||||
expect(hints[1].label).toBe("→ Runs at 00:00");
|
||||
});
|
||||
|
||||
it("returns hint with descriptive label for weekly cron", () => {
|
||||
const input = `on:
|
||||
schedule:
|
||||
- cron: '0 3 * * 1'
|
||||
`;
|
||||
const document = createDocument("test.yaml", input);
|
||||
const hints = getInlayHints(document);
|
||||
|
||||
expect(hints).toHaveLength(1);
|
||||
expect(hints[0].label).toContain("Monday");
|
||||
});
|
||||
|
||||
it("returns no hints for empty workflow", () => {
|
||||
const input = ``;
|
||||
const document = createDocument("test.yaml", input);
|
||||
const hints = getInlayHints(document);
|
||||
|
||||
expect(hints).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns no hints for workflow without schedule", () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hello
|
||||
`;
|
||||
const document = createDocument("test.yaml", input);
|
||||
const hints = getInlayHints(document);
|
||||
|
||||
expect(hints).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns hint for frequent cron that triggers warning", () => {
|
||||
// Even crons that trigger the <5min warning should still get inlay hints
|
||||
const input = `on:
|
||||
schedule:
|
||||
- cron: '* * * * *'
|
||||
`;
|
||||
const document = createDocument("test.yaml", input);
|
||||
const hints = getInlayHints(document);
|
||||
|
||||
expect(hints).toHaveLength(1);
|
||||
expect(hints[0].label).toBe("→ Runs every minute");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import {isString} from "@actions/workflow-parser";
|
||||
import {getCronDescription} from "@actions/workflow-parser/model/converter/cron";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {InlayHint, InlayHintKind} from "vscode-languageserver-types";
|
||||
import {fetchOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
|
||||
/**
|
||||
* Returns inlay hints for a workflow document.
|
||||
* Currently supports cron expressions, showing a human-readable description
|
||||
* of the schedule inline after the cron value.
|
||||
*
|
||||
* @param document Text document to get inlay hints for
|
||||
* @returns Array of inlay hints
|
||||
*/
|
||||
export function getInlayHints(document: TextDocument): InlayHint[] {
|
||||
const file: File = {
|
||||
name: document.uri,
|
||||
content: document.getText()
|
||||
};
|
||||
|
||||
const result = fetchOrParseWorkflow(file, document.uri);
|
||||
if (!result?.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hints: InlayHint[] = [];
|
||||
|
||||
// Traverse the workflow AST to find cron expressions
|
||||
for (const [parent, token, key] of TemplateToken.traverse(result.value)) {
|
||||
const validationToken = key || parent || token;
|
||||
const validationDefinition = validationToken.definition;
|
||||
|
||||
// Check for cron-pattern tokens
|
||||
if (isString(token) && token.range && validationDefinition?.key === "cron-pattern") {
|
||||
const cronValue = token.value;
|
||||
const description = getCronDescription(cronValue);
|
||||
|
||||
if (description) {
|
||||
// Position the hint at the end of the cron value
|
||||
hints.push({
|
||||
position: {
|
||||
line: token.range.end.line - 1, // Convert from 1-based to 0-based
|
||||
character: token.range.end.column - 1 // Convert from 1-based to 0-based
|
||||
},
|
||||
label: `→ ${description}`,
|
||||
kind: InlayHintKind.Parameter,
|
||||
paddingLeft: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hints;
|
||||
}
|
||||
@@ -417,6 +417,21 @@ jobs:
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("job.check_run_id", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo \${{ job.check_run_id }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("job.services.<service_id>", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
@@ -231,7 +231,7 @@ jobs:
|
||||
} as Diagnostic);
|
||||
});
|
||||
|
||||
it("cron with interval of 5 minutes or more shows info", async () => {
|
||||
it("cron with interval of 5 minutes or more shows no diagnostic", async () => {
|
||||
const result = await validate(
|
||||
createDocument(
|
||||
"wf.yaml",
|
||||
@@ -245,25 +245,7 @@ jobs:
|
||||
{valueProviderConfig: defaultValueProviders}
|
||||
);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]).toEqual({
|
||||
message: "Runs every 5 minutes",
|
||||
severity: DiagnosticSeverity.Information,
|
||||
code: "on-schedule",
|
||||
codeDescription: {
|
||||
href: "https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule"
|
||||
},
|
||||
range: {
|
||||
end: {
|
||||
character: 25,
|
||||
line: 2
|
||||
},
|
||||
start: {
|
||||
character: 12,
|
||||
line: 2
|
||||
}
|
||||
}
|
||||
} as Diagnostic);
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
|
||||
it("cron with comma-separated minutes less than 5 apart shows warning", async () => {
|
||||
|
||||
@@ -258,18 +258,32 @@ function validateCronExpression(diagnostics: Diagnostic[], token: StringToken):
|
||||
href: CRON_SCHEDULE_DOCS_URL
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Show info message for valid cron expressions
|
||||
}
|
||||
}
|
||||
|
||||
// Matches a short SHA (7-8 hex characters) that looks like it should be a full SHA
|
||||
const SHORT_SHA_PATTERN = /^[0-9a-f]{7,8}$/i;
|
||||
const SHORT_SHA_DOCS_URL =
|
||||
"https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions";
|
||||
|
||||
/**
|
||||
* Checks if a ref looks like a short SHA and adds a warning if so.
|
||||
* Returns true if a warning was added.
|
||||
*/
|
||||
function warnIfShortSha(diagnostics: Diagnostic[], token: StringToken, ref: string): boolean {
|
||||
if (SHORT_SHA_PATTERN.test(ref)) {
|
||||
diagnostics.push({
|
||||
message: description,
|
||||
message: `The provided ref '${ref}' may be a shortened commit SHA. If so, please use the full 40-character commit SHA instead, as short SHAs are not supported.`,
|
||||
severity: DiagnosticSeverity.Warning,
|
||||
range: mapRange(token.range),
|
||||
severity: DiagnosticSeverity.Information,
|
||||
code: "on-schedule",
|
||||
code: "short-sha-ref",
|
||||
codeDescription: {
|
||||
href: CRON_SCHEDULE_DOCS_URL
|
||||
href: SHORT_SHA_DOCS_URL
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,6 +357,9 @@ function validateStepUsesFormat(diagnostics: Diagnostic[], token: StringToken):
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn if ref looks like a short SHA
|
||||
warnIfShortSha(diagnostics, token, gitRef);
|
||||
}
|
||||
|
||||
function addStepUsesFormatError(diagnostics: Diagnostic[], token: StringToken): void {
|
||||
@@ -501,6 +518,9 @@ function validateWorkflowUsesFormat(diagnostics: Diagnostic[], token: StringToke
|
||||
addWorkflowUsesFormatError(diagnostics, token, "invalid workflow file name");
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn if version looks like a short SHA
|
||||
warnIfShortSha(diagnostics, token, version);
|
||||
}
|
||||
|
||||
function addWorkflowUsesFormatError(diagnostics: Diagnostic[], token: StringToken, reason: string): void {
|
||||
|
||||
@@ -891,4 +891,200 @@ jobs:
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("short SHA warnings", () => {
|
||||
describe("step uses", () => {
|
||||
it("warns on 7-char short SHA", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@a1b2c3d
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"The provided ref 'a1b2c3d' may be a shortened commit SHA. If so, please use the full 40-character commit SHA instead, as short SHAs are not supported.",
|
||||
severity: DiagnosticSeverity.Warning,
|
||||
range: {
|
||||
start: {line: 5, character: 12},
|
||||
end: {line: 5, character: 36}
|
||||
},
|
||||
code: "short-sha-ref",
|
||||
codeDescription: {
|
||||
href: "https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions"
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("warns on 8-char short SHA", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@a1b2c3d4
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"The provided ref 'a1b2c3d4' may be a shortened commit SHA. If so, please use the full 40-character commit SHA instead, as short SHAs are not supported.",
|
||||
severity: DiagnosticSeverity.Warning,
|
||||
range: {
|
||||
start: {line: 5, character: 12},
|
||||
end: {line: 5, character: 37}
|
||||
},
|
||||
code: "short-sha-ref",
|
||||
codeDescription: {
|
||||
href: "https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions"
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not warn on full SHA (40 chars)", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not warn on tag ref", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not warn on branch ref", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@main
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not warn on Docker action", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: docker://alpine:3.8
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not warn on local action", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: ./my-action
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow uses", () => {
|
||||
it("warns on 7-char short SHA in reusable workflow", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/ci.yml@a1b2c3d
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"The provided ref 'a1b2c3d' may be a shortened commit SHA. If so, please use the full 40-character commit SHA instead, as short SHAs are not supported.",
|
||||
severity: DiagnosticSeverity.Warning,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 53}
|
||||
},
|
||||
code: "short-sha-ref",
|
||||
codeDescription: {
|
||||
href: "https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions"
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("warns on 8-char short SHA in reusable workflow", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/ci.yml@a1b2c3d4
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"The provided ref 'a1b2c3d4' may be a shortened commit SHA. If so, please use the full 40-character commit SHA instead, as short SHAs are not supported.",
|
||||
severity: DiagnosticSeverity.Warning,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 54}
|
||||
},
|
||||
code: "short-sha-ref",
|
||||
codeDescription: {
|
||||
href: "https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions"
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not warn on full SHA in reusable workflow", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/ci.yml@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not warn on tag ref in reusable workflow", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/ci.yml@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not warn on local workflow", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,11 +7,26 @@ export interface Value {
|
||||
/** Optional description to show when auto-completing */
|
||||
description?: string;
|
||||
|
||||
/** Optional detail shown after the label, e.g. type or kind information */
|
||||
detail?: string;
|
||||
|
||||
/** Whether this value is deprecated */
|
||||
deprecated?: boolean;
|
||||
|
||||
/** Alternative insert text, if not given `label` will be used */
|
||||
insertText?: string;
|
||||
|
||||
/** Alternative filter text, if not given `label` will be used for filtering */
|
||||
filterText?: string;
|
||||
|
||||
/** Sort text to control ordering, if not given `label` will be used for sorting */
|
||||
sortText?: string;
|
||||
|
||||
/** Custom text edit with specific range, overrides default range calculation */
|
||||
textEdit?: {
|
||||
range: {start: {line: number; character: number}; end: {line: number; character: number}};
|
||||
newText: string;
|
||||
};
|
||||
}
|
||||
|
||||
export enum ValueProviderKind {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {NullDefinition} from "@actions/workflow-parser/templates/schema/null-definition";
|
||||
import {BooleanDefinition} from "@actions/workflow-parser/templates/schema/boolean-definition";
|
||||
import {Definition} from "@actions/workflow-parser/templates/schema/definition";
|
||||
import {DefinitionType} from "@actions/workflow-parser/templates/schema/definition-type";
|
||||
@@ -24,7 +25,35 @@ export enum DefinitionValueMode {
|
||||
Key
|
||||
}
|
||||
|
||||
export function definitionValues(def: Definition, indentation: string, mode: DefinitionValueMode): Value[] {
|
||||
/**
|
||||
* What YAML structure the user has started typing.
|
||||
* Used to filter completions - e.g., if user started a mapping, don't show string completions.
|
||||
*/
|
||||
export type TokenStructure = "scalar" | "sequence" | "mapping" | undefined;
|
||||
|
||||
/**
|
||||
* Generates completion values from a workflow schema definition.
|
||||
*
|
||||
* This is the fallback when no custom or default value provider exists for a token.
|
||||
* It reads the schema definition to determine what values are valid.
|
||||
*
|
||||
* Examples:
|
||||
* - For a job definition this returns keys like "runs-on", "steps", "env", "timeout-minutes", etc.
|
||||
* - For `shell: |`, the schema says it's a string with no constants,
|
||||
* so this returns no completions
|
||||
* - For `continue-on-error: |` on a step, the schema has a boolean definition,
|
||||
* so this returns ["true", "false"]
|
||||
*
|
||||
* @param tokenStructure - If provided, filters completions to only those matching
|
||||
* the YAML structure the user has already started (e.g., only mapping keys if
|
||||
* they've started a mapping)
|
||||
*/
|
||||
export function definitionValues(
|
||||
def: Definition,
|
||||
indentation: string,
|
||||
mode: DefinitionValueMode,
|
||||
tokenStructure?: TokenStructure
|
||||
): Value[] {
|
||||
const schema = getWorkflowSchema();
|
||||
|
||||
if (def instanceof MappingDefinition) {
|
||||
@@ -32,7 +61,7 @@ export function definitionValues(def: Definition, indentation: string, mode: Def
|
||||
}
|
||||
|
||||
if (def instanceof OneOfDefinition) {
|
||||
return oneOfValues(def, schema.definitions, indentation, mode);
|
||||
return oneOfValues(def, schema.definitions, indentation, mode, tokenStructure);
|
||||
}
|
||||
|
||||
if (def instanceof BooleanDefinition) {
|
||||
@@ -58,6 +87,16 @@ export function definitionValues(def: Definition, indentation: string, mode: Def
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns completion items for keys in a mapping (object).
|
||||
*
|
||||
* For example, given the job definition, this returns completions for
|
||||
* "runs-on", "steps", "env", etc. Each completion includes appropriate
|
||||
* insert text based on the expected value type:
|
||||
* - Sequence properties insert `key:\n - ` to start a list
|
||||
* - Mapping properties insert `key:\n ` to start nested keys
|
||||
* - Scalar properties insert `key: ` for inline values
|
||||
*/
|
||||
function mappingValues(
|
||||
mappingDefinition: MappingDefinition,
|
||||
definitions: {[key: string]: Definition},
|
||||
@@ -91,13 +130,13 @@ function mappingValues(
|
||||
}
|
||||
break;
|
||||
|
||||
case DefinitionType.OneOf:
|
||||
if (mode == DefinitionValueMode.Key) {
|
||||
insertText = `\n${indentation}${key}: `;
|
||||
} else {
|
||||
insertText = `${key}: `;
|
||||
}
|
||||
break;
|
||||
case DefinitionType.OneOf: {
|
||||
// Expand one-of into multiple completions based on structural type
|
||||
const oneOfDef = typeDef as OneOfDefinition;
|
||||
const expanded = expandOneOfToCompletions(oneOfDef, definitions, key, description, indentation, mode);
|
||||
properties.push(...expanded);
|
||||
continue; // Skip the default push below
|
||||
}
|
||||
|
||||
case DefinitionType.String:
|
||||
case DefinitionType.Boolean:
|
||||
@@ -123,23 +162,188 @@ function mappingValues(
|
||||
return properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns completions for values that can be one of several types.
|
||||
*
|
||||
* For example, `on:` can be a string ("push"), a list (["push", "pull_request"]),
|
||||
* or a mapping with event configuration. This function collects completions from
|
||||
* all valid variants.
|
||||
*
|
||||
* If the user has already started typing a specific structure (e.g., started a list),
|
||||
* only completions for that structure are returned.
|
||||
*/
|
||||
function oneOfValues(
|
||||
oneOfDefinition: OneOfDefinition,
|
||||
definitions: {[key: string]: Definition},
|
||||
indentation: string,
|
||||
mode: DefinitionValueMode
|
||||
mode: DefinitionValueMode,
|
||||
tokenStructure?: TokenStructure
|
||||
): Value[] {
|
||||
const values: Value[] = [];
|
||||
for (const key of oneOfDefinition.oneOf) {
|
||||
values.push(...definitionValues(definitions[key], indentation, mode));
|
||||
const variantDef = definitions[key];
|
||||
|
||||
// Should never happen - the schema should always have valid references
|
||||
if (!variantDef) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip variants that don't match what the user has already started typing.
|
||||
// For example, if user is at `runs-on:\n |` (inside a mapping), skip the string
|
||||
// variant - only include the mapping variant that suggests keys like "group" or "labels".
|
||||
if (tokenStructure) {
|
||||
const variantBucket = getStructuralBucket(variantDef.definitionType);
|
||||
if (variantBucket !== tokenStructure) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// In Key mode (after colon, e.g., `on: |`), only include scalar variants when
|
||||
// completing an empty value. Mapping/sequence forms require newlines which is
|
||||
// confusing when typing inline. Users who want those forms can use completions
|
||||
// like `(full syntax)` or `(list)` at the parent level.
|
||||
if (!tokenStructure && mode === DefinitionValueMode.Key) {
|
||||
const variantBucket = getStructuralBucket(variantDef.definitionType);
|
||||
if (variantBucket !== "scalar") {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
values.push(...definitionValues(variantDef, indentation, mode, tokenStructure));
|
||||
}
|
||||
return distinctValues(values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates values by label and detail.
|
||||
* Values with the same label but different details are preserved as distinct items.
|
||||
*/
|
||||
function distinctValues(values: Value[]): Value[] {
|
||||
const map = new Map<string, Value>();
|
||||
for (const value of values) {
|
||||
map.set(value.label, value);
|
||||
// Include detail in the key to preserve variants with different details
|
||||
const key = value.detail ? `${value.label}\0${value.detail}` : value.label;
|
||||
map.set(key, value);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket type for one-of expansion
|
||||
*/
|
||||
type StructuralBucket = "scalar" | "sequence" | "mapping";
|
||||
|
||||
/**
|
||||
* Get the structural bucket for a definition type.
|
||||
* Nested one-of is treated as scalar.
|
||||
*/
|
||||
function getStructuralBucket(defType: DefinitionType): StructuralBucket {
|
||||
switch (defType) {
|
||||
case DefinitionType.Sequence:
|
||||
return "sequence";
|
||||
case DefinitionType.Mapping:
|
||||
return "mapping";
|
||||
default:
|
||||
// String, Boolean, Number, Null, OneOf (nested), AllowedValues -> scalar
|
||||
// Note, nested OneOf is assumed to be all scalar values, which is true in practice.
|
||||
return "scalar";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates completion items for a key whose value can be multiple formats.
|
||||
*
|
||||
* For example, `runs-on` can be a string, list, or mapping. This function creates
|
||||
* separate completions for each format:
|
||||
* - "runs-on" for the string form (`runs-on: ubuntu-latest`)
|
||||
* - "runs-on (list)" for the list form (`runs-on:\n - ubuntu-latest`)
|
||||
* - "runs-on (full syntax)" for the mapping form (`runs-on:\n group: my-group`)
|
||||
*
|
||||
* The qualifier (list/full syntax) is only added when multiple formats exist.
|
||||
*/
|
||||
function expandOneOfToCompletions(
|
||||
oneOfDef: OneOfDefinition,
|
||||
definitions: {[key: string]: Definition},
|
||||
key: string,
|
||||
description: string | undefined,
|
||||
indentation: string,
|
||||
mode: DefinitionValueMode
|
||||
): Value[] {
|
||||
// Bucket variants by structural type
|
||||
const buckets: Record<StructuralBucket, boolean> = {
|
||||
scalar: false,
|
||||
sequence: false,
|
||||
mapping: false
|
||||
};
|
||||
|
||||
// Track if scalar bucket only contains null (no actual string/boolean/number values)
|
||||
let scalarIsOnlyNull = true;
|
||||
|
||||
for (const variantKey of oneOfDef.oneOf) {
|
||||
const variantDef = definitions[variantKey];
|
||||
if (variantDef) {
|
||||
const bucket = getStructuralBucket(variantDef.definitionType);
|
||||
buckets[bucket] = true;
|
||||
|
||||
// Check if this scalar is NOT null
|
||||
if (bucket === "scalar" && !(variantDef instanceof NullDefinition)) {
|
||||
scalarIsOnlyNull = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const results: Value[] = [];
|
||||
|
||||
// Count how many structural types are present
|
||||
const bucketCount = [buckets.scalar, buckets.sequence, buckets.mapping].filter(Boolean).length;
|
||||
const needsQualifier = bucketCount > 1;
|
||||
|
||||
// Emit completions in order: scalar, sequence, mapping
|
||||
// Use sortText to preserve this order (scalar sorts first, then 1=sequence, 2=mapping)
|
||||
//
|
||||
// In Key mode (after colon on same line), skip the key completion if scalar only allows null.
|
||||
// Example: at `on: |`, we want `check_run` to insert inline, not start a new mapping.
|
||||
//
|
||||
// In Parent mode (typing a new key), we DO show it since `check_run:` with no value
|
||||
// is valid (triggers on all check_run events).
|
||||
const skipNullOnlyScalar = mode === DefinitionValueMode.Key && scalarIsOnlyNull;
|
||||
if (buckets.scalar && !skipNullOnlyScalar) {
|
||||
// If cursor is after colon (`on: |`), insert newline first so result is `on:\n check_run: `
|
||||
const insertText = mode === DefinitionValueMode.Key ? `\n${indentation}${key}: ` : `${key}: `;
|
||||
results.push({
|
||||
label: key,
|
||||
description,
|
||||
insertText
|
||||
});
|
||||
}
|
||||
|
||||
if (buckets.sequence) {
|
||||
const insertText =
|
||||
mode === DefinitionValueMode.Key
|
||||
? `\n${indentation}${key}:\n${indentation}${indentation}- `
|
||||
: `${key}:\n${indentation}- `;
|
||||
results.push({
|
||||
label: key,
|
||||
description,
|
||||
detail: needsQualifier ? "list" : undefined,
|
||||
insertText,
|
||||
sortText: needsQualifier ? `${key} 1` : undefined
|
||||
});
|
||||
}
|
||||
|
||||
if (buckets.mapping) {
|
||||
const insertText =
|
||||
mode === DefinitionValueMode.Key
|
||||
? `\n${indentation}${key}:\n${indentation}${indentation}`
|
||||
: `${key}:\n${indentation}`;
|
||||
results.push({
|
||||
label: key,
|
||||
description,
|
||||
detail: needsQualifier ? "full syntax" : undefined,
|
||||
insertText,
|
||||
sortText: needsQualifier ? `${key} 2` : undefined
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,5 +6,5 @@
|
||||
"languageservice",
|
||||
"languageserver"
|
||||
],
|
||||
"version": "0.3.26"
|
||||
"version": "0.3.29"
|
||||
}
|
||||
Generated
+9
-9
@@ -136,7 +136,7 @@
|
||||
},
|
||||
"expressions": {
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.26",
|
||||
"version": "0.3.29",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.0.3",
|
||||
@@ -396,11 +396,11 @@
|
||||
},
|
||||
"languageserver": {
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.26",
|
||||
"version": "0.3.29",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.26",
|
||||
"@actions/workflow-parser": "^0.3.26",
|
||||
"@actions/languageservice": "^0.3.29",
|
||||
"@actions/workflow-parser": "^0.3.29",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
@@ -940,11 +940,11 @@
|
||||
},
|
||||
"languageservice": {
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.26",
|
||||
"version": "0.3.29",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.26",
|
||||
"@actions/workflow-parser": "^0.3.26",
|
||||
"@actions/expressions": "^0.3.29",
|
||||
"@actions/workflow-parser": "^0.3.29",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
"vscode-uri": "^3.0.8",
|
||||
@@ -13345,10 +13345,10 @@
|
||||
},
|
||||
"workflow-parser": {
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.26",
|
||||
"version": "0.3.29",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.26",
|
||||
"@actions/expressions": "^0.3.29",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.26",
|
||||
"version": "0.3.29",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
@@ -48,7 +48,7 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.26",
|
||||
"@actions/expressions": "^0.3.29",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user