Compare commits

...

5 Commits

Author SHA1 Message Date
github-actions[bot] f84e42c1f1 Release extension version 0.3.29 (#272)
Co-authored-by: GitHub Actions <github-actions@github.com>
2025-12-29 22:27:06 -06:00
eric sciple 08c78d2a73 Replace cron info diagnostics with inlay hints (#270)
- Remove DiagnosticSeverity.Information for valid cron expressions
- Add new inlay-hints.ts module with getInlayHints() function
- Register inlayHintProvider capability in language server
- Display human-readable cron descriptions as inline hints

Related #269
2025-12-29 13:47:30 -06:00
eric sciple 26f3969cde Add escape hatch completions to switch structural forms (#271)
When completing an empty value position (e.g., `runs-on: |`), add special
completions that let users switch to alternative structural forms:

- "(switch to list)" - restructures to `key:\n  - `
- "(switch to mapping)" - restructures to `key:\n  `

These help users escape "dead end" situations where the current form has
no valid completions but alternative forms are available in the schema.
2025-12-29 12:54:53 -06:00
eric sciple 61a6fc54f2 Use detail field for one-of qualifiers instead of label (#266)
- Move qualifiers (list, full syntax) from label to detail field
- Remove filterText since labels are now clean
- Update distinctValues to preserve variants with different details
- Standard LSP pattern: detail shown after label in completion UI
2025-12-29 10:45:46 -06:00
eric sciple 6511be5ab4 Fix autocomplete showing mapping keys for empty values (#268)
Follow-up to #265

When completing an empty value (e.g., `permissions: |`), mapping keys were
incorrectly shown alongside scalar values. This made completions confusing.

Before:
- `permissions: |` showed read-all, write-all, AND actions, contents, etc.
- `on: |` showed check_run AND check_run (full syntax), etc.

After:
- `permissions: |` shows only read-all and write-all
- `on: |` shows only event names like push, check_run
- `concurrency: |` shows no completions (user types their own group name)

Users who want the mapping form choose (full syntax) completions at the
parent level.
2025-12-29 09:50:59 -06:00
17 changed files with 587 additions and 126 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.28",
"version": "0.3.29",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.28",
"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.28",
"@actions/workflow-parser": "^0.3.28",
"@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",
+11 -2
View File
@@ -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);
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageservice",
"version": "0.3.28",
"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.28",
"@actions/workflow-parser": "^0.3.28",
"@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",
+219 -66
View File
@@ -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 () => {
@@ -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 () => {
@@ -266,7 +277,10 @@ jobs:
concurrency: 'group-name'`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(29);
// 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(25);
// 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(25);
// 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 () => {
@@ -510,7 +529,9 @@ 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("does not show mapping keys for one-of when user has typed a scalar value", async () => {
@@ -521,12 +542,14 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
// 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 (full syntax)` instead)
// (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("shows all options for one-of when user hasn't committed to a type yet", async () => {
// At `permissions: |` user hasn't typed anything yet - show all options
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));
@@ -535,9 +558,9 @@ jobs:
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 should also be available (user hasn't committed yet)
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 () => {
@@ -553,20 +576,18 @@ jobs:
expect(result.filter(x => x.label === "contents")).toEqual([]);
});
it("shows full syntax for null+mapping one-of (skips null-only scalar)", async () => {
// check_run is a one-of: [null, mapping].
// Since the scalar form is only null (no string constants), we skip it
// to avoid clobbering string constants from elsewhere in the schema.
// User should see check_run (full syntax) for the mapping form.
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 NOT have plain check_run (null-only scalar is skipped)
// Instead, string constant check_run from on-string-strict is available
expect(result.some(x => x.label === "check_run")).toBe(true);
// Full syntax variant should be available
expect(result.some(x => x.label === "check_run (full syntax)")).toBe(true);
// 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 () => {
@@ -578,10 +599,12 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
// Should have runs-on, runs-on (list), and runs-on (full syntax)
expect(result.some(x => x.label === "runs-on")).toBe(true);
expect(result.some(x => x.label === "runs-on (list)")).toBe(true);
expect(result.some(x => x.label === "runs-on (full syntax)")).toBe(true);
// 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 () => {
@@ -593,31 +616,34 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
const runsOnVariants = result.filter(x => x.label === "runs-on");
// Scalar: just key with colon and space
expect(result.find(x => x.label === "runs-on")?.textEdit?.newText).toEqual("runs-on: ");
expect(runsOnVariants.find(x => x.detail === undefined)?.textEdit?.newText).toEqual("runs-on: ");
// Sequence: key with colon, newline, and list item
expect(result.find(x => x.label === "runs-on (list)")?.textEdit?.newText).toEqual("runs-on:\n - ");
expect(runsOnVariants.find(x => x.detail === "list")?.textEdit?.newText).toEqual("runs-on:\n - ");
// Mapping: key with colon, newline, and indentation for nested keys
expect(result.find(x => x.label === "runs-on (full syntax)")?.textEdit?.newText).toEqual("runs-on:\n ");
expect(runsOnVariants.find(x => x.detail === "full syntax")?.textEdit?.newText).toEqual("runs-on:\n ");
});
it("generates correct insertText for one-of variants in key mode", async () => {
// concurrency is a one-of: [string, mapping] - testing key mode (after colon on same line)
const input = "concurrency: |";
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));
// Scalar in key mode: newline + indented key + colon + space
expect(result.find(x => x.label === "group")?.textEdit?.newText).toEqual("\n group: ");
// In parent mode: just key + colon + space (no leading newline)
expect(result.find(x => x.label === "group")?.textEdit?.newText).toEqual("group: ");
// Boolean in key mode (cancel-in-progress): newline + indented key + colon + space
expect(result.find(x => x.label === "cancel-in-progress")?.textEdit?.newText).toEqual("\n cancel-in-progress: ");
// 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 base key as filterText for qualified one-of variants", async () => {
// runs-on has multiple structural types, so variants get qualifiers
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:
@@ -625,12 +651,14 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
// Scalar: no qualifier, so no filterText needed
expect(result.find(x => x.label === "runs-on")?.filterText).toBeUndefined();
const runsOnVariants = result.filter(x => x.label === "runs-on");
// Sequence and mapping: qualified labels should filter on base key
expect(result.find(x => x.label === "runs-on (list)")?.filterText).toEqual("runs-on");
expect(result.find(x => x.label === "runs-on (full syntax)")?.filterText).toEqual("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 () => {
@@ -644,14 +672,13 @@ jobs:
const push = result.find(x => x.label === "push");
expect(push?.textEdit?.newText).toEqual("push");
const checkRun = result.find(x => x.label === "check_run");
const checkRun = result.find(x => x.label === "check_run" && x.detail === undefined);
expect(checkRun?.textEdit?.newText).toEqual("check_run");
// Full syntax form inserts as a mapping key (with newline in Key mode)
// This is expected behavior - it starts the mapping form
const checkRunFull = result.find(x => x.label === "check_run (full syntax)");
// In Key mode: \n + indent + key + : + \n + indent + indent (for nested content)
expect(checkRunFull?.textEdit?.newText).toEqual("\n check_run:\n ");
// 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 () => {
@@ -672,4 +699,130 @@ jobs:
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 - ");
});
});
});
+130 -1
View File
@@ -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";
@@ -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,8 +139,19 @@ 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 && {
@@ -136,7 +159,7 @@ export async function complete(
value: value.description
},
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
textEdit: replaceRange ? TextEdit.replace(replaceRange, newText) : TextEdit.insert(position, newText)
textEdit
};
return item;
@@ -245,6 +268,112 @@ function getTokenStructure(token: TemplateToken | null): TokenStructure {
}
}
/**
* 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.
+2 -2
View File
@@ -22,8 +22,8 @@ describe("end-to-end", () => {
expect(result).not.toBeUndefined();
expect(result.length).toEqual(13);
const labels = result.map(x => x.label);
expect(labels).toEqual([
const labelsWithDetails = result.map(x => (x.detail ? `${x.label} (${x.detail})` : x.label));
expect(labelsWithDetails).toEqual([
"concurrency",
"concurrency (full syntax)",
"defaults",
+1
View File
@@ -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";
+116
View File
@@ -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");
});
});
});
+56
View File
@@ -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;
}
+2 -20
View File
@@ -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 () => {
-11
View File
@@ -258,17 +258,6 @@ function validateCronExpression(diagnostics: Diagnostic[], token: StringToken):
href: CRON_SCHEDULE_DOCS_URL
}
});
} else {
// Show info message for valid cron expressions
diagnostics.push({
message: description,
range: mapRange(token.range),
severity: DiagnosticSeverity.Information,
code: "on-schedule",
codeDescription: {
href: CRON_SCHEDULE_DOCS_URL
}
});
}
}
@@ -7,6 +7,9 @@ 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;
@@ -18,6 +21,12 @@ export interface Value {
/** 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 {
@@ -198,15 +198,32 @@ function oneOfValues(
}
}
// 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());
}
@@ -306,10 +323,10 @@ function expandOneOfToCompletions(
? `\n${indentation}${key}:\n${indentation}${indentation}- `
: `${key}:\n${indentation}- `;
results.push({
label: needsQualifier ? `${key} (list)` : key,
label: key,
description,
detail: needsQualifier ? "list" : undefined,
insertText,
filterText: needsQualifier ? key : undefined,
sortText: needsQualifier ? `${key} 1` : undefined
});
}
@@ -320,10 +337,10 @@ function expandOneOfToCompletions(
? `\n${indentation}${key}:\n${indentation}${indentation}`
: `${key}:\n${indentation}`;
results.push({
label: needsQualifier ? `${key} (full syntax)` : key,
label: key,
description,
detail: needsQualifier ? "full syntax" : undefined,
insertText,
filterText: needsQualifier ? key : undefined,
sortText: needsQualifier ? `${key} 2` : undefined
});
}
+1 -1
View File
@@ -6,5 +6,5 @@
"languageservice",
"languageserver"
],
"version": "0.3.28"
"version": "0.3.29"
}
+9 -9
View File
@@ -136,7 +136,7 @@
},
"expressions": {
"name": "@actions/expressions",
"version": "0.3.28",
"version": "0.3.29",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.0.3",
@@ -396,11 +396,11 @@
},
"languageserver": {
"name": "@actions/languageserver",
"version": "0.3.28",
"version": "0.3.29",
"license": "MIT",
"dependencies": {
"@actions/languageservice": "^0.3.28",
"@actions/workflow-parser": "^0.3.28",
"@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.28",
"version": "0.3.29",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.28",
"@actions/workflow-parser": "^0.3.28",
"@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.28",
"version": "0.3.29",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.28",
"@actions/expressions": "^0.3.29",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/workflow-parser",
"version": "0.3.28",
"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.28",
"@actions/expressions": "^0.3.29",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},