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.
This commit is contained in:
eric sciple
2025-12-29 12:54:53 -06:00
committed by GitHub
parent 61a6fc54f2
commit 26f3969cde
3 changed files with 278 additions and 10 deletions
+143 -9
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 () => {
@@ -214,7 +222,7 @@ jobs:
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 - users should use `container (full syntax)`.
// Mapping keys should NOT be shown inline - but escape hatch to full syntax IS shown.
const input = `on: push
jobs:
build:
@@ -222,21 +230,21 @@ jobs:
runs-on: ubuntu-latest`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
// No completions because: scalar has no constants, mapping variant skipped in Key mode
expect(result.length).toEqual(0);
// Only escape hatch to full syntax (container has mapping form but no sequence)
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
});
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 completions.
// Mapping keys are NOT shown - users should use `concurrency (full syntax)` at parent level.
// 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)).toEqual([]);
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
});
it("job key", async () => {
@@ -691,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 - ");
});
});
});
+129 -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,6 +139,16 @@ 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,
@@ -137,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;
@@ -246,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.
@@ -21,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 {