diff --git a/languageservice/src/complete.test.ts b/languageservice/src/complete.test.ts index 45d5005..e9fa920 100644 --- a/languageservice/src/complete.test.ts +++ b/languageservice/src/complete.test.ts @@ -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 - "); + }); + }); }); diff --git a/languageservice/src/complete.ts b/languageservice/src/complete.ts index c33b9fd..f8f01df 100644 --- a/languageservice/src/complete.ts +++ b/languageservice/src/complete.ts @@ -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. diff --git a/languageservice/src/value-providers/config.ts b/languageservice/src/value-providers/config.ts index b2e8518..63cd694 100644 --- a/languageservice/src/value-providers/config.ts +++ b/languageservice/src/value-providers/config.ts @@ -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 {