From e3b56c24160a1f43b1d8b7b27cd4eb03fd457e6f Mon Sep 17 00:00:00 2001 From: eric sciple Date: Fri, 2 Jan 2026 12:18:53 -0600 Subject: [PATCH] Use labelDetails for completion item qualifiers (#278) * Use labelDetails for completion item qualifiers Use labelDetails.description instead of detail for qualifier text like 'full syntax' and 'list'. This renders the text inline after the label in the completion menu, making variants immediately distinguishable without hovering. * Fix formatting --- languageservice/src/complete.test.ts | 36 ++++++++++--------- languageservice/src/complete.ts | 2 +- languageservice/src/e2e.test.ts | 4 ++- languageservice/src/value-providers/config.ts | 4 +-- .../src/value-providers/definition.ts | 12 +++---- 5 files changed, 32 insertions(+), 26 deletions(-) diff --git a/languageservice/src/complete.test.ts b/languageservice/src/complete.test.ts index 9ffa945..0798cde 100644 --- a/languageservice/src/complete.test.ts +++ b/languageservice/src/complete.test.ts @@ -465,7 +465,7 @@ jobs: ]); // One-of (scalar variant) - const concurrencyScalar = result.find(x => x.label === "concurrency" && x.detail === undefined); + const concurrencyScalar = result.find(x => x.label === "concurrency" && x.labelDetails === undefined); expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: "); }); @@ -489,7 +489,7 @@ jobs: ]); // One-of (scalar variant) - const concurrencyScalar = result.find(x => x.label === "concurrency" && x.detail === undefined); + const concurrencyScalar = result.find(x => x.label === "concurrency" && x.labelDetails === undefined); expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: "); }); }); @@ -530,7 +530,7 @@ jobs: const result = await complete(...getPositionFromCursor(input)); // Scalar variant inserts "types: " - const scalarVariant = result.find(x => x.label === "types" && x.detail === undefined); + const scalarVariant = result.find(x => x.label === "types" && x.labelDetails === undefined); expect(scalarVariant?.textEdit?.newText).toEqual("types: "); }); @@ -586,8 +586,8 @@ jobs: // Should have both check_run (scalar) and check_run with detail "full syntax" const checkRunVariants = result.filter(x => x.label === "check_run"); - expect(checkRunVariants.some(x => x.detail === undefined)).toBe(true); - expect(checkRunVariants.some(x => x.detail === "full syntax")).toBe(true); + expect(checkRunVariants.some(x => x.labelDetails === undefined)).toBe(true); + expect(checkRunVariants.some(x => x.labelDetails?.description === "full syntax")).toBe(true); }); it("shows all three variants for scalar+sequence+mapping one-of", async () => { @@ -602,9 +602,9 @@ jobs: // Should have runs-on (scalar), runs-on with detail "list", and runs-on with detail "full syntax" const runsOnVariants = result.filter(x => x.label === "runs-on"); expect(runsOnVariants.length).toBe(3); - expect(runsOnVariants.some(x => x.detail === undefined)).toBe(true); - expect(runsOnVariants.some(x => x.detail === "list")).toBe(true); - expect(runsOnVariants.some(x => x.detail === "full syntax")).toBe(true); + expect(runsOnVariants.some(x => x.labelDetails === undefined)).toBe(true); + expect(runsOnVariants.some(x => x.labelDetails?.description === "list")).toBe(true); + expect(runsOnVariants.some(x => x.labelDetails?.description === "full syntax")).toBe(true); }); it("generates correct insertText for one-of variants in parent mode", async () => { @@ -619,13 +619,17 @@ jobs: const runsOnVariants = result.filter(x => x.label === "runs-on"); // Scalar: just key with colon and space - expect(runsOnVariants.find(x => x.detail === undefined)?.textEdit?.newText).toEqual("runs-on: "); + expect(runsOnVariants.find(x => x.labelDetails === undefined)?.textEdit?.newText).toEqual("runs-on: "); // Sequence: key with colon, newline, and list item - expect(runsOnVariants.find(x => x.detail === "list")?.textEdit?.newText).toEqual("runs-on:\n - "); + expect(runsOnVariants.find(x => x.labelDetails?.description === "list")?.textEdit?.newText).toEqual( + "runs-on:\n - " + ); // Mapping: key with colon, newline, and indentation for nested keys - expect(runsOnVariants.find(x => x.detail === "full syntax")?.textEdit?.newText).toEqual("runs-on:\n "); + expect(runsOnVariants.find(x => x.labelDetails?.description === "full syntax")?.textEdit?.newText).toEqual( + "runs-on:\n " + ); }); it("generates correct insertText for one-of variants in parent mode", async () => { @@ -654,11 +658,11 @@ jobs: const runsOnVariants = result.filter(x => x.label === "runs-on"); // Scalar: no sortText needed (sorts naturally first) - expect(runsOnVariants.find(x => x.detail === undefined)?.sortText).toBeUndefined(); + expect(runsOnVariants.find(x => x.labelDetails === undefined)?.sortText).toBeUndefined(); // Sequence and mapping: sortText controls ordering - expect(runsOnVariants.find(x => x.detail === "list")?.sortText).toEqual("runs-on 1"); - expect(runsOnVariants.find(x => x.detail === "full syntax")?.sortText).toEqual("runs-on 2"); + expect(runsOnVariants.find(x => x.labelDetails?.description === "list")?.sortText).toEqual("runs-on 1"); + expect(runsOnVariants.find(x => x.labelDetails?.description === "full syntax")?.sortText).toEqual("runs-on 2"); }); it("scalar event completion inserts inline without newline", async () => { @@ -672,13 +676,13 @@ jobs: const push = result.find(x => x.label === "push"); expect(push?.textEdit?.newText).toEqual("push"); - const checkRun = result.find(x => x.label === "check_run" && x.detail === undefined); + const checkRun = result.find(x => x.label === "check_run" && x.labelDetails === undefined); expect(checkRun?.textEdit?.newText).toEqual("check_run"); // Full syntax form should NOT be shown in Key mode - it requires a newline // which is confusing when typing inline. Users who want the mapping form // can use `on (full syntax)` at the parent level. - expect(result.find(x => x.label === "check_run" && x.detail === "full syntax")).toBeUndefined(); + expect(result.find(x => x.label === "check_run" && x.labelDetails?.description === "full syntax")).toBeUndefined(); }); it("filters to sequence options when user has started a sequence", async () => { diff --git a/languageservice/src/complete.ts b/languageservice/src/complete.ts index 98ae1be..65e37d0 100644 --- a/languageservice/src/complete.ts +++ b/languageservice/src/complete.ts @@ -194,7 +194,7 @@ export async function complete( const item: CompletionItem = { label: value.label, - detail: value.detail, + labelDetails: value.labelDetail ? {description: value.labelDetail} : undefined, filterText: value.filterText, sortText: value.sortText, documentation: value.description && { diff --git a/languageservice/src/e2e.test.ts b/languageservice/src/e2e.test.ts index b03b572..9b36fcb 100644 --- a/languageservice/src/e2e.test.ts +++ b/languageservice/src/e2e.test.ts @@ -22,7 +22,9 @@ describe("end-to-end", () => { expect(result).not.toBeUndefined(); expect(result.length).toEqual(13); - const labelsWithDetails = result.map(x => (x.detail ? `${x.label} (${x.detail})` : x.label)); + const labelsWithDetails = result.map(x => + x.labelDetails?.description ? `${x.label} (${x.labelDetails.description})` : x.label + ); expect(labelsWithDetails).toEqual([ "concurrency", "concurrency (full syntax)", diff --git a/languageservice/src/value-providers/config.ts b/languageservice/src/value-providers/config.ts index 63cd694..11d4872 100644 --- a/languageservice/src/value-providers/config.ts +++ b/languageservice/src/value-providers/config.ts @@ -7,8 +7,8 @@ export interface Value { /** Optional description to show when auto-completing */ description?: string; - /** Optional detail shown after the label, e.g. type or kind information */ - detail?: string; + /** Optional qualifier shown inline after the label, e.g. "full syntax" or "list" */ + labelDetail?: string; /** Whether this value is deprecated */ deprecated?: boolean; diff --git a/languageservice/src/value-providers/definition.ts b/languageservice/src/value-providers/definition.ts index f0ff41e..3939dd1 100644 --- a/languageservice/src/value-providers/definition.ts +++ b/languageservice/src/value-providers/definition.ts @@ -216,14 +216,14 @@ function oneOfValues( } /** - * Deduplicates values by label and detail. - * Values with the same label but different details are preserved as distinct items. + * Deduplicates values by label and labelDetail. + * Values with the same label but different labelDetails are preserved as distinct items. */ function distinctValues(values: Value[]): Value[] { const map = new Map(); for (const value of values) { - // Include detail in the key to preserve variants with different details - const key = value.detail ? `${value.label}\0${value.detail}` : value.label; + // Include labelDetail in the key to preserve variants with different details + const key = value.labelDetail ? `${value.label}\0${value.labelDetail}` : value.label; map.set(key, value); } return Array.from(map.values()); @@ -326,7 +326,7 @@ function expandOneOfToCompletions( results.push({ label: key, description, - detail: needsQualifier ? "list" : undefined, + labelDetail: needsQualifier ? "list" : undefined, insertText, sortText: needsQualifier ? `${key} 1` : undefined }); @@ -340,7 +340,7 @@ function expandOneOfToCompletions( results.push({ label: key, description, - detail: needsQualifier ? "full syntax" : undefined, + labelDetail: needsQualifier ? "full syntax" : undefined, insertText, sortText: needsQualifier ? `${key} 2` : undefined });