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:
@@ -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 - ");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user