From fd82beb309e0329a0fd780d0e2e8ce6f2fc0711c Mon Sep 17 00:00:00 2001
From: lleviathe <95321986+lleviathe@users.noreply.github.com>
Date: Mon, 9 Nov 2026 02:30:29 +0400
Subject: [PATCH] feat(editor): highlight tags inside doc comments (#2200)
Tags inside doc comments looked like the rest of the comment text. They now get their own colours in Java, Kotlin, JavaScript, TypeScript, JSX, TSX and PHP: block and inline tags, tag values such as a `@param` name, KDoc links, and HTML markup from a fixed element list. Plain comments, strings and an `@` inside text get nothing. Colours come from new theme tokens, with entries under Settings > Editor > Color Scheme, in every built-in theme, and through .icls and IntelliJ theme import.
Only comment nodes in the visible ranges are scanned. The Kotlin tokenizer records doc comment state in its parser state, so long comments stay fast and correct.
Co-authored-by: lleviathe <95321986+lleviathe@users.noreply.github.com>
---
docs/plugins/themes.md | 8 +-
.../themes/harbor-night.scheme.json | 5 +
scripts/theme/theme.test.ts | 15 +
src/editor/core/cm/docCommentTags.test.ts | 302 ++++++++++++++++++
src/editor/core/cm/docCommentTags.ts | 188 +++++++++++
src/editor/core/cm/highlight.ts | 7 +
src/editor/core/cm/interpolation.ts | 23 +-
src/editor/core/cm/languages.ts | 2 +
src/features/plugins/intellijTheme.ts | 6 +
.../settings/__tests__/colorScheme.test.tsx | 36 +++
.../settings/pages/editor/colorScheme.tsx | 9 +-
src/platform/colorScheme.test.ts | 6 +
src/platform/colorScheme.ts | 6 +-
src/platform/icls.test.ts | 37 +++
src/platform/icls.ts | 13 +-
src/platform/locales/text/en.json | 2 +
src/platform/locales/text/zh.json | 1 +
src/styles/tokens.css | 20 ++
18 files changed, 678 insertions(+), 8 deletions(-)
create mode 100644 src/editor/core/cm/docCommentTags.test.ts
create mode 100644 src/editor/core/cm/docCommentTags.ts
diff --git a/docs/plugins/themes.md b/docs/plugins/themes.md
index e6813bb2c..dcdf17523 100644
--- a/docs/plugins/themes.md
+++ b/docs/plugins/themes.md
@@ -221,6 +221,7 @@ The full table is `UI_KEYS` in [`src/features/plugins/intellijTheme.ts`](../../s
| `--vcs-*`, `--gutter-*` | colour `FILESTATUS_*`, `MODIFIED_LINES_COLOR`, `ADDED_LINES_COLOR`, `DELETED_LINES_COLOR` |
| `--syn-keyword`, `--syn-string`, `--syn-number` | `DEFAULT_KEYWORD`, `DEFAULT_STRING`, `DEFAULT_NUMBER` |
| `--syn-comment` (+ `--syn-comment-style`), `--syn-doc-comment` | `DEFAULT_LINE_COMMENT` (+ `FONT_TYPE`), `DEFAULT_DOC_COMMENT` |
+| `--syn-doc-comment-tag`, `--syn-doc-comment-tag-value`, `--syn-doc-markup` (+ `-bg`) | `DEFAULT_DOC_COMMENT_TAG`, `DEFAULT_DOC_COMMENT_TAG_VALUE`, `DEFAULT_DOC_MARKUP` |
| `--syn-function`, `--syn-function-call` | `DEFAULT_FUNCTION_DECLARATION`, `DEFAULT_FUNCTION_CALL` |
| `--syn-type`, `--syn-constant`, `--syn-property` | `DEFAULT_CLASS_NAME`, `DEFAULT_CONSTANT`, `DEFAULT_INSTANCE_FIELD` |
| `--syn-variable`, `--syn-param`, `--syn-builtin` | `DEFAULT_LOCAL_VARIABLE`, `DEFAULT_PARAMETER`, `DEFAULT_PREDEFINED_SYMBOL` |
@@ -328,7 +329,7 @@ a token.
-247 themeable tokens: 125 in the UI theme file, 122 in the editor colour scheme file.
+252 themeable tokens: 125 in the UI theme file, 127 in the editor colour scheme file.
### Surfaces (UI theme)
@@ -428,6 +429,11 @@ a token.
| `--syn-comment` | `#7a7e85` | `#8c8c8c` |
| `--syn-comment-style` | `normal` | `italic` |
| `--syn-doc-comment` | `#5f826b` | `#8c8c8c` |
+| `--syn-doc-comment-tag` | `#67a37c` | `#8c8c8c` |
+| `--syn-doc-comment-tag-effect` | `none` | `underline #999999` |
+| `--syn-doc-comment-tag-value` | `#abadb3` | `#3d3d3d` |
+| `--syn-doc-markup` | `#68a67e` | `#8c8c8c` |
+| `--syn-doc-markup-bg` | `transparent` | `#e2ffe2` |
| `--syn-docstring` | `#5f826b` | `#067d17` |
| `--syn-annotation` | `#b3ae60` | `#9e880d` |
| `--syn-decorator` | `#b3ae60` | `#9e880d` |
diff --git a/plugins/official/harbor-night/themes/harbor-night.scheme.json b/plugins/official/harbor-night/themes/harbor-night.scheme.json
index 7f339c640..75363ac90 100644
--- a/plugins/official/harbor-night/themes/harbor-night.scheme.json
+++ b/plugins/official/harbor-night/themes/harbor-night.scheme.json
@@ -12,6 +12,11 @@
"--syn-comment": "#6b7a8f",
"--syn-comment-style": "normal",
"--syn-doc-comment": "#74889c",
+ "--syn-doc-comment-tag": "#93abc4",
+ "--syn-doc-comment-tag-effect": "none",
+ "--syn-doc-comment-tag-value": "#b4c0ce",
+ "--syn-doc-markup": "#8aa3b8",
+ "--syn-doc-markup-bg": "transparent",
"--syn-docstring": "#8fb071",
"--syn-annotation": "#d7a0e0",
"--syn-decorator": "#d7a0e0",
diff --git a/scripts/theme/theme.test.ts b/scripts/theme/theme.test.ts
index b70c6d80b..d40856e92 100644
--- a/scripts/theme/theme.test.ts
+++ b/scripts/theme/theme.test.ts
@@ -184,6 +184,21 @@ describe("IntelliJ theme import", () => {
expect(s.tokens["--syn-constant"]).toBeUndefined();
});
+ it("maps doc comment tags, tag values and markup", () => {
+ const fg = (c: string) => ``;
+ const s = convertIcls(`
+
+
+
+ `);
+ expect(s.tokens).toMatchObject({
+ "--syn-doc-comment-tag": "#67a37c",
+ "--syn-doc-comment-tag-value": "#abadb3",
+ "--syn-doc-markup": "#68a67e",
+ "--syn-doc-markup-bg": "#e2ffe2",
+ });
+ });
+
it("produces an installable package", () => {
const light = stripJsonComments(themeJson.replace('"dark": true,', ""));
const c = convertIntellijTheme(light, icls.replace("Darcula", "Default"), "sea");
diff --git a/src/editor/core/cm/docCommentTags.test.ts b/src/editor/core/cm/docCommentTags.test.ts
new file mode 100644
index 000000000..5ec5be7fd
--- /dev/null
+++ b/src/editor/core/cm/docCommentTags.test.ts
@@ -0,0 +1,302 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { Compartment, EditorState, Prec } from "@codemirror/state";
+import { Decoration, EditorView } from "@codemirror/view";
+import { codeFolding, foldEffect, HighlightStyle, highlightingFor, syntaxHighlighting } from "@codemirror/language";
+import { completedSyntaxState } from "@/test/syntax";
+import { docCommentMarkup, docCommentTag, docCommentTagValue, openideaHighlightStyle } from "./highlight";
+import { findLanguageDescription, loadSupport } from "./languages";
+import { docCommentTagRanges, docCommentTags } from "./docCommentTags";
+
+const views: EditorView[] = [];
+afterEach(() => views.splice(0).forEach((v) => v.destroy()));
+
+async function state(language: string, path: string, doc: string) {
+ const support = await loadSupport(findLanguageDescription(language, path)!);
+ return completedSyntaxState(EditorState.create({ doc, extensions: [support!] }));
+}
+
+function ranges(s: EditorState, language: string, from = 0) {
+ return docCommentTagRanges(s, language, from, s.doc.length).map((r) => [s.sliceDoc(r.from, r.to), r.kind]);
+}
+
+describe("doc comment tags", () => {
+ it("marks JavaDoc block and inline tags, @param values and HTML markup", async () => {
+ const s = await state(
+ "Java",
+ "/p/A.java",
+ [
+ "/**",
+ " * Sums {@link Math#addExact} values, mail dev@example.com.",
+ " *
Second @param paragraph, List stays text.
",
+ " * @param the type",
+ " * @param left the left",
+ " * @return the sum",
+ " * @throws ArithmeticException on overflow",
+ " */",
+ "/* @param plain */",
+ "// @param line",
+ 'class A { String s = "/** @param x */"; /**/ }',
+ "/** @deprecated use {@link B} */",
+ ].join("\n"),
+ );
+ expect(ranges(s, "Java")).toEqual([
+ ["@link", "tag"],
+ ["", "markup"],
+ ["
", "markup"],
+ ["@param", "tag"],
+ ["", "value"],
+ ["@param", "tag"],
+ ["left", "value"],
+ ["@return", "tag"],
+ ["@throws", "tag"],
+ ["@deprecated", "tag"],
+ ["@link", "tag"],
+ ]);
+ });
+
+ it("marks KDoc tags, their subjects and [links], also when the comment starts above the range", async () => {
+ const doc = [
+ "/**",
+ " * Adds [a] to b, see [Math.max] or [the docs](https://example.com).",
+ " * @param a the a",
+ " * @throws IllegalStateException never",
+ " * @return the sum, {@link Foo} is not KDoc",
+ " */",
+ "fun f(a: Int) = a // @param x",
+ "/* @param plain */",
+ ].join("\n");
+ const s = await state("Kotlin", "/p/a.kt", doc);
+ expect(ranges(s, "Kotlin")).toEqual([
+ ["[a]", "value"],
+ ["[Math.max]", "value"],
+ ["@param", "tag"],
+ ["a", "value"],
+ ["@throws", "tag"],
+ ["IllegalStateException", "value"],
+ ["@return", "tag"],
+ ]);
+ // The stream parser retains the doc-comment flag when the opener is above the range.
+ expect(ranges(s, "Kotlin", doc.indexOf(" * @throws"))).toEqual([
+ ["@throws", "tag"],
+ ["IllegalStateException", "value"],
+ ["@return", "tag"],
+ ]);
+ });
+
+ it("marks JSDoc tags in JavaScript, TypeScript, JSX and TSX", async () => {
+ const doc = [
+ "/**",
+ " * Formats {@link Date} values for user@example.com.",
+ " * @param {string} name the name",
+ " * @returns {string}",
+ " */",
+ "/* @param plain */",
+ "const s = '/** @param x */';",
+ ].join("\n");
+ for (const [language, path] of [
+ ["JavaScript", "/p/a.js"],
+ ["TypeScript", "/p/a.ts"],
+ ["JSX", "/p/a.jsx"],
+ ["TSX", "/p/a.tsx"],
+ ] as const) {
+ const s = await state(language, path, doc);
+ expect(ranges(s, language), language).toEqual([
+ ["@link", "tag"],
+ ["@param", "tag"],
+ ["@returns", "tag"],
+ ]);
+ }
+ });
+
+ it("marks PHPDoc tags and the variable of @param and @var", async () => {
+ const s = await state(
+ "PHP",
+ "/p/a.php",
+ [
+ " ...$rest",
+ " * @var Foo&Bar $x",
+ " * @return ?User",
+ " */",
+ "function find($id) {} # @param $y",
+ "/* @param int $z */",
+ ].join("\n"),
+ );
+ expect(ranges(s, "PHP")).toEqual([
+ ["@inheritDoc", "tag"],
+ ["@param", "tag"],
+ ["$id", "value"],
+ ["@param", "tag"],
+ ["$rest", "value"],
+ ["@var", "tag"],
+ ["$x", "value"],
+ ["@return", "tag"],
+ ]);
+ });
+
+ it.each(["Java", "Kotlin", "TypeScript", "PHP"])("rejects non-doc lookalikes in %s", async (language) => {
+ const path = { Java: "A.java", Kotlin: "a.kt", TypeScript: "a.ts", PHP: "a.php" }[language]!;
+ const s = await state(
+ language,
+ path,
+ [
+ language === "PHP" ? " {
+ const doc = ["/**", ...Array.from({ length: 19997 }, () => " * @param item description"), " */", ""].join("\n");
+ const s = await state(language, language === "Java" ? "A.java" : "a.kt", doc);
+ expect(
+ docCommentTagRanges(s, language, s.doc.line(19000).from, s.doc.line(19001).to).filter((r) => r.kind === "tag"),
+ ).toHaveLength(2);
+ });
+
+ it("keeps Kotlin nested comments in their outer comment's dialect", async () => {
+ const doc = "/* plain\n/** @param hidden */\n * @return hidden\n */\n/** docs\n/* nested */\n * @return shown\n */";
+ const s = await state("Kotlin", "a.kt", doc);
+ expect(ranges(s, "Kotlin")).toEqual([["@return", "tag"]]);
+ });
+
+ it("rebuilds decorations when the highlighter is reconfigured", async () => {
+ const support = await loadSupport(findLanguageDescription("Java", "A.java")!);
+ const theme = new Compartment();
+ const view = new EditorView({
+ parent: document.body,
+ state: completedSyntaxState(
+ EditorState.create({
+ doc: "/** @return result */",
+ extensions: [support!, theme.of(syntaxHighlighting(openideaHighlightStyle))],
+ }),
+ ),
+ });
+ views.push(view);
+ const replacement = HighlightStyle.define([{ tag: docCommentTag, color: "red" }]);
+ view.dispatch({ effects: theme.reconfigure(syntaxHighlighting(replacement)) });
+ const cls = highlightingFor(view.state, [docCommentTag])!;
+ expect(view.contentDOM.querySelector(`.${cls}`)?.textContent).toBe("@return");
+ view.dispatch({ changes: { from: 2, to: 3, insert: "" } });
+ expect(view.contentDOM.querySelector(`.${cls}`)).toBeNull();
+ });
+
+ it("removes decorations when switching to an unsupported language", async () => {
+ const language = new Compartment();
+ const support = await loadSupport(findLanguageDescription("Java", "A.java")!);
+ const view = new EditorView({
+ parent: document.body,
+ state: completedSyntaxState(
+ EditorState.create({
+ doc: "/** @return result */",
+ extensions: [language.of(support!), syntaxHighlighting(openideaHighlightStyle)],
+ }),
+ ),
+ });
+ views.push(view);
+ const cls = highlightingFor(view.state, [docCommentTag])!;
+ expect(view.contentDOM.querySelector(`.${cls}`)?.textContent).toBe("@return");
+ view.dispatch({ effects: language.reconfigure([]) });
+ expect(view.contentDOM.querySelector(`.${cls}`)).toBeNull();
+ });
+
+ it("keeps tag marks inside backend syntax and semantic comment marks", async () => {
+ const support = await loadSupport(findLanguageDescription("Java", "A.java")!);
+ const doc = "/** @return result */";
+ const view = new EditorView({
+ parent: document.body,
+ state: completedSyntaxState(
+ EditorState.create({
+ doc,
+ extensions: [
+ support!,
+ syntaxHighlighting(openideaHighlightStyle),
+ Prec.high(
+ EditorView.decorations.of(
+ Decoration.set(Decoration.mark({ class: "backend-comment" }).range(0, doc.length)),
+ ),
+ ),
+ Prec.highest(
+ EditorView.decorations.of(
+ Decoration.set(Decoration.mark({ class: "cm-sem-comment" }).range(0, doc.length)),
+ ),
+ ),
+ ],
+ }),
+ ),
+ });
+ views.push(view);
+ const cls = highlightingFor(view.state, [docCommentTag])!;
+ const tag = view.contentDOM.querySelector(`.${cls}`)!;
+ expect(tag.textContent).toBe("@return");
+ expect(tag.closest(".backend-comment")).not.toBeNull();
+ expect(tag.closest(".cm-sem-comment")).not.toBeNull();
+ expect(tag.children.length).toBe(0);
+ });
+
+ it("gives other languages nothing", async () => {
+ const s = await state("Rust", "/p/a.rs", "/** @param x */\nfn f() {}");
+ expect(ranges(s, "Rust")).toEqual([]);
+ for (const language of ["Rust", "PluginLanguage", "constructor", "toString"]) {
+ expect(ranges(s, language)).toEqual([]);
+ expect(docCommentTags(language)).toEqual([]);
+ }
+ });
+
+ it("styles the tags with the highlight style's classes, inside the comment's own", async () => {
+ const support = await loadSupport(findLanguageDescription("Java", "/p/A.java")!);
+ const doc = "/**\n * See this.\n * @param id the id\n */\nclass A {}";
+ const highlight = syntaxHighlighting(openideaHighlightStyle);
+ // The language support brings the tags; whichever comes first, they nest inside the comment's marks.
+ for (const extensions of [
+ [support!, highlight],
+ [highlight, support!],
+ ]) {
+ const view = new EditorView({
+ parent: document.body,
+ state: completedSyntaxState(EditorState.create({ doc, extensions })),
+ });
+ views.push(view);
+ for (const [text, tag] of [
+ ["@param", docCommentTag],
+ ["id", docCommentTagValue],
+ ["", docCommentMarkup],
+ ] as const) {
+ const cls = highlightingFor(view.state, [tag])!;
+ const span = view.contentDOM.querySelector(`.${cls}`);
+ expect(span?.textContent).toBe(text);
+ expect(span?.children.length).toBe(0);
+ }
+ }
+ });
+
+ it("keeps the tags of a line that a fold splits into two visible ranges", async () => {
+ const support = await loadSupport(findLanguageDescription("Java", "/p/A.java")!);
+ const doc = "/** See {@link A} and some text long enough to fold away, then {@link B}. */\nclass A {}";
+ const view = new EditorView({
+ parent: document.body,
+ state: completedSyntaxState(
+ EditorState.create({ doc, extensions: [support!, syntaxHighlighting(openideaHighlightStyle), codeFolding()] }),
+ ),
+ });
+ views.push(view);
+ view.dispatch({ effects: foldEffect.of({ from: doc.indexOf("some"), to: doc.indexOf(", then") }) });
+ expect(view.visibleRanges.length).toBe(2);
+ const cls = highlightingFor(view.state, [docCommentTag])!;
+ expect([...view.contentDOM.querySelectorAll(`.${cls}`)].map((e) => e.textContent)).toEqual(["@link", "@link"]);
+ });
+});
diff --git a/src/editor/core/cm/docCommentTags.ts b/src/editor/core/cm/docCommentTags.ts
new file mode 100644
index 000000000..0b1744ee0
--- /dev/null
+++ b/src/editor/core/cm/docCommentTags.ts
@@ -0,0 +1,188 @@
+/**
+ * Doc comment tags, as IntelliJ colours them inside `/** … *\/`: a block tag at the start of a
+ * line and an inline `{@tag`, the value some tags take (a `@param` name, a KDoc subject or
+ * `[link]`, a PHPDoc `$variable`) and HTML markup. Only comment nodes in the visible ranges are
+ * scanned; the colours come from the highlight style (`--syn-doc-comment-tag*`, `--syn-doc-markup`).
+ */
+import { highlightingFor, syntaxTree } from "@codemirror/language";
+import { Prec, RangeSetBuilder, type EditorState, type Extension } from "@codemirror/state";
+import { Decoration, ViewPlugin, type DecorationSet, type EditorView, type ViewUpdate } from "@codemirror/view";
+import { docCommentMarkup, docCommentTag, docCommentTagValue } from "./highlight";
+
+export type DocCommentTagKind = "tag" | "value" | "markup";
+
+export interface DocCommentTagRange {
+ from: number;
+ to: number;
+ kind: DocCommentTagKind;
+}
+
+interface Dialect {
+ /** Inline tags: `{@link …}`. */
+ inline: boolean;
+ /** HTML tags in the text. */
+ markup: boolean;
+ /** KDoc `[links]` in the text. */
+ links?: boolean;
+ /** The value of a block tag as `[from, to)` in `rest`, the line after the tag. */
+ value?: (tag: string, rest: string) => [number, number] | null;
+}
+
+/** The value of `tags`: the last group of `value`, which ends the match. */
+function valueOf(tags: RegExp, value: RegExp): Dialect["value"] {
+ return (tag, rest) => {
+ if (!tags.test(tag)) return null;
+ const m = value.exec(rest);
+ return m ? [m.index + m[0].length - m[1]!.length, m.index + m[0].length] : null;
+ };
+}
+
+const JAVADOC: Dialect = { inline: true, markup: true, value: valueOf(/^@param$/, /^\s+(<\w+>|[\w$]+)/) };
+const JSDOC: Dialect = { inline: true, markup: true };
+// KDoc links the subject of these tags; a `[name]` anywhere in the text is a link too.
+const KDOC: Dialect = {
+ inline: false,
+ markup: false,
+ links: true,
+ value: valueOf(/^@(?:param|property|throws|exception|see|sample)$/, /^\s+(\[[^\]\n]+\]|[\w.]+)/),
+};
+const PHPDOC: Dialect = {
+ inline: true,
+ markup: true,
+ value: valueOf(/^@(?:[a-z]+-)?(?:param|var|property(?:-read|-write)?)$/, /^.*?(?:\s|&|\.\.\.)(\$[A-Za-z_]\w*)/),
+};
+
+/** By CodeMirror language name (`LanguageDescription.name`). */
+const DIALECTS: Record = {
+ Java: JAVADOC,
+ Kotlin: KDOC,
+ JavaScript: JSDOC,
+ TypeScript: JSDOC,
+ JSX: JSDOC,
+ TSX: JSDOC,
+ "Angular TypeScript": JSDOC,
+ PHP: PHPDOC,
+};
+
+const COMMENT = /comment/i;
+/** Indentation and the leading `*` of a comment line. */
+const LEAD = /^\s*\**\s*/;
+const BLOCK_TAG = /^@[A-Za-z][\w\\-]*/;
+const INLINE_TAG = /\{@[A-Za-z][\w-]*/g;
+const LINK = /\[[^[\]\n]+\](?![([])/g;
+// Known HTML elements only, so generics such as `List` stay text.
+const MARKUP =
+ /<\/?(?:a|abbr|b|big|blockquote|br|caption|cite|code|dd|del|details|div|dl|dt|em|h[1-6]|hr|i|img|ins|kbd|li|ol|p|pre|q|s|samp|section|small|span|strong|sub|summary|sup|table|tbody|td|th|thead|tr|tt|u|ul|var)\b[^<>]*>/gi;
+/** `/**` opens a doc comment; `/**\/` is an empty block comment. */
+const opensDoc = (head: string) => head.startsWith("/**") && !head.startsWith("/**/");
+
+function scanComment(
+ state: EditorState,
+ dialect: Dialect,
+ comment: { from: number; to: number },
+ range: { from: number; to: number },
+ out: DocCommentTagRange[],
+) {
+ const end = Math.min(comment.to, range.to);
+ for (let pos = Math.max(comment.from, range.from); pos <= end;) {
+ const line = state.doc.lineAt(pos);
+ const from = Math.max(line.from, comment.from);
+ let text = state.sliceDoc(from, Math.min(line.to, comment.to));
+ if (text.endsWith("*/")) text = text.slice(0, -2);
+ const skip = from === comment.from && opensDoc(text.slice(0, 4)) ? 3 : 0;
+ const lead = skip + LEAD.exec(text.slice(skip))![0].length;
+ const tag = BLOCK_TAG.exec(text.slice(lead));
+ if (tag) {
+ const at = from + lead;
+ out.push({ from: at, to: at + tag[0].length, kind: "tag" });
+ const value = dialect.value?.(tag[0], text.slice(lead + tag[0].length));
+ if (value) out.push({ from: at + tag[0].length + value[0], to: at + tag[0].length + value[1], kind: "value" });
+ }
+ const rest = text.slice(skip);
+ const base = from + skip;
+ if (dialect.inline)
+ for (const m of rest.matchAll(INLINE_TAG))
+ out.push({ from: base + m.index + 1, to: base + m.index + m[0].length, kind: "tag" });
+ if (dialect.links)
+ for (const m of rest.matchAll(LINK))
+ out.push({ from: base + m.index, to: base + m.index + m[0].length, kind: "value" });
+ if (dialect.markup)
+ for (const m of rest.matchAll(MARKUP))
+ out.push({ from: base + m.index, to: base + m.index + m[0].length, kind: "markup" });
+ pos = line.to + 1;
+ }
+}
+
+/** Tag, value and markup ranges in the doc comments between `from` and `to`, sorted, without overlaps. */
+export function docCommentTagRanges(
+ state: EditorState,
+ language: string,
+ from: number,
+ to: number,
+): DocCommentTagRange[] {
+ const dialect = Object.hasOwn(DIALECTS, language) ? DIALECTS[language] : undefined;
+ if (!dialect) return [];
+ const found: DocCommentTagRange[] = [];
+ syntaxTree(state).iterate({
+ from,
+ to,
+ enter: (node) => {
+ if (!COMMENT.test(node.name)) return;
+ const doc =
+ language === "Kotlin" ? node.name === "docComment" : opensDoc(state.sliceDoc(node.from, node.from + 4));
+ if (doc) scanComment(state, dialect, node, { from, to }, found);
+ return false;
+ },
+ });
+ found.sort((a, b) => a.from - b.from);
+ const out: DocCommentTagRange[] = [];
+ for (const r of found) if (!out.length || r.from >= out[out.length - 1]!.to) out.push(r);
+ return out;
+}
+
+const TAGS = { tag: docCommentTag, value: docCommentTagValue, markup: docCommentMarkup };
+const marks = new Map();
+
+function decorations(view: EditorView, language: string): DecorationSet {
+ // A fold inside one line splits it into two visible ranges that both scan that whole line.
+ const found = view.visibleRanges
+ .flatMap((range) => docCommentTagRanges(view.state, language, range.from, range.to))
+ .sort((a, b) => a.from - b.from);
+ const builder = new RangeSetBuilder();
+ let end = -1;
+ for (const r of found) {
+ if (r.from < end) continue;
+ const cls = highlightingFor(view.state, [TAGS[r.kind]]);
+ if (!cls) continue;
+ let mark = marks.get(cls);
+ if (!mark) marks.set(cls, (mark = Decoration.mark({ class: cls })));
+ builder.add(r.from, r.to, mark);
+ end = r.to;
+ }
+ return builder.finish();
+}
+
+/** Doc comment tag highlighting for a CodeMirror language (by name); none where IntelliJ has none. */
+export function docCommentTags(language: string): Extension {
+ if (!Object.hasOwn(DIALECTS, language)) return [];
+ // Above the syntax highlighters (Lezer and tree-sitter), so the marks nest inside the comment's and win.
+ const plugin = ViewPlugin.fromClass(
+ class {
+ decorations: DecorationSet;
+ constructor(view: EditorView) {
+ this.decorations = decorations(view, language);
+ }
+ update(u: ViewUpdate) {
+ if (
+ u.docChanged ||
+ u.viewportChanged ||
+ u.transactions.some((t) => t.reconfigured) ||
+ syntaxTree(u.state) !== syntaxTree(u.startState)
+ )
+ this.decorations = decorations(u.view, language);
+ }
+ },
+ { decorations: (v) => v.decorations },
+ );
+ return Prec.highest(plugin);
+}
diff --git a/src/editor/core/cm/highlight.ts b/src/editor/core/cm/highlight.ts
index 78285557d..ec7b3671f 100644
--- a/src/editor/core/cm/highlight.ts
+++ b/src/editor/core/cm/highlight.ts
@@ -9,6 +9,10 @@ import { Tag, tags as t } from "@lezer/highlight";
/** Log warnings: the UI warning colour, since no syntax token reads as one in every scheme. */
export const logWarning = Tag.define("logWarning");
export const parameter = Tag.define(t.variableName);
+/** Inside doc comments, as IntelliJ colours them: `@param` / `{@link`, a `@param` name, HTML markup. */
+export const docCommentTag = Tag.define(t.docComment);
+export const docCommentTagValue = Tag.define(t.docComment);
+export const docCommentMarkup = Tag.define(t.docComment);
function token(
tag: Tag | Tag[],
@@ -66,6 +70,9 @@ export const openideaHighlightStyle = HighlightStyle.define([
),
token([t.comment, t.lineComment, t.blockComment], "comment", { italic: "var(--syn-comment-style)" }),
token(t.docComment, "doc-comment", { italic: "italic" }),
+ token(docCommentTag, "doc-comment-tag", { italic: "normal", bold: "var(--fw-code, 400)" }),
+ token(docCommentTagValue, "doc-comment-tag-value", { italic: "normal", bold: "var(--fw-code, 400)" }),
+ token(docCommentMarkup, "doc-markup", { italic: "normal", bold: "var(--fw-code, 400)" }),
token([t.meta, t.annotation, t.macroName], "decorator"),
// `` is a sub-tag of `meta`; PyCharm leaves the XML prologue as plain text.
{ tag: t.documentMeta, color: "var(--fg-editor)" },
diff --git a/src/editor/core/cm/interpolation.ts b/src/editor/core/cm/interpolation.ts
index 51d0f3d84..ea894d2b1 100644
--- a/src/editor/core/cm/interpolation.ts
+++ b/src/editor/core/cm/interpolation.ts
@@ -10,7 +10,15 @@ type Literal = {
multiline: boolean;
braces: number;
};
-type Frame = { code: S; literal?: Literal; depth: number; name?: boolean; close?: string };
+type Frame = {
+ code: S;
+ literal?: Literal;
+ depth: number;
+ name?: boolean;
+ close?: string;
+ doc?: boolean;
+ commentDepth?: number;
+};
type State = { frames: Frame[]; indentUnit: number };
/** Preserve the stock code tokenizer while suspending strings around embedded code. */
@@ -129,7 +137,18 @@ export function interpolationParser(base: StreamParser, dialect: Interpola
if (text[0] === "}") frame.depth--;
}
stream.pos = from;
- return base.token(stream, frame.code);
+ const result = base.token(stream, frame.code);
+ if (dialect === "Kotlin" && result === "comment" && (frame.commentDepth || text.startsWith("/*"))) {
+ // Keep the doc flag in the incremental parser state, including through nested comments.
+ const doc = frame.commentDepth ? frame.doc : text.startsWith("/**") && !text.startsWith("/**/");
+ let depth = frame.commentDepth ?? 0;
+ for (const delimiter of stream.string.slice(from, stream.pos).matchAll(/\/\*|\*\//g))
+ depth += delimiter[0] === "/*" ? 1 : -1;
+ frame.commentDepth = depth;
+ frame.doc = depth > 0 && doc;
+ return doc ? "docComment" : result;
+ }
+ return result;
},
};
}
diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts
index 51f06c177..4c8d78ed9 100644
--- a/src/editor/core/cm/languages.ts
+++ b/src/editor/core/cm/languages.ts
@@ -20,6 +20,7 @@ import { languages as languageData } from "@codemirror/language-data";
import type { DocBackend, LanguageInfo } from "../backend";
import { _languageMode } from "@/editor/api";
import { JS_TAGS } from "./jsTags";
+import { docCommentTags } from "./docCommentTags";
let infos: Map | null = null;
let infosPromise: Promise