diff --git a/src/editor/lsp/completion.test.ts b/src/editor/lsp/completion.test.ts index c56459f30..369280b0f 100644 --- a/src/editor/lsp/completion.test.ts +++ b/src/editor/lsp/completion.test.ts @@ -4,7 +4,14 @@ import { EditorView } from "@codemirror/view"; import type { CompletionItem } from "@/bindings"; import type { EditorDocContext } from "@/editor/api"; import { lspDoc } from "./lsp"; -import { completionSymbolKind, identifierEnd, toCmCompletion, triggerCharacter, typeColumnText } from "./completionItems"; +import { + completionSymbolKind, + identifierEnd, + toCmCompletion, + triggerCharacter, + typeColumnText, + withoutDuplicates, +} from "./completionItems"; import { applyLspCompletion, callParens, @@ -75,6 +82,13 @@ describe("toCmCompletion", () => { expect(toCmCompletion(item({ label: "foo", filterText: "foo_bar" })).label).toBe("foo_bar"); }); + it("lists identical items once", () => { + const ctor = item({ label: "String()", kind: "constructor", data: "1" }); + const overload = item({ label: "String()", kind: "constructor", labelDetail: "(bytes: ByteArray)" }); + const out = withoutDuplicates([ctor, { ...ctor, data: "2" }, overload, { ...ctor, server: 2 }]); + expect(out).toEqual([ctor, overload, { ...ctor, server: 2 }]); + }); + it("boosts preselected and demotes deprecated items", () => { expect(toCmCompletion(item({ preselect: true })).boost).toBe(99); expect(toCmCompletion(item({ deprecated: true })).boost).toBeLessThan(0); diff --git a/src/editor/lsp/completion.ts b/src/editor/lsp/completion.ts index bb12f8235..66d8c5740 100644 --- a/src/editor/lsp/completion.ts +++ b/src/editor/lsp/completion.ts @@ -35,7 +35,14 @@ import { type RequestId, } from "@/platform/ipc"; import { useProject } from "@/platform/project"; -import { completionSymbolKind, identifierEnd, toCmCompletion, triggerCharacter, typeColumnText } from "./completionItems"; +import { + completionSymbolKind, + identifierEnd, + toCmCompletion, + triggerCharacter, + typeColumnText, + withoutDuplicates, +} from "./completionItems"; import { lspSnippetToCm, snippetVariables } from "./snippet"; import { docOf, isUnavailable, lspDoc, lspRequest, reportLspError } from "./lsp"; import { offsetToPos, posToOffset, textEditsToChanges } from "./positions"; @@ -437,9 +444,11 @@ async function lspCompletionSource(ctx: CompletionContext): Promise matchesCase(it.filterText ?? it.label, typed, prefs.caseSensitivity)) - : list.items; + let items = withoutDuplicates( + prefs.matchCase + ? list.items.filter((it) => matchesCase(it.filterText ?? it.label, typed, prefs.caseSensitivity)) + : list.items, + ); // A server snippet named like a live template (jdtls `sout`, `fori`) would be listed twice; the template wins. if (items.some((it) => it.kind === "snippet")) { const own = await contributedSource(ctx); diff --git a/src/editor/lsp/completionItems.ts b/src/editor/lsp/completionItems.ts index f4fc42ed2..6aadb3ab6 100644 --- a/src/editor/lsp/completionItems.ts +++ b/src/editor/lsp/completionItems.ts @@ -65,6 +65,22 @@ function extendsLabel(item: CompletionItem): boolean { return item.filterText!.startsWith(item.label) && /^[^\p{L}\p{N}_$]/u.test(rest); } +/** + * Items that look and insert the same, listed once. kotlin-language-server + * repeats a class once per classpath entry declaring it (`String()` ×3); + * these all have their own `apply` (see `makeCompletion`), so CodeMirror + * wouldn't merge them. + */ +export function withoutDuplicates(items: readonly CompletionItem[]): CompletionItem[] { + const seen = new Set(); + return items.filter((it) => { + const key = JSON.stringify([it.server, it.label, it.labelDetail, it.detail, it.kind, it.insertText, it.additionalEdits]); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + const IMPORT_SOURCE = [ /^\s*from\s+([\w.]+)\s+import\b/m, // Python /^\s*import\s+(?:static\s+)?([\w.]+?)\.\w+;?\s*$/m, // Java, Kotlin