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));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
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);
|
const labels = result.map(x => x.label);
|
||||||
expect(labels).toContain("macos-latest");
|
expect(labels).toContain("macos-latest");
|
||||||
|
expect(labels).toContain("(switch to list)");
|
||||||
|
expect(labels).toContain("(switch to mapping)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("needs", async () => {
|
it("needs", async () => {
|
||||||
@@ -95,6 +98,7 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
types: |`;
|
types: |`;
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
// Expect string values plus escape hatch to switch to list form
|
||||||
expect(result.map(x => x.label)).toEqual([
|
expect(result.map(x => x.label)).toEqual([
|
||||||
"created",
|
"created",
|
||||||
"deleted",
|
"deleted",
|
||||||
@@ -102,7 +106,8 @@ jobs:
|
|||||||
"prereleased",
|
"prereleased",
|
||||||
"published",
|
"published",
|
||||||
"released",
|
"released",
|
||||||
"unpublished"
|
"unpublished",
|
||||||
|
"(switch to list)"
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,8 +195,11 @@ jobs:
|
|||||||
const result = await complete(...getPositionFromCursor(input), {valueProviderConfig: config});
|
const result = await complete(...getPositionFromCursor(input), {valueProviderConfig: config});
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
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[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 () => {
|
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 () => {
|
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.
|
// 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
|
const input = `on: push
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -222,21 +230,21 @@ jobs:
|
|||||||
runs-on: ubuntu-latest`;
|
runs-on: ubuntu-latest`;
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
// No completions because: scalar has no constants, mapping variant skipped in Key mode
|
// Only escape hatch to full syntax (container has mapping form but no sequence)
|
||||||
expect(result.length).toEqual(0);
|
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show mapping keys in Key mode when structure is uncommitted", 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.
|
// 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.
|
// The scalar form is a string with no constants, so no scalar completions.
|
||||||
// Mapping keys are NOT shown - users should use `concurrency (full syntax)` at parent level.
|
// But escape hatch to full syntax IS shown as a way out.
|
||||||
const input = `on: push
|
const input = `on: push
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
concurrency: |`;
|
concurrency: |`;
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
expect(result).not.toBeUndefined();
|
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 () => {
|
it("job key", async () => {
|
||||||
@@ -691,4 +699,130 @@ jobs:
|
|||||||
expect(result.filter(x => x.label === "group")).toEqual([]);
|
expect(result.filter(x => x.label === "group")).toEqual([]);
|
||||||
expect(result.filter(x => x.label === "labels")).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 {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion";
|
||||||
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
|
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
|
||||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
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 {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
|
||||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
||||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
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 {TokenType} from "@actions/workflow-parser/templates/tokens/types";
|
||||||
import {File} from "@actions/workflow-parser/workflows/file";
|
import {File} from "@actions/workflow-parser/workflows/file";
|
||||||
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
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 {Position, TextDocument} from "vscode-languageserver-textdocument";
|
||||||
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
|
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
|
||||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
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);
|
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;
|
let replaceRange: Range | undefined;
|
||||||
if (token?.range) {
|
if (token?.range) {
|
||||||
|
// Prefer the token's range since it accounts for YAML syntax like quotes
|
||||||
replaceRange = mapRange(token.range);
|
replaceRange = mapRange(token.range);
|
||||||
} else if (!token) {
|
} else if (!token) {
|
||||||
// Not a valid token, create a range from the current position
|
// Not a valid token, create a range from the current position
|
||||||
@@ -127,6 +139,16 @@ export async function complete(
|
|||||||
return values.map(value => {
|
return values.map(value => {
|
||||||
const newText = value.insertText || value.label;
|
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 = {
|
const item: CompletionItem = {
|
||||||
label: value.label,
|
label: value.label,
|
||||||
detail: value.detail,
|
detail: value.detail,
|
||||||
@@ -137,7 +159,7 @@ export async function complete(
|
|||||||
value: value.description
|
value: value.description
|
||||||
},
|
},
|
||||||
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
|
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
|
||||||
textEdit: replaceRange ? TextEdit.replace(replaceRange, newText) : TextEdit.insert(position, newText)
|
textEdit
|
||||||
};
|
};
|
||||||
|
|
||||||
return item;
|
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
|
* Collects values that are already present in the current context, so they can be
|
||||||
* excluded from completion suggestions.
|
* 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 */
|
/** Sort text to control ordering, if not given `label` will be used for sorting */
|
||||||
sortText?: string;
|
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 {
|
export enum ValueProviderKind {
|
||||||
|
|||||||
Reference in New Issue
Block a user