From 123e7c51768dd345c7c72599c8e6c7b7271dcb1f Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:05:27 +0530 Subject: [PATCH 1/4] feat(editor): highlight JSDoc inside JS/TS block comments --- src/cm/modes/jsdoc/index.ts | 359 ++++++++++++++++++++++++++++++++++++ src/cm/supportedModes.ts | 16 +- 2 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 src/cm/modes/jsdoc/index.ts diff --git a/src/cm/modes/jsdoc/index.ts b/src/cm/modes/jsdoc/index.ts new file mode 100644 index 000000000..ddc6cc0e5 --- /dev/null +++ b/src/cm/modes/jsdoc/index.ts @@ -0,0 +1,359 @@ +import { + foldNodeProp, + indentNodeProp, + LanguageSupport, + LRLanguage, +} from "@codemirror/language"; +import { + type Input, + NodeSet, + NodeType, + Parser, + type ParseWrapper, + type PartialParse, + parseMixed, + Tree, + type TreeFragment, +} from "@lezer/common"; +import { styleTags, tags as t } from "@lezer/highlight"; + +/** + * A tiny JSDoc tokenizer mounted inside JS/TS `/** ... *\/` comments so + * themes can style block tags, types, parameter names and inline links + * separately from the comment text. + * + * The mounted tree replaces the host `BlockComment` node, so its root keeps + * that name (comment checks in completion and rainbow brackets still match) + * and the fold/indent behaviour JS gives block comments. It is not a top + * node, so language data (comment tokens, completions) still resolves to the + * surrounding JS/TS. + */ + +enum Type { + BlockComment, + JSDocTag, + JSDocType, + JSDocTypeBrace, + JSDocTypePunctuation, + JSDocParam, + JSDocBracket, + JSDocLink, +} + +const nodeSet = new NodeSet( + [ + "BlockComment", + "JSDocTag", + "JSDocType", + "JSDocTypeBrace", + "JSDocTypePunctuation", + "JSDocParam", + "JSDocBracket", + "JSDocLink", + ].map((name, id) => NodeType.define({ id, name })), +).extend( + styleTags({ + BlockComment: t.docComment, + JSDocTag: t.keyword, + JSDocType: t.typeName, + JSDocTypeBrace: t.brace, + JSDocTypePunctuation: t.punctuation, + JSDocParam: t.variableName, + JSDocBracket: t.squareBracket, + JSDocLink: t.link, + }), + foldNodeProp.add({ + BlockComment: (tree) => ({ from: tree.from + 2, to: tree.to - 2 }), + }), + indentNodeProp.add({ BlockComment: () => null }), +); + +// Tags followed by a parameter/property name, e.g. `@param {T} name`. +const PARAM_TAGS = new Set(["param", "arg", "argument", "property", "prop"]); +// Tags followed by a type name, e.g. `@typedef {Object} Name`. +const TYPE_NAME_TAGS = new Set(["typedef", "callback", "template"]); + +const IDENTIFIER = /[A-Za-z_$][\w$]*(?:(?:\[\])?\.[\w$]+)*/y; +const TAG_NAME = /@[A-Za-z][\w-]*/y; +const LINK_TARGET = /[^\s|}]+/y; + +function matchAt(re: RegExp, text: string, pos: number): string | null { + re.lastIndex = pos; + const match = re.exec(text); + return match ? match[0] : null; +} + +function isSpace(ch: string | undefined): boolean { + return ch === " " || ch === "\t" || ch === "\r" || ch === "\n"; +} + +function tokenize(text: string): number[] { + // Flat [type, from, to, size] groups, as expected by Tree.build. + const buffer: number[] = []; + const push = (type: Type, from: number, to: number) => { + if (to > from) buffer.push(type, from, to, 4); + }; + + const end = text.endsWith("*/") ? text.length - 2 : text.length; + let pos = 3; // skip "/**" + let lineStart = false; + let typeAllowed = false; + let pending: "param" | "typeName" | null = null; + // First `}`, newline or end at or after the last `{@` scan. Every `{@` + // before it stops at the same place, so it is reused to keep scanning + // linear when there are many unclosed inline tags. + let inlineStop = -1; + + while (pos < end) { + const ch = text[pos]; + + if (ch === "\n") { + lineStart = true; + typeAllowed = false; + pending = null; + pos++; + continue; + } + if (isSpace(ch)) { + pos++; + continue; + } + if (lineStart && ch === "*") { + pos++; + continue; + } + lineStart = false; + + const prev = text[pos - 1]; + if (ch === "@" && (isSpace(prev) || prev === "*")) { + const tag = matchAt(TAG_NAME, text, pos); + if (tag) { + const name = tag.slice(1); + push(Type.JSDocTag, pos, pos + tag.length); + pos += tag.length; + typeAllowed = true; + pending = PARAM_TAGS.has(name) + ? "param" + : TYPE_NAME_TAGS.has(name) + ? "typeName" + : null; + continue; + } + } + + if (ch === "{" && text[pos + 1] === "@") { + if (inlineStop <= pos) { + inlineStop = pos + 1; + while ( + inlineStop < end && + text[inlineStop] !== "}" && + text[inlineStop] !== "\n" + ) { + inlineStop++; + } + } + pos = + text[inlineStop] === "}" && inlineStop < end + ? inlineTag(text, pos, inlineStop, push) + : pos + 1; + typeAllowed = false; + pending = null; + continue; + } + + if (ch === "{" && typeAllowed) { + pos = typeExpression(text, pos, end, push); + typeAllowed = false; + continue; + } + typeAllowed = false; + + if (pending === "param" && ch === "[") { + pos = optionalParam(text, pos, end, push); + pending = null; + continue; + } + + if (pending) { + const name = matchAt(IDENTIFIER, text, pos); + if (name) { + push( + pending === "param" ? Type.JSDocParam : Type.JSDocType, + pos, + pos + name.length, + ); + pos += name.length; + pending = null; + continue; + } + } + pending = null; + + // Plain description text: covered by the JSDoc root node. + pos++; + while (pos < end && !isSpace(text[pos]) && text[pos] !== "{") pos++; + } + + return buffer; +} + +type Push = (type: Type, from: number, to: number) => void; + +/** `{@link Target|label}` and friends; `close` is the matching `}`. */ +function inlineTag(text: string, pos: number, close: number, push: Push) { + push(Type.JSDocTypeBrace, pos, pos + 1); + let i = pos + 1; + const tag = matchAt(TAG_NAME, text, i); + if (tag) { + push(Type.JSDocTag, i, i + tag.length); + i += tag.length; + while (i < close && isSpace(text[i])) i++; + const target = matchAt(LINK_TARGET, text, i); + if (target && i + target.length <= close) { + push(Type.JSDocLink, i, i + target.length); + } + } + push(Type.JSDocTypeBrace, close, close + 1); + return close + 1; +} + +// Longest type expression scanned before giving up on a missing `}`. +const MAX_TYPE_LENGTH = 500; + +/** + * `{Array | null}`, possibly spanning lines. Tokens are only kept + * when the closing brace is found, so a stray `{` doesn't colour the rest + * of the comment as a type. + */ +function typeExpression(text: string, pos: number, end: number, push: Push) { + const tokens: [Type, number, number][] = [ + [Type.JSDocTypeBrace, pos, pos + 1], + ]; + const limit = Math.min(end, pos + MAX_TYPE_LENGTH); + let depth = 1; + let i = pos + 1; + + while (i < limit) { + const ch = text[i]; + if (isSpace(ch) || ch === "*") { + i++; + continue; + } + // Types never contain `@`; it starts the next tag, so `{` was unclosed. + if (ch === "@") break; + if (ch === "{") depth++; + if (ch === "}" && --depth === 0) { + tokens.push([Type.JSDocTypeBrace, i, i + 1]); + for (const [type, from, to] of tokens) push(type, from, to); + return i + 1; + } + const name = matchAt(IDENTIFIER, text, i); + if (name) { + tokens.push([Type.JSDocType, i, i + name.length]); + i += name.length; + continue; + } + if (ch === '"' || ch === "'") { + let close = i + 1; + while (close < limit && text[close] !== ch && text[close] !== "\n") { + close++; + } + if (text[close] !== ch) break; + tokens.push([Type.JSDocType, i, close + 1]); + i = close + 1; + continue; + } + tokens.push([Type.JSDocTypePunctuation, i, i + 1]); + i++; + } + return pos + 1; +} + +/** `[name=default]` after `@param`. */ +function optionalParam(text: string, pos: number, end: number, push: Push) { + push(Type.JSDocBracket, pos, pos + 1); + let i = pos + 1; + const name = matchAt(IDENTIFIER, text, i); + if (name) { + push(Type.JSDocParam, i, i + name.length); + i += name.length; + } + + let depth = 1; + while (i < end && text[i] !== "\n") { + if (text[i] === "[") depth++; + if (text[i] === "]" && --depth === 0) { + push(Type.JSDocBracket, i, i + 1); + return i + 1; + } + i++; + } + return i; +} + +// Comments are short, so the whole range is tokenized in one step. +class JSDocParse implements PartialParse { + parsedPos: number; + stoppedAt: number | null = null; + + constructor( + private input: Input, + private from: number, + private to: number, + ) { + this.parsedPos = from; + } + + advance(): Tree { + const { from, to } = this; + const buffer = tokenize(this.input.read(from, to)).map((value, index) => + index % 4 === 1 || index % 4 === 2 ? value + from : value, + ); + this.parsedPos = to; + return Tree.build({ + buffer, + nodeSet, + topID: Type.BlockComment, + start: from, + length: to - from, + }); + } + + stopAt(pos: number) { + this.stoppedAt = pos; + } +} + +class JSDocParser extends Parser { + createParse( + input: Input, + _fragments: readonly TreeFragment[], + ranges: readonly { from: number; to: number }[], + ): PartialParse { + return new JSDocParse(input, ranges[0].from, ranges[ranges.length - 1].to); + } +} + +export const jsdocParser = new JSDocParser(); + +const MAX_COMMENT_LENGTH = 100_000; + +/** Mounts the JSDoc parser on `/** ... *\/` block comments. */ +export const jsdocWrap: ParseWrapper = parseMixed((node, input) => { + if (node.name !== "BlockComment") return null; + // Every edit re-tokenizes the whole comment; leave huge ones plain. + if (node.to - node.from > MAX_COMMENT_LENGTH) return null; + const head = input.read(node.from, Math.min(node.from + 4, node.to)); + if (!head.startsWith("/**") || head === "/**/") return null; + return { parser: jsdocParser }; +}); + +/** Returns a copy of a JS/TS language support with JSDoc highlighting. */ +export function withJSDoc(support: LanguageSupport): LanguageSupport { + const language = support.language; + if (!(language instanceof LRLanguage)) return support; + return new LanguageSupport( + language.configure({ wrap: jsdocWrap }), + support.support, + ); +} diff --git a/src/cm/supportedModes.ts b/src/cm/supportedModes.ts index 44334dbc4..3aaec6493 100644 --- a/src/cm/supportedModes.ts +++ b/src/cm/supportedModes.ts @@ -67,9 +67,21 @@ function createLanguageLoader(name: string, lang: LanguageDescription) { switch (normalizedName) { case "javascript": + case "jsx": + case "typescript": + case "tsx": return async () => { - const { javascript } = await import("@codemirror/lang-javascript"); - return javascript({ jsx: true }); + const [{ javascript }, { withJSDoc }] = await Promise.all([ + import("@codemirror/lang-javascript"), + import("./modes/jsdoc"), + ]); + return withJSDoc( + javascript({ + jsx: normalizedName !== "typescript", + typescript: + normalizedName === "typescript" || normalizedName === "tsx", + }), + ); }; case "markdown": From 2f9333684b794507797ac6525d41ccda5dc5fbbd Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:20:39 +0530 Subject: [PATCH 2/4] feat(editor): colour JS/TS methods as functions and refine JSDoc highlighting - Tag class and interface method names as function definitions and give method calls, method definitions and function declaration names the function colour in all built-in themes. - Bump @lezer/highlight to 1.2.5, which fixes the precedence of context-specific highlight rules added through parser props. - Leave JSDoc braces and punctuation as comment text, handle escaped quotes in types and quoted brackets in optional defaults, and skip /*** comments like TypeScript does. - Add unit tests for JSDoc and method highlighting. --- bun.lock | 4 +- package-lock.json | 8 +- package.json | 2 +- src/cm/modes/javascript/index.ts | 27 +++ src/cm/modes/jsdoc/index.ts | 92 ++++---- src/cm/supportedModes.ts | 17 +- src/cm/themes/aura.js | 11 +- src/cm/themes/ayuDark.js | 11 +- src/cm/themes/catppuccin.js | 2 + src/cm/themes/cobalt.js | 9 +- src/cm/themes/dracula.js | 11 +- src/cm/themes/githubDark.js | 11 +- src/cm/themes/githubLight.js | 11 +- src/cm/themes/gruvboxDark.js | 11 +- src/cm/themes/index.js | 29 ++- src/cm/themes/materialPalenight.js | 11 +- src/cm/themes/monokai.js | 11 +- src/cm/themes/noctisLilac.js | 8 +- src/cm/themes/nord.js | 11 +- src/cm/themes/solarizedDark.js | 11 +- src/cm/themes/solarizedLight.js | 11 +- src/cm/themes/tokyoNight.js | 11 +- src/cm/themes/tokyoNightDay.js | 11 +- src/cm/themes/tomorrowNight.js | 11 +- src/cm/themes/tomorrowNightBright.js | 11 +- src/cm/themes/vscodeDark.js | 7 +- tests/unit/javascriptHighlighting.test.ts | 249 ++++++++++++++++++++++ 27 files changed, 528 insertions(+), 91 deletions(-) create mode 100644 src/cm/modes/javascript/index.ts create mode 100644 tests/unit/javascriptHighlighting.test.ts diff --git a/bun.lock b/bun.lock index a72716e32..5f2c0d565 100644 --- a/bun.lock +++ b/bun.lock @@ -40,7 +40,7 @@ "@deadlyjack/ajax": "^1.2.6", "@emmetio/codemirror6-plugin": "^0.4.1", "@lezer/common": "^1.5.2", - "@lezer/highlight": "^1.2.3", + "@lezer/highlight": "^1.2.5", "@lezer/lr": "^1.4.10", "@ungap/custom-elements": "^1.3.0", "@xterm/addon-attach": "^0.12.0", @@ -485,7 +485,7 @@ "@lezer/go": ["@lezer/go@1.0.1", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ=="], - "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], + "@lezer/highlight": ["@lezer/highlight@1.2.5", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w=="], "@lezer/html": ["@lezer/html@1.3.13", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="], diff --git a/package-lock.json b/package-lock.json index b702bafd7..41fccea23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "@deadlyjack/ajax": "^1.2.6", "@emmetio/codemirror6-plugin": "^0.4.1", "@lezer/common": "^1.5.2", - "@lezer/highlight": "^1.2.3", + "@lezer/highlight": "^1.2.5", "@lezer/lr": "^1.4.10", "@ungap/custom-elements": "^1.3.0", "@xterm/addon-attach": "^0.12.0", @@ -4335,9 +4335,9 @@ } }, "node_modules/@lezer/highlight": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", - "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz", + "integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==", "license": "MIT", "dependencies": { "@lezer/common": "^1.3.0" diff --git a/package.json b/package.json index 73c831007..aebe3e172 100644 --- a/package.json +++ b/package.json @@ -160,7 +160,7 @@ "@deadlyjack/ajax": "^1.2.6", "@emmetio/codemirror6-plugin": "^0.4.1", "@lezer/common": "^1.5.2", - "@lezer/highlight": "^1.2.3", + "@lezer/highlight": "^1.2.5", "@lezer/lr": "^1.4.10", "@ungap/custom-elements": "^1.3.0", "@xterm/addon-attach": "^0.12.0", diff --git a/src/cm/modes/javascript/index.ts b/src/cm/modes/javascript/index.ts new file mode 100644 index 000000000..91f170a39 --- /dev/null +++ b/src/cm/modes/javascript/index.ts @@ -0,0 +1,27 @@ +import { javascript } from "@codemirror/lang-javascript"; +import { LanguageSupport, LRLanguage } from "@codemirror/language"; +import { styleTags, tags as t } from "@lezer/highlight"; +import { jsdocWrap } from "../jsdoc"; + +// Lezer tags class and interface method names like property definitions; +// tag them as functions so themes can colour methods like other editors do. +const methodNames = styleTags({ + "MethodDeclaration/PropertyDefinition MethodDeclaration/PrivatePropertyDefinition MethodType/PropertyDefinition": + t.function(t.definition(t.propertyName)), +}); + +/** + * JavaScript/TypeScript support with JSDoc highlighting inside `/** *\/` + * comments and method names tagged as functions. + */ +export function javascriptSupport( + config: { jsx?: boolean; typescript?: boolean } = {}, +): LanguageSupport { + const support = javascript(config); + const language = support.language; + if (!(language instanceof LRLanguage)) return support; + return new LanguageSupport( + language.configure({ props: [methodNames], wrap: jsdocWrap }), + support.support, + ); +} diff --git a/src/cm/modes/jsdoc/index.ts b/src/cm/modes/jsdoc/index.ts index ddc6cc0e5..f6935bd83 100644 --- a/src/cm/modes/jsdoc/index.ts +++ b/src/cm/modes/jsdoc/index.ts @@ -1,9 +1,4 @@ -import { - foldNodeProp, - indentNodeProp, - LanguageSupport, - LRLanguage, -} from "@codemirror/language"; +import { foldNodeProp, indentNodeProp } from "@codemirror/language"; import { type Input, NodeSet, @@ -20,7 +15,8 @@ import { styleTags, tags as t } from "@lezer/highlight"; /** * A tiny JSDoc tokenizer mounted inside JS/TS `/** ... *\/` comments so * themes can style block tags, types, parameter names and inline links - * separately from the comment text. + * separately from the comment text. Braces and other punctuation are left + * as comment text, like VS Code does. * * The mounted tree replaces the host `BlockComment` node, so its root keeps * that name (comment checks in completion and rainbow brackets still match) @@ -33,33 +29,20 @@ enum Type { BlockComment, JSDocTag, JSDocType, - JSDocTypeBrace, - JSDocTypePunctuation, JSDocParam, - JSDocBracket, JSDocLink, } const nodeSet = new NodeSet( - [ - "BlockComment", - "JSDocTag", - "JSDocType", - "JSDocTypeBrace", - "JSDocTypePunctuation", - "JSDocParam", - "JSDocBracket", - "JSDocLink", - ].map((name, id) => NodeType.define({ id, name })), + ["BlockComment", "JSDocTag", "JSDocType", "JSDocParam", "JSDocLink"].map( + (name, id) => NodeType.define({ id, name }), + ), ).extend( styleTags({ BlockComment: t.docComment, JSDocTag: t.keyword, JSDocType: t.typeName, - JSDocTypeBrace: t.brace, - JSDocTypePunctuation: t.punctuation, JSDocParam: t.variableName, - JSDocBracket: t.squareBracket, JSDocLink: t.link, }), foldNodeProp.add({ @@ -87,6 +70,21 @@ function isSpace(ch: string | undefined): boolean { return ch === " " || ch === "\t" || ch === "\r" || ch === "\n"; } +/** + * Index just past the string literal opening at `pos`, honouring backslash + * escapes, or -1 when it isn't closed on the same line before `limit`. + */ +function skipQuoted(text: string, pos: number, limit: number): number { + const quote = text[pos]; + for (let i = pos + 1; i < limit; i++) { + const ch = text[i]; + if (ch === "\\") i++; + else if (ch === quote) return i + 1; + else if (ch === "\n") return -1; + } + return -1; +} + function tokenize(text: string): number[] { // Flat [type, from, to, size] groups, as expected by Tree.build. const buffer: number[] = []; @@ -201,7 +199,6 @@ type Push = (type: Type, from: number, to: number) => void; /** `{@link Target|label}` and friends; `close` is the matching `}`. */ function inlineTag(text: string, pos: number, close: number, push: Push) { - push(Type.JSDocTypeBrace, pos, pos + 1); let i = pos + 1; const tag = matchAt(TAG_NAME, text, i); if (tag) { @@ -213,7 +210,6 @@ function inlineTag(text: string, pos: number, close: number, push: Push) { push(Type.JSDocLink, i, i + target.length); } } - push(Type.JSDocTypeBrace, close, close + 1); return close + 1; } @@ -226,9 +222,7 @@ const MAX_TYPE_LENGTH = 500; * of the comment as a type. */ function typeExpression(text: string, pos: number, end: number, push: Push) { - const tokens: [Type, number, number][] = [ - [Type.JSDocTypeBrace, pos, pos + 1], - ]; + const tokens: [Type, number, number][] = []; const limit = Math.min(end, pos + MAX_TYPE_LENGTH); let depth = 1; let i = pos + 1; @@ -243,7 +237,6 @@ function typeExpression(text: string, pos: number, end: number, push: Push) { if (ch === "@") break; if (ch === "{") depth++; if (ch === "}" && --depth === 0) { - tokens.push([Type.JSDocTypeBrace, i, i + 1]); for (const [type, from, to] of tokens) push(type, from, to); return i + 1; } @@ -254,16 +247,12 @@ function typeExpression(text: string, pos: number, end: number, push: Push) { continue; } if (ch === '"' || ch === "'") { - let close = i + 1; - while (close < limit && text[close] !== ch && text[close] !== "\n") { - close++; - } - if (text[close] !== ch) break; - tokens.push([Type.JSDocType, i, close + 1]); - i = close + 1; + const close = skipQuoted(text, i, limit); + if (close === -1) break; + tokens.push([Type.JSDocType, i, close]); + i = close; continue; } - tokens.push([Type.JSDocTypePunctuation, i, i + 1]); i++; } return pos + 1; @@ -271,7 +260,6 @@ function typeExpression(text: string, pos: number, end: number, push: Push) { /** `[name=default]` after `@param`. */ function optionalParam(text: string, pos: number, end: number, push: Push) { - push(Type.JSDocBracket, pos, pos + 1); let i = pos + 1; const name = matchAt(IDENTIFIER, text, i); if (name) { @@ -279,13 +267,18 @@ function optionalParam(text: string, pos: number, end: number, push: Push) { i += name.length; } + // Find the closing `]`, ignoring brackets inside quoted defaults. let depth = 1; while (i < end && text[i] !== "\n") { - if (text[i] === "[") depth++; - if (text[i] === "]" && --depth === 0) { - push(Type.JSDocBracket, i, i + 1); - return i + 1; + const ch = text[i]; + if (ch === '"' || ch === "'") { + const close = skipQuoted(text, i, end); + if (close === -1) return i; + i = close; + continue; } + if (ch === "[") depth++; + if (ch === "]" && --depth === 0) return i + 1; i++; } return i; @@ -343,17 +336,10 @@ export const jsdocWrap: ParseWrapper = parseMixed((node, input) => { if (node.name !== "BlockComment") return null; // Every edit re-tokenizes the whole comment; leave huge ones plain. if (node.to - node.from > MAX_COMMENT_LENGTH) return null; + // Like TypeScript, only `/**` not followed by `*` or `/` is JSDoc. const head = input.read(node.from, Math.min(node.from + 4, node.to)); - if (!head.startsWith("/**") || head === "/**/") return null; + if (!head.startsWith("/**") || head[3] === "*" || head[3] === "/") { + return null; + } return { parser: jsdocParser }; }); - -/** Returns a copy of a JS/TS language support with JSDoc highlighting. */ -export function withJSDoc(support: LanguageSupport): LanguageSupport { - const language = support.language; - if (!(language instanceof LRLanguage)) return support; - return new LanguageSupport( - language.configure({ wrap: jsdocWrap }), - support.support, - ); -} diff --git a/src/cm/supportedModes.ts b/src/cm/supportedModes.ts index 3aaec6493..12febc9f9 100644 --- a/src/cm/supportedModes.ts +++ b/src/cm/supportedModes.ts @@ -71,17 +71,12 @@ function createLanguageLoader(name: string, lang: LanguageDescription) { case "typescript": case "tsx": return async () => { - const [{ javascript }, { withJSDoc }] = await Promise.all([ - import("@codemirror/lang-javascript"), - import("./modes/jsdoc"), - ]); - return withJSDoc( - javascript({ - jsx: normalizedName !== "typescript", - typescript: - normalizedName === "typescript" || normalizedName === "tsx", - }), - ); + const { javascriptSupport } = await import("./modes/javascript"); + return javascriptSupport({ + jsx: normalizedName !== "typescript", + typescript: + normalizedName === "typescript" || normalizedName === "tsx", + }); }; case "markdown": diff --git a/src/cm/themes/aura.js b/src/cm/themes/aura.js index fdbe82456..1b7729bba 100644 --- a/src/cm/themes/aura.js +++ b/src/cm/themes/aura.js @@ -119,7 +119,16 @@ export const auraHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/ayuDark.js b/src/cm/themes/ayuDark.js index b7629eaae..b06a78240 100644 --- a/src/cm/themes/ayuDark.js +++ b/src/cm/themes/ayuDark.js @@ -101,7 +101,16 @@ export const ayuDarkHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/catppuccin.js b/src/cm/themes/catppuccin.js index 6b1e9a533..d274a3eb5 100644 --- a/src/cm/themes/catppuccin.js +++ b/src/cm/themes/catppuccin.js @@ -225,6 +225,8 @@ function createCatppuccinTheme(config) { { tag: [ t.function(t.variableName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), t.function(t.propertyName), t.propertyName, t.labelName, diff --git a/src/cm/themes/cobalt.js b/src/cm/themes/cobalt.js index 6d23c56ea..870c5b83b 100644 --- a/src/cm/themes/cobalt.js +++ b/src/cm/themes/cobalt.js @@ -92,7 +92,14 @@ export const cobaltHighlightStyle = HighlightStyle.define([ color: config.variable, }, { - tag: [t.propertyName, t.function(t.variableName), t.labelName], + tag: [ + t.propertyName, + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], color: config.function, }, { diff --git a/src/cm/themes/dracula.js b/src/cm/themes/dracula.js index 8a02c06cb..994057f49 100644 --- a/src/cm/themes/dracula.js +++ b/src/cm/themes/dracula.js @@ -116,7 +116,16 @@ export const draculaHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/githubDark.js b/src/cm/themes/githubDark.js index e1dcf7653..9687fe815 100644 --- a/src/cm/themes/githubDark.js +++ b/src/cm/themes/githubDark.js @@ -119,7 +119,16 @@ export const githubDarkHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/githubLight.js b/src/cm/themes/githubLight.js index e05b03bb2..83d78888d 100644 --- a/src/cm/themes/githubLight.js +++ b/src/cm/themes/githubLight.js @@ -119,7 +119,16 @@ export const githubLightHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/gruvboxDark.js b/src/cm/themes/gruvboxDark.js index e86960d8c..06ae5a1c5 100644 --- a/src/cm/themes/gruvboxDark.js +++ b/src/cm/themes/gruvboxDark.js @@ -102,7 +102,16 @@ export const gruvboxDarkHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/index.js b/src/cm/themes/index.js index dbcd67eba..71c95dc1e 100644 --- a/src/cm/themes/index.js +++ b/src/cm/themes/index.js @@ -1,5 +1,11 @@ +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorState } from "@codemirror/state"; -import { oneDark } from "@codemirror/theme-one-dark"; +import { + oneDark, + oneDarkHighlightStyle, + oneDarkTheme, +} from "@codemirror/theme-one-dark"; +import { tags as t } from "@lezer/highlight"; import aura, { config as auraConfig } from "./aura"; import ayuDark, { config as ayuDarkConfig } from "./ayuDark"; import { @@ -47,6 +53,25 @@ const oneDarkConfig = { invalid: "#ff6b6b", }; +// One Dark with method calls, method definitions and function declaration +// names in the function colour, matching the other built-in themes. +const oneDarkExtensions = [ + oneDarkTheme, + syntaxHighlighting( + HighlightStyle.define([ + ...oneDarkHighlightStyle.specs, + { + tag: [ + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + ], + color: oneDarkConfig.function, + }, + ]), + ), +]; + const themes = new Map(); const warnedInvalidThemes = new Set(); @@ -164,7 +189,7 @@ export function removeTheme(id) { themes.delete(String(id).toLowerCase()); } -addTheme("one_dark", "One Dark", true, () => [oneDark], oneDarkConfig); +addTheme("one_dark", "One Dark", true, () => oneDarkExtensions, oneDarkConfig); addTheme(auraConfig.name, "Aura", !!auraConfig.dark, () => aura(), auraConfig); addTheme( cobaltConfig.name, diff --git a/src/cm/themes/materialPalenight.js b/src/cm/themes/materialPalenight.js index 2063e88d7..11fc81f25 100644 --- a/src/cm/themes/materialPalenight.js +++ b/src/cm/themes/materialPalenight.js @@ -101,7 +101,16 @@ export const materialPalenightHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/monokai.js b/src/cm/themes/monokai.js index e43e2969d..43427a0dc 100644 --- a/src/cm/themes/monokai.js +++ b/src/cm/themes/monokai.js @@ -123,7 +123,16 @@ export const monokaiHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/noctisLilac.js b/src/cm/themes/noctisLilac.js index c32479274..eb605e084 100644 --- a/src/cm/themes/noctisLilac.js +++ b/src/cm/themes/noctisLilac.js @@ -117,7 +117,13 @@ export const noctisLilacHighlightStyle = HighlightStyle.define([ }, { tag: [t.number, t.bool, t.null, t.special(t.brace)], color: config.number }, { - tag: [t.definition(t.propertyName), t.function(t.variableName)], + tag: [ + t.definition(t.propertyName), + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + ], color: config.function, }, { tag: t.typeName, color: config.type }, diff --git a/src/cm/themes/nord.js b/src/cm/themes/nord.js index 60e85cb13..d387d73df 100644 --- a/src/cm/themes/nord.js +++ b/src/cm/themes/nord.js @@ -101,7 +101,16 @@ export const nordHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/solarizedDark.js b/src/cm/themes/solarizedDark.js index 088a200a6..168c8b187 100644 --- a/src/cm/themes/solarizedDark.js +++ b/src/cm/themes/solarizedDark.js @@ -118,7 +118,16 @@ export const solarizedDarkHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/solarizedLight.js b/src/cm/themes/solarizedLight.js index 01dd678f5..742918aa9 100644 --- a/src/cm/themes/solarizedLight.js +++ b/src/cm/themes/solarizedLight.js @@ -118,7 +118,16 @@ export const solarizedLightHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/tokyoNight.js b/src/cm/themes/tokyoNight.js index 3ec2548e7..4b5f477d3 100644 --- a/src/cm/themes/tokyoNight.js +++ b/src/cm/themes/tokyoNight.js @@ -119,7 +119,16 @@ export const tokyoNightHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/tokyoNightDay.js b/src/cm/themes/tokyoNightDay.js index 75a9fcf82..b588cd497 100644 --- a/src/cm/themes/tokyoNightDay.js +++ b/src/cm/themes/tokyoNightDay.js @@ -116,7 +116,16 @@ export const tokyoNightDayHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/tomorrowNight.js b/src/cm/themes/tomorrowNight.js index 0ff0d39c0..6536b705e 100644 --- a/src/cm/themes/tomorrowNight.js +++ b/src/cm/themes/tomorrowNight.js @@ -124,7 +124,16 @@ export const tomorrowNightHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/tomorrowNightBright.js b/src/cm/themes/tomorrowNightBright.js index 26bfacf9d..ba76c31e2 100644 --- a/src/cm/themes/tomorrowNightBright.js +++ b/src/cm/themes/tomorrowNightBright.js @@ -124,7 +124,16 @@ export const tomorrowNightBrightHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/vscodeDark.js b/src/cm/themes/vscodeDark.js index 8790b32b1..a6107d944 100644 --- a/src/cm/themes/vscodeDark.js +++ b/src/cm/themes/vscodeDark.js @@ -161,7 +161,12 @@ export const vscodeDarkHighlightStyle = HighlightStyle.define([ { tag: [t.typeName, t.className], color: config.type }, { tag: [t.tagName, t.standard(t.tagName)], color: config.keyword }, { - tag: [t.function(t.variableName), t.function(t.propertyName)], + tag: [ + t.function(t.variableName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.function(t.propertyName), + ], color: config.function, }, { diff --git a/tests/unit/javascriptHighlighting.test.ts b/tests/unit/javascriptHighlighting.test.ts new file mode 100644 index 000000000..dde38bc07 --- /dev/null +++ b/tests/unit/javascriptHighlighting.test.ts @@ -0,0 +1,249 @@ +import { + ensureSyntaxTree, + foldable, + getIndentation, + highlightingFor, +} from "@codemirror/language"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { highlightTree, type Tag, tags as t } from "@lezer/highlight"; +import { javascriptSupport } from "cm/modes/javascript"; +import { getThemeExtensions, getThemes } from "cm/themes"; +import { describe, expect, it } from "vitest"; + +type Config = { jsx?: boolean; typescript?: boolean }; + +function createState(doc: string, config: Config = {}) { + return EditorState.create({ doc, extensions: javascriptSupport(config) }); +} + +/** Highlighted ranges as `[tags, text, from]`, skipping whitespace. */ +function highlights(state: EditorState) { + const doc = state.doc.toString(); + const tree = ensureSyntaxTree(state, doc.length, 5000); + if (!tree) throw new Error("parse did not finish"); + const out: [string, string, number][] = []; + highlightTree( + tree, + { style: (tags) => tags.map(String).join(" ") }, + (from, to, tags) => { + const text = doc.slice(from, to); + if (text.trim()) out.push([tags, text, from]); + }, + ); + return out; +} + +/** Texts highlighted with exactly `tags`. */ +function textsWith(state: EditorState, tags: string) { + return highlights(state) + .filter(([style]) => style === tags) + .map(([, text]) => text); +} + +const jsdoc = `const before = 1; +/** + * Adds numbers, mail me@example.com. + * @param {number} a - first + * @param {Array<{ + * id: string + * }> | null} list - multiline type + * @param {string} [text="["] quoted bracket, see {@link Foo.bar|bar} + * @param {'don\\'t' | "a\\"b"} quoted escaped quotes + * @returns {Promise} + * @typedef {Object} Shape + * @template T + */ +function add(a, list, text, quoted) {}`; + +describe.each([ + ["JavaScript", { jsx: true }], + ["TypeScript", { typescript: true }], +])("JSDoc highlighting in %s", (_name, config) => { + const state = createState(jsdoc, config); + + it("highlights block tags, types, parameter names and links", () => { + expect(textsWith(state, "keyword")).toEqual([ + "@param", + "@param", + "@param", + "@link", + "@param", + "@returns", + "@typedef", + "@template", + ]); + expect(textsWith(state, "typeName")).toEqual([ + "number", + "Array", + "id", + "string", + "null", + "string", + "'don\\'t'", + '"a\\"b"', + "Promise", + "void", + "Object", + "Shape", + "T", + ]); + expect(textsWith(state, "variableName")).toEqual([ + "a", + "list", + "text", + "quoted", + ]); + expect(textsWith(state, "link")).toEqual(["Foo.bar"]); + }); + + it("places ranges at the right document positions", () => { + const doc = state.doc.toString(); + for (const [, text, from] of highlights(state)) { + expect(doc.slice(from, from + text.length)).toBe(text); + } + const param = highlights(state).find(([, text]) => text === "@param"); + expect(param?.[2]).toBe(doc.indexOf("@param")); + }); + + it("leaves braces, punctuation and prose as doc comment text", () => { + const docText = textsWith(state, "docComment").join(" "); + expect(docText).toContain("me@example.com"); + expect(docText).toContain("{"); + expect(docText).toContain("["); + + const doc = state.doc.toString(); + const commentEnd = doc.indexOf("*/") + 2; + const inComment = highlights(state).filter( + ([, , from]) => from >= doc.indexOf("/**") && from < commentEnd, + ); + const commentTags = new Set(inComment.map(([tags]) => tags)); + expect([...commentTags].sort()).toEqual([ + "docComment", + "keyword", + "link", + "typeName", + "variableName", + ]); + }); + + it("keeps comment behaviour from the host language", () => { + const inside = state.doc.toString().indexOf("@returns"); + expect(state.languageDataAt("commentTokens", inside)).toEqual([ + { line: "//", block: { open: "/*", close: "*/" } }, + ]); + expect(getIndentation(state, state.doc.line(4).from)).toBeNull(); + const line = state.doc.line(2); + expect(foldable(state, line.from, line.to)).toEqual({ + from: line.from + 2, + to: state.doc.toString().indexOf("*/", line.from), + }); + }); +}); + +describe("JSDoc highlighting edge cases", () => { + it("leaves plain block comments alone", () => { + const state = createState( + "/* @param {T} a */ /**/ /*** @param {T} b */ let x;", + ); + expect(textsWith(state, "keyword")).toEqual([]); + expect(textsWith(state, "blockComment")).toEqual([ + "/* @param {T} a */", + "/**/", + "/*** @param {T} b */", + ]); + }); + + it("does not colour the rest of the comment after an unclosed type", () => { + const state = createState("/** @returns { unclosed\n * prose words */"); + expect(textsWith(state, "keyword")).toEqual(["@returns"]); + expect(textsWith(state, "typeName")).toEqual([]); + }); + + it("updates highlighting after an edit", () => { + let state = createState("/**\n * @param {string} a\n */\nlet x;"); + expect(textsWith(state, "typeName")).toEqual(["string"]); + + const at = state.doc.toString().indexOf("string"); + state = state.update({ + changes: { from: at, to: at + "string".length, insert: "number" }, + }).state; + expect(textsWith(state, "typeName")).toEqual(["number"]); + + state = state.update({ + changes: { from: 0, insert: "/** @see {@link Q} */\n" }, + }).state; + expect(textsWith(state, "link")).toEqual(["Q"]); + expect(textsWith(state, "typeName")).toEqual(["number"]); + }); +}); + +describe("method names", () => { + const fnDefProp = "function(definition(propertyName))"; + + it("tags class and interface methods as function definitions", () => { + const state = createState( + `class A { + constructor(a) {} + get size() { return 1 } + static create() {} + #secret() {} + field = 1; +} +interface I { draw(): void; area: number }`, + { typescript: true }, + ); + expect(textsWith(state, fnDefProp)).toEqual([ + "constructor", + "size", + "create", + "#secret", + "draw", + ]); + expect(textsWith(state, "definition(propertyName)")).toEqual([ + "field", + "area", + ]); + }); + + it("keeps method calls tagged as function properties", () => { + const state = createState("options.uri.hashCode();"); + expect(textsWith(state, "function(propertyName)")).toEqual(["hashCode"]); + expect(textsWith(state, "propertyName")).toEqual(["uri"]); + }); +}); + +describe("built-in theme function colours", () => { + const functionTags = [ + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + ]; + + /** The CSS colour a theme gives `tag`. */ + function colorOf(state: EditorState, tag: Tag) { + const classes = highlightingFor(state, [tag]); + if (!classes) return null; + const rules = state + .facet(EditorView.styleModule) + .map((module) => module.getRules()) + .join("\n"); + for (const cls of classes.split(" ")) { + const rule = rules.match(new RegExp(`\\.${cls}\\s*\\{([^}]*)\\}`)); + const color = rule?.[1].match(/(?:^|;)\s*color:\s*([^;]+)/); + if (color) return color[1].trim(); + } + return null; + } + + it.each( + getThemes().map((theme) => [theme.id]), + )("%s colours methods like functions", (id) => { + const state = EditorState.create({ extensions: getThemeExtensions(id) }); + const expected = colorOf(state, t.function(t.variableName)); + expect(expected).toBeTruthy(); + for (const tag of functionTags) { + expect(colorOf(state, tag)).toBe(expected); + } + }); +}); From 3654241980ca13b85ecaca9088f5920af853ffb7 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:37:45 +0530 Subject: [PATCH 3/4] fix(themes): colour properties differently from methods Themes that reused the function colour for properties made this.foo look like a method call. Give properties the palette's property colour where it has one (Tokyo Night, Catppuccin) and the foreground otherwise, and stop colouring Noctis Lilac property definitions as functions. --- src/cm/themes/aura.js | 3 ++- src/cm/themes/catppuccin.js | 3 ++- src/cm/themes/cobalt.js | 3 ++- src/cm/themes/dracula.js | 3 ++- src/cm/themes/monokai.js | 3 ++- src/cm/themes/noctisLilac.js | 1 - src/cm/themes/nord.js | 4 +++- src/cm/themes/solarizedDark.js | 3 ++- src/cm/themes/solarizedLight.js | 3 ++- src/cm/themes/tokyoNight.js | 3 ++- src/cm/themes/tokyoNightDay.js | 3 ++- src/cm/themes/tomorrowNight.js | 3 ++- src/cm/themes/tomorrowNightBright.js | 3 ++- tests/unit/javascriptHighlighting.test.ts | 9 +++++++++ 14 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/cm/themes/aura.js b/src/cm/themes/aura.js index 1b7729bba..d1c7240f4 100644 --- a/src/cm/themes/aura.js +++ b/src/cm/themes/aura.js @@ -21,6 +21,7 @@ export const config = { variable: "#edecee", parameter: "#edecee", function: "#ffca85", + property: "#edecee", string: "#61ffca", constant: "#61ffca", type: "#82e2ff", @@ -114,7 +115,7 @@ export const auraHighlightStyle = HighlightStyle.define([ tag: [t.name, t.deleted, t.character, t.macroName], color: config.variable, }, - { tag: [t.propertyName], color: config.function }, + { tag: [t.propertyName], color: config.property }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/src/cm/themes/catppuccin.js b/src/cm/themes/catppuccin.js index d274a3eb5..529bbd1da 100644 --- a/src/cm/themes/catppuccin.js +++ b/src/cm/themes/catppuccin.js @@ -122,6 +122,7 @@ function createConfig(name, caption, palette) { variable: palette.text, parameter: palette.text, function: palette.blue, + property: palette.lavender, string: palette.green, constant: palette.peach, type: palette.yellow, @@ -228,11 +229,11 @@ function createCatppuccinTheme(config) { t.function(t.definition(t.propertyName)), t.function(t.definition(t.variableName)), t.function(t.propertyName), - t.propertyName, t.labelName, ], color: config.function, }, + { tag: [t.propertyName], color: config.property }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/cobalt.js b/src/cm/themes/cobalt.js index 870c5b83b..af9d805c1 100644 --- a/src/cm/themes/cobalt.js +++ b/src/cm/themes/cobalt.js @@ -20,6 +20,7 @@ export const config = { variable: "#ffffff", parameter: "#ffffff", function: "#ffc600", + property: "#ffffff", string: "#3ad900", constant: "#ff628c", type: "#80ffbb", @@ -93,7 +94,6 @@ export const cobaltHighlightStyle = HighlightStyle.define([ }, { tag: [ - t.propertyName, t.function(t.variableName), t.function(t.propertyName), t.function(t.definition(t.propertyName)), @@ -102,6 +102,7 @@ export const cobaltHighlightStyle = HighlightStyle.define([ ], color: config.function, }, + { tag: [t.propertyName], color: config.property }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/src/cm/themes/dracula.js b/src/cm/themes/dracula.js index 994057f49..fc662d772 100644 --- a/src/cm/themes/dracula.js +++ b/src/cm/themes/dracula.js @@ -20,6 +20,7 @@ export const config = { variable: "#F8F8F2", parameter: "#F8F8F2", function: "#50FA7B", + property: "#F8F8F2", string: "#F1FA8C", constant: "#BD93F9", type: "#8BE9FD", @@ -111,7 +112,7 @@ export const draculaHighlightStyle = HighlightStyle.define([ tag: [t.name, t.deleted, t.character, t.macroName], color: config.variable, }, - { tag: [t.propertyName], color: config.function }, + { tag: [t.propertyName], color: config.property }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/src/cm/themes/monokai.js b/src/cm/themes/monokai.js index 43427a0dc..124bafd65 100644 --- a/src/cm/themes/monokai.js +++ b/src/cm/themes/monokai.js @@ -20,6 +20,7 @@ export const config = { variable: "#FD971F", parameter: "#FD971F", function: "#66D9EF", + property: "#f8f8f2", string: "#E6DB74", constant: "#AE81FF", type: "#66D9EF", @@ -118,7 +119,7 @@ export const monokaiHighlightStyle = HighlightStyle.define([ tag: [t.name, t.deleted, t.character, t.macroName], color: config.variable, }, - { tag: [t.propertyName], color: config.function }, + { tag: [t.propertyName], color: config.property }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/src/cm/themes/noctisLilac.js b/src/cm/themes/noctisLilac.js index eb605e084..35fc3259a 100644 --- a/src/cm/themes/noctisLilac.js +++ b/src/cm/themes/noctisLilac.js @@ -118,7 +118,6 @@ export const noctisLilacHighlightStyle = HighlightStyle.define([ { tag: [t.number, t.bool, t.null, t.special(t.brace)], color: config.number }, { tag: [ - t.definition(t.propertyName), t.function(t.variableName), t.function(t.propertyName), t.function(t.definition(t.propertyName)), diff --git a/src/cm/themes/nord.js b/src/cm/themes/nord.js index d387d73df..1b0212a2c 100644 --- a/src/cm/themes/nord.js +++ b/src/cm/themes/nord.js @@ -19,6 +19,7 @@ export const config = { variable: "#d8dee9", parameter: "#d8dee9", function: "#8fbcbb", + property: "#d8dee9", string: "#a3be8c", constant: "#b48ead", type: "#88c0d0", @@ -96,7 +97,8 @@ export const nordHighlightStyle = HighlightStyle.define([ tag: [t.name, t.deleted, t.character, t.macroName], color: config.variable, }, - { tag: [t.propertyName, t.attributeName], color: config.function }, + { tag: [t.attributeName], color: config.function }, + { tag: [t.propertyName], color: config.property }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/src/cm/themes/solarizedDark.js b/src/cm/themes/solarizedDark.js index 168c8b187..02d6bc412 100644 --- a/src/cm/themes/solarizedDark.js +++ b/src/cm/themes/solarizedDark.js @@ -21,6 +21,7 @@ export const config = { variable: "#268BD2", parameter: "#268BD2", function: "#268BD2", + property: "#839496", string: "#2AA198", constant: "#CB4B16", type: "#859900", @@ -113,7 +114,7 @@ export const solarizedDarkHighlightStyle = HighlightStyle.define([ tag: [t.name, t.deleted, t.character, t.macroName], color: config.variable, }, - { tag: [t.propertyName], color: config.function }, + { tag: [t.propertyName], color: config.property }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/src/cm/themes/solarizedLight.js b/src/cm/themes/solarizedLight.js index 742918aa9..b35fbfede 100644 --- a/src/cm/themes/solarizedLight.js +++ b/src/cm/themes/solarizedLight.js @@ -21,6 +21,7 @@ export const config = { variable: "#268BD2", parameter: "#268BD2", function: "#268BD2", + property: "#657B83", string: "#2AA198", constant: "#CB4B16", type: "#859900", @@ -113,7 +114,7 @@ export const solarizedLightHighlightStyle = HighlightStyle.define([ tag: [t.name, t.deleted, t.character, t.macroName], color: config.variable, }, - { tag: [t.propertyName], color: config.function }, + { tag: [t.propertyName], color: config.property }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/src/cm/themes/tokyoNight.js b/src/cm/themes/tokyoNight.js index 4b5f477d3..caad6ec4f 100644 --- a/src/cm/themes/tokyoNight.js +++ b/src/cm/themes/tokyoNight.js @@ -21,6 +21,7 @@ export const config = { variable: "#c0caf5", parameter: "#cfc9c2", function: "#7aa2f7", + property: "#73daca", string: "#9ece6a", constant: "#ff9e64", type: "#2ac3de", @@ -114,7 +115,7 @@ export const tokyoNightHighlightStyle = HighlightStyle.define([ tag: [t.name, t.deleted, t.character, t.macroName], color: config.variable, }, - { tag: [t.propertyName], color: config.function }, + { tag: [t.propertyName], color: config.property }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/src/cm/themes/tokyoNightDay.js b/src/cm/themes/tokyoNightDay.js index b588cd497..a001f1310 100644 --- a/src/cm/themes/tokyoNightDay.js +++ b/src/cm/themes/tokyoNightDay.js @@ -20,6 +20,7 @@ export const config = { variable: "#3760bf", parameter: "#3760bf", function: "#2e7de9", + property: "#387068", string: "#587539", constant: "#9854f1", type: "#07879d", @@ -111,7 +112,7 @@ export const tokyoNightDayHighlightStyle = HighlightStyle.define([ tag: [t.name, t.deleted, t.character, t.macroName], color: config.variable, }, - { tag: [t.propertyName], color: config.function }, + { tag: [t.propertyName], color: config.property }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/src/cm/themes/tomorrowNight.js b/src/cm/themes/tomorrowNight.js index 6536b705e..232bb46ff 100644 --- a/src/cm/themes/tomorrowNight.js +++ b/src/cm/themes/tomorrowNight.js @@ -22,6 +22,7 @@ export const config = { variable: "#CC6666", parameter: "#DE935F", function: "#81A2BE", + property: "#C5C8C6", string: "#B5BD68", constant: "#DE935F", type: "#F0C674", @@ -119,7 +120,7 @@ export const tomorrowNightHighlightStyle = HighlightStyle.define([ tag: [t.name, t.deleted, t.character, t.macroName], color: config.variable, }, - { tag: [t.propertyName], color: config.function }, + { tag: [t.propertyName], color: config.property }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/src/cm/themes/tomorrowNightBright.js b/src/cm/themes/tomorrowNightBright.js index ba76c31e2..70b2fad12 100644 --- a/src/cm/themes/tomorrowNightBright.js +++ b/src/cm/themes/tomorrowNightBright.js @@ -22,6 +22,7 @@ export const config = { variable: "#D54E53", parameter: "#E78C45", function: "#7AA6DA", + property: "#DEDEDE", string: "#B9CA4A", constant: "#E78C45", type: "#E7C547", @@ -119,7 +120,7 @@ export const tomorrowNightBrightHighlightStyle = HighlightStyle.define([ tag: [t.name, t.deleted, t.character, t.macroName], color: config.variable, }, - { tag: [t.propertyName], color: config.function }, + { tag: [t.propertyName], color: config.property }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/tests/unit/javascriptHighlighting.test.ts b/tests/unit/javascriptHighlighting.test.ts index dde38bc07..924e78d4e 100644 --- a/tests/unit/javascriptHighlighting.test.ts +++ b/tests/unit/javascriptHighlighting.test.ts @@ -246,4 +246,13 @@ describe("built-in theme function colours", () => { expect(colorOf(state, tag)).toBe(expected); } }); + + it.each( + getThemes().map((theme) => [theme.id]), + )("%s colours properties differently from methods", (id) => { + const state = EditorState.create({ extensions: getThemeExtensions(id) }); + const method = colorOf(state, t.function(t.propertyName)); + expect(colorOf(state, t.propertyName)).not.toBe(method); + expect(colorOf(state, t.definition(t.propertyName))).not.toBe(method); + }); }); From d698b5a4734fe2fba3b0272a26fb9739b7c660d9 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:58:41 +0530 Subject: [PATCH 4/4] fix(themes): colour JS properties apart from methods without affecting other languages propertyName is shared by CSS properties, JSON/YAML keys, Go/C++ fields and (through attributeName) HTML attributes, so giving it its own colour moved those away from how VS Code colours them. Restore the themes' propertyName colour and tag JS/TS property names with memberProperty, a sub-tag of propertyName that falls back to it in themes that don't style it. Built-in themes colour it like their VS Code counterparts. --- src/cm/highlightTags.ts | 10 ++++++++++ src/cm/modes/javascript/index.ts | 15 ++++++++++----- src/cm/themes/aura.js | 9 +++++++-- src/cm/themes/ayuDark.js | 6 ++++++ src/cm/themes/catppuccin.js | 9 +++++++-- src/cm/themes/cobalt.js | 9 +++++++-- src/cm/themes/dracula.js | 9 +++++++-- src/cm/themes/githubDark.js | 6 ++++++ src/cm/themes/githubLight.js | 6 ++++++ src/cm/themes/materialPalenight.js | 6 ++++++ src/cm/themes/monokai.js | 9 +++++++-- src/cm/themes/noctisLilac.js | 7 +++++++ src/cm/themes/nord.js | 10 +++++++--- src/cm/themes/solarizedDark.js | 3 +-- src/cm/themes/solarizedLight.js | 3 +-- src/cm/themes/tokyoNight.js | 9 +++++++-- src/cm/themes/tokyoNightDay.js | 9 +++++++-- src/cm/themes/tomorrowNight.js | 9 +++++++-- src/cm/themes/tomorrowNightBright.js | 9 +++++++-- tests/unit/javascriptHighlighting.test.ts | 15 +++++++++------ 20 files changed, 132 insertions(+), 36 deletions(-) create mode 100644 src/cm/highlightTags.ts diff --git a/src/cm/highlightTags.ts b/src/cm/highlightTags.ts new file mode 100644 index 000000000..49b746d33 --- /dev/null +++ b/src/cm/highlightTags.ts @@ -0,0 +1,10 @@ +import { Tag, tags as t } from "@lezer/highlight"; + +/** + * Property names in JavaScript/TypeScript: `obj.foo`, object keys and class + * fields. It is a sub-tag of `propertyName`, so themes that don't style it + * keep their `propertyName` colour, and themes that do can colour JS + * properties apart from methods without changing CSS properties, JSON/YAML + * keys or other languages that share `propertyName`. + */ +export const memberProperty = Tag.define("memberProperty", t.propertyName); diff --git a/src/cm/modes/javascript/index.ts b/src/cm/modes/javascript/index.ts index 91f170a39..2098d61d6 100644 --- a/src/cm/modes/javascript/index.ts +++ b/src/cm/modes/javascript/index.ts @@ -1,18 +1,23 @@ import { javascript } from "@codemirror/lang-javascript"; import { LanguageSupport, LRLanguage } from "@codemirror/language"; import { styleTags, tags as t } from "@lezer/highlight"; +import { memberProperty } from "../../highlightTags"; import { jsdocWrap } from "../jsdoc"; -// Lezer tags class and interface method names like property definitions; -// tag them as functions so themes can colour methods like other editors do. -const methodNames = styleTags({ +const highlightTags = styleTags({ + // Lezer tags class and interface method names like property definitions; + // tag them as functions so themes can colour methods like other editors. "MethodDeclaration/PropertyDefinition MethodDeclaration/PrivatePropertyDefinition MethodType/PropertyDefinition": t.function(t.definition(t.propertyName)), + // Method calls keep their more specific `function(propertyName)` rule. + PropertyName: memberProperty, + PropertyDefinition: t.definition(memberProperty), }); /** * JavaScript/TypeScript support with JSDoc highlighting inside `/** *\/` - * comments and method names tagged as functions. + * comments, method names tagged as functions and property names tagged as + * {@link memberProperty}. */ export function javascriptSupport( config: { jsx?: boolean; typescript?: boolean } = {}, @@ -21,7 +26,7 @@ export function javascriptSupport( const language = support.language; if (!(language instanceof LRLanguage)) return support; return new LanguageSupport( - language.configure({ props: [methodNames], wrap: jsdocWrap }), + language.configure({ props: [highlightTags], wrap: jsdocWrap }), support.support, ); } diff --git a/src/cm/themes/aura.js b/src/cm/themes/aura.js index d1c7240f4..b3e94bc12 100644 --- a/src/cm/themes/aura.js +++ b/src/cm/themes/aura.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; // Aura theme configuration and extensions export const config = { @@ -21,7 +22,7 @@ export const config = { variable: "#edecee", parameter: "#edecee", function: "#ffca85", - property: "#edecee", + memberProperty: "#edecee", string: "#61ffca", constant: "#61ffca", type: "#82e2ff", @@ -110,12 +111,16 @@ export const auraTheme = EditorView.theme( ); export const auraHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], color: config.variable, }, - { tag: [t.propertyName], color: config.property }, + { tag: [t.propertyName], color: config.function }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/src/cm/themes/ayuDark.js b/src/cm/themes/ayuDark.js index b06a78240..cd62ab5fc 100644 --- a/src/cm/themes/ayuDark.js +++ b/src/cm/themes/ayuDark.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "ayuDark", @@ -19,6 +20,7 @@ export const config = { variable: "#b3b1ad", parameter: "#f07178", function: "#ffee99", + memberProperty: "#b3b1ad", string: "#c2d94c", constant: "#e6b450", type: "#ff8f40", @@ -91,6 +93,10 @@ export const ayuDarkTheme = EditorView.theme( ); export const ayuDarkHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], diff --git a/src/cm/themes/catppuccin.js b/src/cm/themes/catppuccin.js index 529bbd1da..7f9d0fb5f 100644 --- a/src/cm/themes/catppuccin.js +++ b/src/cm/themes/catppuccin.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; const palettes = { latte: { @@ -122,7 +123,7 @@ function createConfig(name, caption, palette) { variable: palette.text, parameter: palette.text, function: palette.blue, - property: palette.lavender, + memberProperty: palette.text, string: palette.green, constant: palette.peach, type: palette.yellow, @@ -218,6 +219,10 @@ function createCatppuccinTheme(config) { ); const highlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.definition(t.name), t.deleted, t.character, t.macroName], @@ -229,11 +234,11 @@ function createCatppuccinTheme(config) { t.function(t.definition(t.propertyName)), t.function(t.definition(t.variableName)), t.function(t.propertyName), + t.propertyName, t.labelName, ], color: config.function, }, - { tag: [t.propertyName], color: config.property }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/cobalt.js b/src/cm/themes/cobalt.js index af9d805c1..84ade32c1 100644 --- a/src/cm/themes/cobalt.js +++ b/src/cm/themes/cobalt.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "cobalt", @@ -20,7 +21,7 @@ export const config = { variable: "#ffffff", parameter: "#ffffff", function: "#ffc600", - property: "#ffffff", + memberProperty: "#ffffff", string: "#3ad900", constant: "#ff628c", type: "#80ffbb", @@ -86,6 +87,10 @@ export const cobaltTheme = EditorView.theme( ); export const cobaltHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.modifier, t.definitionKeyword], color: config.storage }, { @@ -94,6 +99,7 @@ export const cobaltHighlightStyle = HighlightStyle.define([ }, { tag: [ + t.propertyName, t.function(t.variableName), t.function(t.propertyName), t.function(t.definition(t.propertyName)), @@ -102,7 +108,6 @@ export const cobaltHighlightStyle = HighlightStyle.define([ ], color: config.function, }, - { tag: [t.propertyName], color: config.property }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/src/cm/themes/dracula.js b/src/cm/themes/dracula.js index fc662d772..f6e3cc6e6 100644 --- a/src/cm/themes/dracula.js +++ b/src/cm/themes/dracula.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView, lineNumbers } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "dracula", @@ -20,7 +21,7 @@ export const config = { variable: "#F8F8F2", parameter: "#F8F8F2", function: "#50FA7B", - property: "#F8F8F2", + memberProperty: "#F8F8F2", string: "#F1FA8C", constant: "#BD93F9", type: "#8BE9FD", @@ -107,12 +108,16 @@ export const draculaTheme = EditorView.theme( ); export const draculaHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], color: config.variable, }, - { tag: [t.propertyName], color: config.property }, + { tag: [t.propertyName], color: config.function }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/src/cm/themes/githubDark.js b/src/cm/themes/githubDark.js index 9687fe815..8ad2c8a4c 100644 --- a/src/cm/themes/githubDark.js +++ b/src/cm/themes/githubDark.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "githubDark", @@ -20,6 +21,7 @@ export const config = { variable: "#e6edf3", parameter: "#e6edf3", function: "#d2a8ff", + memberProperty: "#e6edf3", string: "#a5d6ff", constant: "#79c0ff", type: "#79c0ff", @@ -109,6 +111,10 @@ export const githubDarkTheme = EditorView.theme( ); export const githubDarkHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], diff --git a/src/cm/themes/githubLight.js b/src/cm/themes/githubLight.js index 83d78888d..8ea5e35b8 100644 --- a/src/cm/themes/githubLight.js +++ b/src/cm/themes/githubLight.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView, lineNumbers } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "githubLight", @@ -20,6 +21,7 @@ export const config = { variable: "#1f2328", parameter: "#1f2328", function: "#8250df", + memberProperty: "#1f2328", string: "#0a3069", constant: "#0550ae", type: "#0550ae", @@ -109,6 +111,10 @@ export const githubLightTheme = EditorView.theme( ); export const githubLightHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], diff --git a/src/cm/themes/materialPalenight.js b/src/cm/themes/materialPalenight.js index 11fc81f25..8247e4f28 100644 --- a/src/cm/themes/materialPalenight.js +++ b/src/cm/themes/materialPalenight.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "materialPalenight", @@ -19,6 +20,7 @@ export const config = { variable: "#f07178", parameter: "#eeffff", function: "#82aaff", + memberProperty: "#a6accd", string: "#c3e88d", constant: "#f78c6c", type: "#decb6b", @@ -91,6 +93,10 @@ export const materialPalenightTheme = EditorView.theme( ); export const materialPalenightHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], diff --git a/src/cm/themes/monokai.js b/src/cm/themes/monokai.js index 124bafd65..82365a6b3 100644 --- a/src/cm/themes/monokai.js +++ b/src/cm/themes/monokai.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "monokai", @@ -20,7 +21,7 @@ export const config = { variable: "#FD971F", parameter: "#FD971F", function: "#66D9EF", - property: "#f8f8f2", + memberProperty: "#f8f8f2", string: "#E6DB74", constant: "#AE81FF", type: "#66D9EF", @@ -114,12 +115,16 @@ export const monokaiTheme = EditorView.theme( ); export const monokaiHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], color: config.variable, }, - { tag: [t.propertyName], color: config.property }, + { tag: [t.propertyName], color: config.function }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/src/cm/themes/noctisLilac.js b/src/cm/themes/noctisLilac.js index 35fc3259a..c681f69f1 100644 --- a/src/cm/themes/noctisLilac.js +++ b/src/cm/themes/noctisLilac.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "noctisLilac", @@ -20,6 +21,7 @@ export const config = { variable: "#0c006b", parameter: "#0c006b", function: "#0095a8", + memberProperty: "#fa8900", string: "#00b368", constant: "#5842ff", type: "#b3694d", @@ -108,6 +110,10 @@ export const noctisLilacTheme = EditorView.theme( ); export const noctisLilacHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.comment, color: config.comment }, { tag: t.keyword, color: config.keyword, fontWeight: "bold" }, { tag: [t.definitionKeyword, t.modifier], color: config.keyword }, @@ -118,6 +124,7 @@ export const noctisLilacHighlightStyle = HighlightStyle.define([ { tag: [t.number, t.bool, t.null, t.special(t.brace)], color: config.number }, { tag: [ + t.definition(t.propertyName), t.function(t.variableName), t.function(t.propertyName), t.function(t.definition(t.propertyName)), diff --git a/src/cm/themes/nord.js b/src/cm/themes/nord.js index 1b0212a2c..97cf7c7bc 100644 --- a/src/cm/themes/nord.js +++ b/src/cm/themes/nord.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "nord", @@ -19,7 +20,7 @@ export const config = { variable: "#d8dee9", parameter: "#d8dee9", function: "#8fbcbb", - property: "#d8dee9", + memberProperty: "#d8dee9", string: "#a3be8c", constant: "#b48ead", type: "#88c0d0", @@ -92,13 +93,16 @@ export const nordTheme = EditorView.theme( ); export const nordHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], color: config.variable, }, - { tag: [t.attributeName], color: config.function }, - { tag: [t.propertyName], color: config.property }, + { tag: [t.propertyName, t.attributeName], color: config.function }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/src/cm/themes/solarizedDark.js b/src/cm/themes/solarizedDark.js index 02d6bc412..168c8b187 100644 --- a/src/cm/themes/solarizedDark.js +++ b/src/cm/themes/solarizedDark.js @@ -21,7 +21,6 @@ export const config = { variable: "#268BD2", parameter: "#268BD2", function: "#268BD2", - property: "#839496", string: "#2AA198", constant: "#CB4B16", type: "#859900", @@ -114,7 +113,7 @@ export const solarizedDarkHighlightStyle = HighlightStyle.define([ tag: [t.name, t.deleted, t.character, t.macroName], color: config.variable, }, - { tag: [t.propertyName], color: config.property }, + { tag: [t.propertyName], color: config.function }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/src/cm/themes/solarizedLight.js b/src/cm/themes/solarizedLight.js index b35fbfede..742918aa9 100644 --- a/src/cm/themes/solarizedLight.js +++ b/src/cm/themes/solarizedLight.js @@ -21,7 +21,6 @@ export const config = { variable: "#268BD2", parameter: "#268BD2", function: "#268BD2", - property: "#657B83", string: "#2AA198", constant: "#CB4B16", type: "#859900", @@ -114,7 +113,7 @@ export const solarizedLightHighlightStyle = HighlightStyle.define([ tag: [t.name, t.deleted, t.character, t.macroName], color: config.variable, }, - { tag: [t.propertyName], color: config.property }, + { tag: [t.propertyName], color: config.function }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/src/cm/themes/tokyoNight.js b/src/cm/themes/tokyoNight.js index caad6ec4f..cee0c90dd 100644 --- a/src/cm/themes/tokyoNight.js +++ b/src/cm/themes/tokyoNight.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView, lineNumbers } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "tokyoNight", @@ -21,7 +22,7 @@ export const config = { variable: "#c0caf5", parameter: "#cfc9c2", function: "#7aa2f7", - property: "#73daca", + memberProperty: "#7dcfff", string: "#9ece6a", constant: "#ff9e64", type: "#2ac3de", @@ -110,12 +111,16 @@ export const tokyoNightTheme = EditorView.theme( ); export const tokyoNightHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], color: config.variable, }, - { tag: [t.propertyName], color: config.property }, + { tag: [t.propertyName], color: config.function }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/src/cm/themes/tokyoNightDay.js b/src/cm/themes/tokyoNightDay.js index a001f1310..dd50d9ce8 100644 --- a/src/cm/themes/tokyoNightDay.js +++ b/src/cm/themes/tokyoNightDay.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView, lineNumbers } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "tokyoNightDay", @@ -20,7 +21,7 @@ export const config = { variable: "#3760bf", parameter: "#3760bf", function: "#2e7de9", - property: "#387068", + memberProperty: "#007197", string: "#587539", constant: "#9854f1", type: "#07879d", @@ -107,12 +108,16 @@ export const tokyoNightDayTheme = EditorView.theme( ); export const tokyoNightDayHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], color: config.variable, }, - { tag: [t.propertyName], color: config.property }, + { tag: [t.propertyName], color: config.function }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/src/cm/themes/tomorrowNight.js b/src/cm/themes/tomorrowNight.js index 232bb46ff..e4102b871 100644 --- a/src/cm/themes/tomorrowNight.js +++ b/src/cm/themes/tomorrowNight.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; // Palette adapted from Tomorrow Night (Chris Kempson) export const config = { @@ -22,7 +23,7 @@ export const config = { variable: "#CC6666", parameter: "#DE935F", function: "#81A2BE", - property: "#C5C8C6", + memberProperty: "#C5C8C6", string: "#B5BD68", constant: "#DE935F", type: "#F0C674", @@ -115,12 +116,16 @@ export const tomorrowNightTheme = EditorView.theme( ); export const tomorrowNightHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], color: config.variable, }, - { tag: [t.propertyName], color: config.property }, + { tag: [t.propertyName], color: config.function }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/src/cm/themes/tomorrowNightBright.js b/src/cm/themes/tomorrowNightBright.js index 70b2fad12..68055c5ad 100644 --- a/src/cm/themes/tomorrowNightBright.js +++ b/src/cm/themes/tomorrowNightBright.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; // Palette adapted from Tomorrow Night Bright (Chris Kempson) export const config = { @@ -22,7 +23,7 @@ export const config = { variable: "#D54E53", parameter: "#E78C45", function: "#7AA6DA", - property: "#DEDEDE", + memberProperty: "#DEDEDE", string: "#B9CA4A", constant: "#E78C45", type: "#E7C547", @@ -115,12 +116,16 @@ export const tomorrowNightBrightTheme = EditorView.theme( ); export const tomorrowNightBrightHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], color: config.variable, }, - { tag: [t.propertyName], color: config.property }, + { tag: [t.propertyName], color: config.function }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, diff --git a/tests/unit/javascriptHighlighting.test.ts b/tests/unit/javascriptHighlighting.test.ts index 924e78d4e..829983722 100644 --- a/tests/unit/javascriptHighlighting.test.ts +++ b/tests/unit/javascriptHighlighting.test.ts @@ -7,6 +7,7 @@ import { import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { highlightTree, type Tag, tags as t } from "@lezer/highlight"; +import { memberProperty } from "cm/highlightTags"; import { javascriptSupport } from "cm/modes/javascript"; import { getThemeExtensions, getThemes } from "cm/themes"; import { describe, expect, it } from "vitest"; @@ -200,16 +201,16 @@ interface I { draw(): void; area: number }`, "#secret", "draw", ]); - expect(textsWith(state, "definition(propertyName)")).toEqual([ + expect(textsWith(state, "definition(memberProperty)")).toEqual([ "field", "area", ]); }); it("keeps method calls tagged as function properties", () => { - const state = createState("options.uri.hashCode();"); + const state = createState("options.uri.hashCode(); const { a: b } = o;"); expect(textsWith(state, "function(propertyName)")).toEqual(["hashCode"]); - expect(textsWith(state, "propertyName")).toEqual(["uri"]); + expect(textsWith(state, "memberProperty")).toEqual(["uri", "a"]); }); }); @@ -249,10 +250,12 @@ describe("built-in theme function colours", () => { it.each( getThemes().map((theme) => [theme.id]), - )("%s colours properties differently from methods", (id) => { + )("%s colours JS properties differently from methods", (id) => { + // VS Code's Solarized colours them alike too. + if (id.startsWith("solarized")) return; const state = EditorState.create({ extensions: getThemeExtensions(id) }); const method = colorOf(state, t.function(t.propertyName)); - expect(colorOf(state, t.propertyName)).not.toBe(method); - expect(colorOf(state, t.definition(t.propertyName))).not.toBe(method); + expect(colorOf(state, memberProperty)).not.toBe(method); + expect(colorOf(state, t.definition(memberProperty))).not.toBe(method); }); });