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