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);