diff --git a/src/editor/lsp/semantic/kotlin/index.test.ts b/src/editor/lsp/semantic/kotlin/index.test.ts index 5eab0bb2d..844aea598 100644 --- a/src/editor/lsp/semantic/kotlin/index.test.ts +++ b/src/editor/lsp/semantic/kotlin/index.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { Text } from "@codemirror/state"; +import { Tree } from "@lezer/common"; import { semanticRanges } from "../index"; describe("Kotlin semantic tokens", () => { @@ -42,4 +43,42 @@ describe("Kotlin semantic tokens", () => { )[0]?.className, ).toBe("cm-sem-property"); }); + + it("colours annotation names as annotations, not as the constructor calls the server reports", () => { + const lines = [ + '@Column(name = "x")', + "@field:Inject @Outer.Inner", + "list.forEach { return@forEach }", + "this@Outer.call()", + ]; + const doc = Text.of(lines); + const types = ["method", "class", "label"]; + const tok = (line: number, word: string, type: string) => [ + line, + lines[line]!.lastIndexOf(word), + word.length, + types.indexOf(type), + 0, + ]; + const data = [ + tok(0, "Column", "method"), + tok(1, "Inject", "class"), + tok(1, "Outer", "class"), + tok(1, "Inner", "class"), + tok(2, "forEach", "label"), + tok(3, "Outer", "class"), + ].flat(); + const tokens = { tokenTypes: types, tokenModifiers: [], data }; + const ranges = semanticRanges(doc, tokens, false, "kotlin", Tree.empty); + const classOf = (line: number, word: string) => { + const from = doc.line(line + 1).from + lines[line]!.lastIndexOf(word); + return ranges.find((r) => r.from === from)?.className; + }; + expect(classOf(0, "Column")).toBe("cm-sem-annotation"); + expect(classOf(1, "Inject")).toBe("cm-sem-annotation"); + expect(classOf(1, "Outer")).toBe("cm-sem-annotation"); + expect(classOf(1, "Inner")).toBe("cm-sem-annotation"); + expect(classOf(2, "forEach")).toBe("cm-sem-constant"); + expect(classOf(3, "Outer")).not.toBe("cm-sem-annotation"); + }); }); diff --git a/src/editor/lsp/semantic/kotlin/index.ts b/src/editor/lsp/semantic/kotlin/index.ts index 2a0c1a842..9b5aa13b4 100644 --- a/src/editor/lsp/semantic/kotlin/index.ts +++ b/src/editor/lsp/semantic/kotlin/index.ts @@ -1,7 +1,49 @@ +import type { Text } from "@codemirror/state"; import { registerLanguageSemantics, type LanguageSemantics } from "../languages"; +const NAME = /[A-Za-z_][\w]*/y; + +/** + * The names of annotation usages (`@Column`, `@field:Column`, `@Outer.Inner`), as `"from:to"` → `"annotation"`. + * An `@` right after a name is a label (`return@forEach`, `this@Outer`, `loop@`), not an annotation. + */ +function annotationNames(doc: Text): Map { + const out = new Map(); + const text = doc.toString(); + for (let at = text.indexOf("@"); at >= 0; at = text.indexOf("@", at + 1)) { + if (/[\w`]/.test(text[at - 1] ?? "")) continue; + let pos = at + 1; + const name = () => { + NAME.lastIndex = pos; + const m = NAME.exec(text); + return m ? { from: pos, to: pos + m[0].length } : null; + }; + let first = name(); + // A use-site target (`@field:`, `@get:`, `@file:`) precedes the annotation's name. + if (first && text[first.to] === ":" && text[first.to + 1] !== ":") { + pos = first.to + 1; + first = name(); + } + for (let part = first; part;) { + out.set(`${part.from}:${part.to}`, "annotation"); + if (text[part.to] !== ".") break; + pos = part.to + 1; + part = name(); + } + } + return out; +} + const kotlinSemantics: LanguageSemantics = { - tokenClasses({ type, modifiers, classes }) { + symbolKinds: (_tree, doc) => annotationNames(doc), + tokenClasses({ type, modifiers, kind, classes }) { + // JetBrains Kotlin LSP reports an annotation usage as a constructor call (`method`); + // IDEA colours the name as an annotation (Metadata), like its `@`. + if (kind === "annotation" && type !== "label") { + classes.length = 0; + classes.push("cm-sem-annotation"); + return; + } if (type === "operator" && modifiers.includes("declaration")) classes.push("cm-sem-function"); // JetBrains Kotlin LSP uses `modification` for mutable declarations and references. if ((type === "variable" || type === "property" || type === "parameter") && modifiers.includes("modification"))