diff --git a/languageservice/src/complete.test.ts b/languageservice/src/complete.test.ts index bdd078d..756c138 100644 --- a/languageservice/src/complete.test.ts +++ b/languageservice/src/complete.test.ts @@ -44,7 +44,7 @@ jobs: const result = await complete(...getPositionFromCursor(input)); expect(result).not.toBeUndefined(); - expect(result.length).toEqual(9); + expect(result.length).toEqual(13); expect(result[0].label).toEqual("concurrency"); }); @@ -70,7 +70,7 @@ jobs: |`; const result = await complete(...getPositionFromCursor(input)); expect(result).not.toBeUndefined(); - expect(result.length).toEqual(21); + expect(result.length).toEqual(30); }); it("string definition completion in sequence", async () => { @@ -243,7 +243,7 @@ jobs: runs-|`; const result = await complete(...getPositionFromCursor(input)); expect(result).not.toBeUndefined(); - expect(result).toHaveLength(21); + expect(result).toHaveLength(30); }); it("job key with comment afterwards", async () => { @@ -254,7 +254,7 @@ jobs: #`; const result = await complete(...getPositionFromCursor(input)); expect(result).not.toBeUndefined(); - expect(result).toHaveLength(21); + expect(result).toHaveLength(30); }); it("job key with other values afterwards", async () => { @@ -266,7 +266,7 @@ jobs: concurrency: 'group-name'`; const result = await complete(...getPositionFromCursor(input)); expect(result).not.toBeUndefined(); - expect(result).toHaveLength(20); + expect(result).toHaveLength(29); }); it("step key without space after colon", async () => { @@ -335,7 +335,7 @@ jobs: - uses: actions/checkout@v2 `; const result = await complete(...getPositionFromCursor(input)); - expect(result).toHaveLength(17); + expect(result).toHaveLength(25); }); it("complete from behind a colon will replace it", async () => { @@ -348,7 +348,7 @@ jobs: - uses: actions/checkout@v2 `; const result = await complete(...getPositionFromCursor(input)); - expect(result).toHaveLength(17); + expect(result).toHaveLength(25); const textEdit = result[0].textEdit as TextEdit; expect(textEdit.range).toEqual({ start: {line: 5, character: 4}, @@ -533,4 +533,81 @@ jobs: 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: "]); }); + + 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 (full syntax) (ready to add mapping keys) + const input = "on:\n |"; + + const result = await complete(...getPositionFromCursor(input)); + + // Should have both check_run and check_run (full syntax) + expect(result.some(x => x.label === "check_run")).toBe(true); + expect(result.some(x => x.label === "check_run (full syntax)")).toBe(true); + }); + + it("shows all three variants for scalar+sequence+mapping one-of", async () => { + // runs-on is a one-of: [string, sequence, mapping] + const input = `on: push +jobs: + build: + |`; + + 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); + }); + + it("generates correct insertText for one-of variants in parent mode", async () => { + // runs-on is a one-of: [string, sequence, mapping] + const input = `on: push +jobs: + build: + |`; + + const result = await complete(...getPositionFromCursor(input)); + + // Scalar: just key with colon and space + expect(result.find(x => x.label === "runs-on")?.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 - "); + + // 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 "); + }); + + 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: |"; + + 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: "); + + // 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: "); + }); + + it("uses base key as filterText for qualified one-of variants", async () => { + // runs-on has multiple structural types, so variants get qualifiers + const input = `on: push +jobs: + build: + |`; + + const result = await complete(...getPositionFromCursor(input)); + + // Scalar: no qualifier, so no filterText needed + expect(result.find(x => x.label === "runs-on")?.filterText).toBeUndefined(); + + // 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"); + }); }); diff --git a/languageservice/src/complete.ts b/languageservice/src/complete.ts index e54bc0b..96ea3b0 100644 --- a/languageservice/src/complete.ts +++ b/languageservice/src/complete.ts @@ -129,6 +129,8 @@ export async function complete( const item: CompletionItem = { label: value.label, + filterText: value.filterText, + sortText: value.sortText, documentation: value.description && { kind: "markdown", value: value.description @@ -253,7 +255,7 @@ function getExpressionCompletionItems( function filterAndSortCompletionOptions(options: Value[], existingValues?: Set) { options = options.filter(x => !existingValues?.has(x.label)); - options.sort((a, b) => a.label.localeCompare(b.label)); + options.sort((a, b) => (a.sortText ?? a.label).localeCompare(b.sortText ?? b.label)); return options; } diff --git a/languageservice/src/e2e.test.ts b/languageservice/src/e2e.test.ts index da61cc4..3a88500 100644 --- a/languageservice/src/e2e.test.ts +++ b/languageservice/src/e2e.test.ts @@ -21,17 +21,21 @@ describe("end-to-end", () => { const result = await complete(...getPositionFromCursor(input)); expect(result).not.toBeUndefined(); - expect(result.length).toEqual(9); + expect(result.length).toEqual(13); const labels = result.map(x => x.label); expect(labels).toEqual([ "concurrency", + "concurrency (full syntax)", "defaults", "description", "env", "jobs", "name", "on", + "on (list)", + "on (full syntax)", "permissions", + "permissions (full syntax)", "run-name" ]); }); diff --git a/languageservice/src/value-providers/config.ts b/languageservice/src/value-providers/config.ts index c1a7d5d..cc7e428 100644 --- a/languageservice/src/value-providers/config.ts +++ b/languageservice/src/value-providers/config.ts @@ -12,6 +12,12 @@ export interface Value { /** Alternative insert text, if not given `label` will be used */ insertText?: string; + + /** Alternative filter text, if not given `label` will be used for filtering */ + filterText?: string; + + /** Sort text to control ordering, if not given `label` will be used for sorting */ + sortText?: string; } export enum ValueProviderKind { diff --git a/languageservice/src/value-providers/definition.ts b/languageservice/src/value-providers/definition.ts index 99137a3..a37ff3e 100644 --- a/languageservice/src/value-providers/definition.ts +++ b/languageservice/src/value-providers/definition.ts @@ -91,13 +91,13 @@ function mappingValues( } break; - case DefinitionType.OneOf: - if (mode == DefinitionValueMode.Key) { - insertText = `\n${indentation}${key}: `; - } else { - insertText = `${key}: `; - } - break; + case DefinitionType.OneOf: { + // Expand one-of into multiple completions based on structural type + const oneOfDef = typeDef as OneOfDefinition; + const expanded = expandOneOfToCompletions(oneOfDef, definitions, key, description, indentation, mode); + properties.push(...expanded); + continue; // Skip the default push below + } case DefinitionType.String: case DefinitionType.Boolean: @@ -143,3 +143,101 @@ function distinctValues(values: Value[]): Value[] { } return Array.from(map.values()); } + +/** + * Bucket type for one-of expansion + */ +type StructuralBucket = "scalar" | "sequence" | "mapping"; + +/** + * Get the structural bucket for a definition type. + * Nested one-of is treated as scalar. + */ +function getStructuralBucket(defType: DefinitionType): StructuralBucket { + switch (defType) { + case DefinitionType.Sequence: + return "sequence"; + case DefinitionType.Mapping: + return "mapping"; + default: + // String, Boolean, Number, Null, OneOf (nested), AllowedValues -> scalar + // Note, nested OneOf is assumed to be all scalar values, which is true in practice. + return "scalar"; + } +} + +/** + * Expand a one-of definition into multiple completion items based on structural types. + * Returns one completion per unique structural type (scalar, sequence, mapping). + */ +function expandOneOfToCompletions( + oneOfDef: OneOfDefinition, + definitions: {[key: string]: Definition}, + key: string, + description: string | undefined, + indentation: string, + mode: DefinitionValueMode +): Value[] { + // Bucket variants by structural type + const buckets: Record = { + scalar: false, + sequence: false, + mapping: false + }; + + for (const variantKey of oneOfDef.oneOf) { + const variantDef = definitions[variantKey]; + if (variantDef) { + const bucket = getStructuralBucket(variantDef.definitionType); + buckets[bucket] = true; + } + } + + const results: Value[] = []; + + // Count how many structural types are present + const bucketCount = [buckets.scalar, buckets.sequence, buckets.mapping].filter(Boolean).length; + const needsQualifier = bucketCount > 1; + + // Emit completions in order: scalar, sequence, mapping + // Use sortText to preserve this order (scalar sorts first, then 1=sequence, 2=mapping) + if (buckets.scalar) { + // In Key mode, insert newline and indentation to produce valid YAML structure + const insertText = mode === DefinitionValueMode.Key ? `\n${indentation}${key}: ` : `${key}: `; + results.push({ + label: key, + description, + insertText + }); + } + + if (buckets.sequence) { + const insertText = + mode === DefinitionValueMode.Key + ? `\n${indentation}${key}:\n${indentation}${indentation}- ` + : `${key}:\n${indentation}- `; + results.push({ + label: needsQualifier ? `${key} (list)` : key, + description, + insertText, + filterText: needsQualifier ? key : undefined, + sortText: needsQualifier ? `${key} 1` : undefined + }); + } + + if (buckets.mapping) { + const insertText = + mode === DefinitionValueMode.Key + ? `\n${indentation}${key}:\n${indentation}${indentation}` + : `${key}:\n${indentation}`; + results.push({ + label: needsQualifier ? `${key} (full syntax)` : key, + description, + insertText, + filterText: needsQualifier ? key : undefined, + sortText: needsQualifier ? `${key} 2` : undefined + }); + } + + return results; +}