Compare commits

...
Author SHA1 Message Date
l0ng-ai f4f4ff3a44 fix(kotlin): colour annotation names as annotations
The JetBrains Kotlin LSP reports an annotation usage as a constructor call
(`method`), so the name after `@` took the Function call colour while the
`@` had Metadata. Names of annotation usages, including use-site targets
(`@field:Name`) and nested names (`@Outer.Inner`), now get the annotation
class; labels such as `return@forEach` and `this@Outer` are left alone.

Fixes #2543
2026-11-10 23:27:01 +08:00
2 changed files with 82 additions and 1 deletions

No files matched your search

@@ -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 }",
"[email protected]()",
];
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");
});
});
+43 -1
View File
@@ -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<string, string> {
const out = new Map<string, string>();
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"))