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> | null = null; @@ -303,6 +304,7 @@ function patchSupport(desc: LanguageDescription, support: LanguageSupport): Lang : support.language; const patched = new LanguageSupport(language, [ support.support, + docCommentTags(desc.name), EditorView.editorAttributes.of({ "data-language": desc.name }), ]); // `loadedSupport` reads `desc.support` synchronously; keep it on the patched instance. diff --git a/src/features/plugins/intellijTheme.ts b/src/features/plugins/intellijTheme.ts index b24bb3f3f..5e19770ee 100644 --- a/src/features/plugins/intellijTheme.ts +++ b/src/features/plugins/intellijTheme.ts @@ -135,6 +135,12 @@ export const ICLS_ATTRIBUTES: Record> = { DEFAULT_NUMBER: [["FOREGROUND", "--syn-number"]], DEFAULT_LINE_COMMENT: [["FOREGROUND", "--syn-comment"]], DEFAULT_DOC_COMMENT: [["FOREGROUND", "--syn-doc-comment"]], + DEFAULT_DOC_COMMENT_TAG: [["FOREGROUND", "--syn-doc-comment-tag"]], + DEFAULT_DOC_COMMENT_TAG_VALUE: [["FOREGROUND", "--syn-doc-comment-tag-value"]], + DEFAULT_DOC_MARKUP: [ + ["FOREGROUND", "--syn-doc-markup"], + ["BACKGROUND", "--syn-doc-markup-bg"], + ], DEFAULT_FUNCTION_DECLARATION: [["FOREGROUND", "--syn-function"]], DEFAULT_FUNCTION_CALL: [["FOREGROUND", "--syn-function-call"]], DEFAULT_CLASS_NAME: [["FOREGROUND", "--syn-type"]], diff --git a/src/features/settings/__tests__/colorScheme.test.tsx b/src/features/settings/__tests__/colorScheme.test.tsx index 2a961f287..4fd6b211e 100644 --- a/src/features/settings/__tests__/colorScheme.test.tsx +++ b/src/features/settings/__tests__/colorScheme.test.tsx @@ -121,6 +121,42 @@ describe("scheme actions", () => { }); }); +describe("doc comment scheme attributes", () => { + it.each([ + ["en", "Doc comment", "Tag", "Tag value", "Markup"], + ["zh", "文档注释", "标签", "标签值", "标记"], + ])("groups, previews, edits and resets doc attributes in %s", async (locale, group, tag, valueName, markup) => { + await act(async () => { + await setLocale(locale); + }); + try { + act(() => stage("editor.colorScheme", "global", "editor.colorScheme", "dark")); + schemePage("editor.colorScheme.language-defaults"); + const preview = screen.getByLabelText(locale === "en" ? "Color scheme preview" : "配色方案预览"); + for (const [sample, label, id] of [ + ["@param", tag, "docCommentTag"], + ["id", valueName, "docCommentTagValue"], + ["", markup, "docCommentMarkup"], + ]) { + act(() => fireEvent.click(within(preview).getByText(sample!))); + expect(screen.getAllByRole("treeitem", { name: group })).toHaveLength(1); + expect(screen.getByRole("treeitem", { name: label, selected: true })).toBeInTheDocument(); + act(() => fireEvent.click(screen.getByRole("checkbox", { name: locale === "en" ? "Bold" : "粗体" }))); + act(() => fireEvent.click(screen.getByRole("checkbox", { name: locale === "en" ? "Italic" : "斜体" }))); + expect(value("colorOverrides")).toMatchObject({ dark: { [id!]: { bold: true, italic: true } } }); + } + act(() => + fireEvent.click(screen.getByRole("button", { name: locale === "en" ? "Restore Defaults" : "恢复默认值" })), + ); + expect(value("colorOverrides")).toEqual({}); + } finally { + await act(async () => { + await setLocale("en"); + }); + } + }); +}); + describe("Go scheme attributes", () => { it("translates the attribute tree and preview selection in Chinese", async () => { await act(async () => { diff --git a/src/features/settings/pages/editor/colorScheme.tsx b/src/features/settings/pages/editor/colorScheme.tsx index 9f3359009..cd74d36f9 100644 --- a/src/features/settings/pages/editor/colorScheme.tsx +++ b/src/features/settings/pages/editor/colorScheme.tsx @@ -249,9 +249,12 @@ const SAMPLES: Record = { ], [ [" /**", "docComment"], - [" doc comment ", "docComment"], - ["@param", "docComment"], - [" id", "docComment"], + [" ", "docCommentMarkup"], + ["doc comment", "docComment"], + [" ", "docCommentMarkup"], + ["@param", "docCommentTag"], + [" ", "docComment"], + ["id", "docCommentTagValue"], [" */", "docComment"], ], [ diff --git a/src/platform/colorScheme.test.ts b/src/platform/colorScheme.test.ts index a3082dc06..762bf1812 100644 --- a/src/platform/colorScheme.test.ts +++ b/src/platform/colorScheme.test.ts @@ -29,6 +29,12 @@ describe("color schemes", () => { expect(dark.get("--syn-python-bytes")).toBe("#a5c261"); }); + it.each(["light", "dark"] as const)("inherits new doc tokens for older %s custom schemes", (parent) => { + const customs = { "custom-old": { name: "Old", parent, attributes: {} } }; + for (const id of ["docCommentTag", "docCommentTagValue", "docCommentMarkup"]) + expect(resolvedAttribute("custom-old", id, customs)).toEqual(resolvedAttribute(parent, id)); + }); + it("gives PHP variables and function calls their own colours, as PhpStorm (#1532)", () => { expect(schemeTokens("dark").get("--syn-php-variable")).toBe("#c77dbb"); expect(schemeTokens("dark").get("--syn-php-function-call")).toBe("#56a8f5"); diff --git a/src/platform/colorScheme.ts b/src/platform/colorScheme.ts index 8a7ef88c9..a1a48914d 100644 --- a/src/platform/colorScheme.ts +++ b/src/platform/colorScheme.ts @@ -137,7 +137,10 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [ syn("string", "String › String text", [LD], "string"), syn("escape", "String › Valid escape sequence", [LD], "escape"), syn("lineComment", "Comments › Line comment", [LD], "comment"), - syn("docComment", "Comments › Doc comment", [LD], "doc-comment", { defaultItalic: true }), + syn("docComment", "Comments › Doc comment › Text", [LD], "doc-comment", { defaultItalic: true }), + syn("docCommentTag", "Comments › Doc comment › Tag", [LD], "doc-comment-tag"), + syn("docCommentTagValue", "Comments › Doc comment › Tag value", [LD], "doc-comment-tag-value"), + { ...syn("docCommentMarkup", "Comments › Doc comment › Markup", [LD], "doc-markup"), bg: "--syn-doc-markup-bg" }, syn("metadata", "Metadata", [LD], "decorator"), syn("operator", "Braces and Operators", [LD], "punct"), syn("markupTag", "Markup › Tag", [LD], "tag"), @@ -208,6 +211,7 @@ export const ATTRIBUTES: readonly AttributeDef[] = [ const SCHEME_VARS = new Set([ ...ATTRIBUTES.flatMap((a) => [a.fg, a.bg, a.effect].filter((v): v is string => !!v)), "--syn-comment-style", + "--syn-doc-comment-tag-effect", "--editor-error-decoration", "--editor-warning-decoration", "--editor-warning-background", diff --git a/src/platform/icls.test.ts b/src/platform/icls.test.ts index 705242c13..7d4f9ca59 100644 --- a/src/platform/icls.test.ts +++ b/src/platform/icls.test.ts @@ -25,6 +25,43 @@ describe(".icls schemes", () => { expect(scheme.attributes["php.string"]).toBeUndefined(); }); + it("maps doc comment tags, tag values and markup per language", () => { + const fg = (c: string) => ``; + const { scheme } = parseIcls(` + + + + + + + + + `); + expect(scheme.attributes.docCommentTag?.foreground).toBe("#67a37c"); + expect(scheme.attributes.docCommentTagValue?.foreground).toBe("#abadb3"); + expect(scheme.attributes.docCommentMarkup?.background).toBe("#e2ffe2"); + expect(scheme.attributes["java.docCommentTag"]?.foreground).toBe("#111111"); + expect(scheme.attributes["kotlin.docCommentTagValue"]?.foreground).toBe("#222222"); + expect(scheme.attributes["php.docCommentTag"]?.foreground).toBe("#333333"); + expect(scheme.attributes["php.docCommentTagValue"]?.foreground).toBe("#444444"); + // PhpStorm's PHP 8 attributes, which PHP_DOC_TAG used to stand in for. + expect(scheme.attributes["php.metadata"]?.foreground).toBe("#555555"); + const css = schemeCss("custom-docs", undefined, null, { "custom-docs": scheme }); + expect(css).toContain("--syn-doc-comment-tag: #67a37c !important"); + expect(css).toContain("--syn-doc-markup-bg: #e2ffe2 !important"); + const imported = parseIcls(exportIcls("custom-docs", { "custom-docs": scheme })).scheme; + for (const id of [ + "docCommentTag", + "docCommentTagValue", + "docCommentMarkup", + "java.docCommentTag", + "kotlin.docCommentTagValue", + "php.docCommentTag", + "php.docCommentTagValue", + ]) + expect(imported.attributes[id]).toMatchObject(scheme.attributes[id]!); + }); + it("preserves PHP colours and regular styles separately from Java and language defaults", () => { const { scheme } = parseIcls(fixture("monokai-php")); expect(scheme.parent).toBe("dark"); diff --git a/src/platform/icls.ts b/src/platform/icls.ts index f4f155c5f..0e5abb23f 100644 --- a/src/platform/icls.ts +++ b/src/platform/icls.ts @@ -27,6 +27,9 @@ const ATTRIBUTE_NAMES: Record = { DEFAULT_LINE_COMMENT: "lineComment", DEFAULT_BLOCK_COMMENT: "lineComment", DEFAULT_DOC_COMMENT: "docComment", + DEFAULT_DOC_COMMENT_TAG: "docCommentTag", + DEFAULT_DOC_COMMENT_TAG_VALUE: "docCommentTagValue", + DEFAULT_DOC_MARKUP: "docCommentMarkup", DEFAULT_METADATA: "metadata", DEFAULT_OPERATION_SIGN: "operator", DEFAULT_BRACES: "operator", @@ -107,6 +110,11 @@ Object.assign(ATTRIBUTE_NAMES, { KOTLIN_EXTENSION_FUNCTION_CALL: "kotlin.functionCall", KOTLIN_NAMED_ARGUMENT: "kotlin.parameter", KOTLIN_OBJECT: "kotlin.className", + JAVA_DOC_TAG: "java.docCommentTag", + DOC_COMMENT_TAG_VALUE: "java.docCommentTagValue", + JAVA_DOC_MARKUP: "java.docCommentMarkup", + KDOC_TAG_NAME: "kotlin.docCommentTag", + KDOC_LINK: "kotlin.docCommentTagValue", CLASS_NAME_ATTRIBUTES: "className", PHP_KEYWORD: "php.keyword", PHP_VAR: "php.identifier", @@ -124,7 +132,10 @@ Object.assign(ATTRIBUTE_NAMES, { PHP_ESCAPE_SEQUENCE: "php.escape", PHP_COMMENT: "php.lineComment", PHP_DOC_COMMENT_ID: "php.docComment", - PHP_DOC_TAG: "php.metadata", + PHP_DOC_TAG: "php.docCommentTag", + PHP_DOC_VAR: "php.docCommentTagValue", + PHP_MARKUP_ID: "php.docCommentMarkup", + PHP_ATTRIBUTE: "php.metadata", PHP_OPERATION_SIGN: "php.operator", PHP_BRACKETS: "php.brackets", PHP_COMMA: "php.separator", diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index e16d65813..cf5934c3a 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -36,10 +36,12 @@ "Comments": "Comments", "Line comment": "Line comment", "Doc comment": "Doc comment", + "Text": "Text", "Metadata": "Metadata", "Braces and Operators": "Braces and Operators", "Markup": "Markup", "Tag": "Tag", + "Tag value": "Tag value", "Attribute": "Attribute", "Contents differ only in line separators": "Contents differ only in line separators", "Cannot remove a worktree containing the open project.": "Cannot remove a worktree containing the open project.", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 21455ff0a..6eeaaea3f 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -2489,6 +2489,7 @@ "Tabs and Indents": "制表符和缩进", "Tag": "标签", "Tag name:": "标签名称:", + "Tag value": "标签值", "Tags to push": "要推送的标签", "Target changelist": "目标更改列表", "Target directory": "目标目录", diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 7f103e261..6214044e3 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -116,6 +116,11 @@ html { --syn-comment: #7a7e85; --syn-comment-style: normal; --syn-doc-comment: #5f826b; + --syn-doc-comment-tag: #67a37c; + --syn-doc-comment-tag-effect: none; + --syn-doc-comment-tag-value: #abadb3; + --syn-doc-markup: #68a67e; + --syn-doc-markup-bg: transparent; --syn-docstring: #5f826b; --syn-annotation: #b3ae60; --syn-decorator: #b3ae60; @@ -521,6 +526,11 @@ html { --syn-comment: #8c8c8c; --syn-comment-style: italic; --syn-doc-comment: #8c8c8c; + --syn-doc-comment-tag: #8c8c8c; + --syn-doc-comment-tag-effect: underline #999999; + --syn-doc-comment-tag-value: #3d3d3d; + --syn-doc-markup: #8c8c8c; + --syn-doc-markup-bg: #e2ffe2; --syn-docstring: #067d17; --syn-annotation: #9e880d; --syn-decorator: #9e880d; @@ -799,6 +809,11 @@ html { --syn-function-call: #e3e4e8; --syn-comment: #a5a9b0; --syn-doc-comment: #8fb89c; + --syn-doc-comment-tag: #8fcca3; + --syn-doc-comment-tag-effect: none; + --syn-doc-comment-tag-value: #d2d4d9; + --syn-doc-markup: #90c9a3; + --syn-doc-markup-bg: transparent; --syn-docstring: #8fb89c; --syn-annotation: #d6d17a; --syn-decorator: #d6d17a; @@ -1166,6 +1181,11 @@ html { --syn-function-call: var(--palette-blue); --syn-comment: var(--palette-comment); --syn-doc-comment: var(--palette-comment); + --syn-doc-comment-tag: var(--palette-keyword); + --syn-doc-comment-tag-effect: none; + --syn-doc-comment-tag-value: var(--palette-variable); + --syn-doc-markup: var(--syn-doc-comment); + --syn-doc-markup-bg: transparent; --syn-docstring: var(--palette-green); --syn-annotation: var(--palette-orange); --syn-decorator: var(--palette-orange);