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 <[email protected]>
Цей коміт міститься в:
lleviathe authored and GitHub committed 2026-11-09 06:30:29 +08:00
1 джерело ccd1f3aca1
коміт fd82beb309
18 files changed
+678 -8

No files matched your search

+302
Переглянути файл
@@ -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 [email protected].",
" * <p>Second @param paragraph, List<String> stays text.</p>",
" * @param <T> 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"],
["<p>", "markup"],
["</p>", "markup"],
["@param", "tag"],
["<T>", "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 [email protected].",
" * @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",
[
"<?php",
"/**",
" * Finds a user, {@inheritDoc}",
" * @param int|string $id the id",
" * @param array<int, string> ...$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" ? "<?php" : "",
"@Override class A {}",
"// @Override {@link X}",
"/* @Override {@link X} */",
"/* plain",
" * @param falsePositive",
" */",
'const s = "/** @param string */";',
"/**/",
"/** [email protected]",
" * @return unfinished",
].join("\n"),
);
// Java and PHP do not expose unterminated block comments as comment syntax nodes.
expect(ranges(s, language)).toEqual(language === "Java" || language === "PHP" ? [] : [["@return", "tag"]]);
});
it.each(["Java", "Kotlin"])("keeps tags deep inside a long %s doc comment", async (language) => {
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 <b>this</b>.\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],
["<b>", 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"]);
});
});
+188
Переглянути файл
@@ -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<string, Dialect> = {
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<String>` 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<string, Decoration>();
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<Decoration>();
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);
}
+7
Переглянути файл
@@ -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"),
// `<!doctype>` is a sub-tag of `meta`; PyCharm leaves the XML prologue as plain text.
{ tag: t.documentMeta, color: "var(--fg-editor)" },
+21 -2
Переглянути файл
@@ -10,7 +10,15 @@ type Literal = {
multiline: boolean;
braces: number;
};
type Frame<S> = { code: S; literal?: Literal; depth: number; name?: boolean; close?: string };
type Frame<S> = {
code: S;
literal?: Literal;
depth: number;
name?: boolean;
close?: string;
doc?: boolean;
commentDepth?: number;
};
type State<S> = { frames: Frame<S>[]; indentUnit: number };
/** Preserve the stock code tokenizer while suspending strings around embedded code. */
@@ -129,7 +137,18 @@ export function interpolationParser<S>(base: StreamParser<S>, 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;
},
};
}
+2
Переглянути файл
@@ -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<string, LanguageInfo> | null = null;
let infosPromise: Promise<Map<string, LanguageInfo>> | 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.
+6
Переглянути файл
@@ -135,6 +135,12 @@ export const ICLS_ATTRIBUTES: Record<string, Array<[Part, string]>> = {
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"]],
+36
Переглянути файл
@@ -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"],
["<b>", 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 () => {
+6 -3
Переглянути файл
@@ -249,9 +249,12 @@ const SAMPLES: Record<string, CodeSample> = {
],
[
[" /**", "docComment"],
[" doc comment ", "docComment"],
["@param", "docComment"],
[" id", "docComment"],
[" <b>", "docCommentMarkup"],
["doc comment", "docComment"],
["</b> ", "docCommentMarkup"],
["@param", "docCommentTag"],
[" ", "docComment"],
["id", "docCommentTagValue"],
[" */", "docComment"],
],
[
+6
Переглянути файл
@@ -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");
+5 -1
Переглянути файл
@@ -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<string>([
...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",
+37
Переглянути файл
@@ -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) => `<value><option name="FOREGROUND" value="${c}"/></value>`;
const { scheme } = parseIcls(`<scheme name="Docs" parent_scheme="Darcula"><attributes>
<option name="DEFAULT_DOC_COMMENT_TAG">${fg("67a37c")}</option>
<option name="DEFAULT_DOC_COMMENT_TAG_VALUE">${fg("abadb3")}</option>
<option name="DEFAULT_DOC_MARKUP"><value><option name="BACKGROUND" value="e2ffe2"/></value></option>
<option name="JAVA_DOC_TAG">${fg("111111")}</option>
<option name="KDOC_LINK">${fg("222222")}</option>
<option name="PHP_DOC_TAG">${fg("333333")}</option>
<option name="PHP_DOC_VAR">${fg("444444")}</option>
<option name="PHP_ATTRIBUTE">${fg("555555")}</option>
</attributes></scheme>`);
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");
+12 -1
Переглянути файл
@@ -27,6 +27,9 @@ const ATTRIBUTE_NAMES: Record<string, string> = {
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",
+2
Переглянути файл
@@ -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.",
+1
Переглянути файл
@@ -2489,6 +2489,7 @@
"Tabs and Indents": "制表符和缩进",
"Tag": "标签",
"Tag name:": "标签名称:",
"Tag value": "标签值",
"Tags to push": "要推送的标签",
"Target changelist": "目标更改列表",
"Target directory": "目标目录",
+20
Переглянути файл
@@ -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);