diff --git a/bun.lock b/bun.lock index a72716e329..5f2c0d5655 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 b702bafd71..41fccea233 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 73c8310072..aebe3e1725 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/highlightTags.ts b/src/cm/highlightTags.ts new file mode 100644 index 0000000000..49b746d33f --- /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 new file mode 100644 index 0000000000..2098d61d63 --- /dev/null +++ b/src/cm/modes/javascript/index.ts @@ -0,0 +1,32 @@ +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"; + +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, method names tagged as functions and property names tagged as + * {@link memberProperty}. + */ +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: [highlightTags], wrap: jsdocWrap }), + support.support, + ); +} diff --git a/src/cm/modes/jsdoc/index.ts b/src/cm/modes/jsdoc/index.ts new file mode 100644 index 0000000000..f6935bd83e --- /dev/null +++ b/src/cm/modes/jsdoc/index.ts @@ -0,0 +1,345 @@ +import { foldNodeProp, indentNodeProp } 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. 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) + * 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, + JSDocParam, + JSDocLink, +} + +const nodeSet = new NodeSet( + ["BlockComment", "JSDocTag", "JSDocType", "JSDocParam", "JSDocLink"].map( + (name, id) => NodeType.define({ id, name }), + ), +).extend( + styleTags({ + BlockComment: t.docComment, + JSDocTag: t.keyword, + JSDocType: t.typeName, + JSDocParam: t.variableName, + 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"; +} + +/** + * 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[] = []; + 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) { + 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); + } + } + 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][] = []; + 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) { + 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 === "'") { + const close = skipQuoted(text, i, limit); + if (close === -1) break; + tokens.push([Type.JSDocType, i, close]); + i = close; + continue; + } + i++; + } + return pos + 1; +} + +/** `[name=default]` after `@param`. */ +function optionalParam(text: string, pos: number, end: number, push: Push) { + let i = pos + 1; + const name = matchAt(IDENTIFIER, text, i); + if (name) { + push(Type.JSDocParam, i, i + name.length); + i += name.length; + } + + // Find the closing `]`, ignoring brackets inside quoted defaults. + let depth = 1; + while (i < end && text[i] !== "\n") { + 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; +} + +// 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; + // 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[3] === "*" || head[3] === "/") { + return null; + } + return { parser: jsdocParser }; +}); diff --git a/src/cm/supportedModes.ts b/src/cm/supportedModes.ts index 44334dbc4d..12febc9f99 100644 --- a/src/cm/supportedModes.ts +++ b/src/cm/supportedModes.ts @@ -67,9 +67,16 @@ 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 { 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 fdbe82456a..b3e94bc12b 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,6 +22,7 @@ export const config = { variable: "#edecee", parameter: "#edecee", function: "#ffca85", + memberProperty: "#edecee", string: "#61ffca", constant: "#61ffca", type: "#82e2ff", @@ -109,6 +111,10 @@ 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], @@ -119,7 +125,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 b7629eaaea..cd62ab5fc4 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], @@ -101,7 +107,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 6b1e9a5330..7f9d0fb5fc 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,6 +123,7 @@ function createConfig(name, caption, palette) { variable: palette.text, parameter: palette.text, function: palette.blue, + memberProperty: palette.text, string: palette.green, constant: palette.peach, type: palette.yellow, @@ -217,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], @@ -225,6 +231,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 6d23c56ea8..84ade32c10 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,6 +21,7 @@ export const config = { variable: "#ffffff", parameter: "#ffffff", function: "#ffc600", + memberProperty: "#ffffff", string: "#3ad900", constant: "#ff628c", type: "#80ffbb", @@ -85,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 }, { @@ -92,7 +98,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 8a02c06cbc..f6e3cc6e6b 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,6 +21,7 @@ export const config = { variable: "#F8F8F2", parameter: "#F8F8F2", function: "#50FA7B", + memberProperty: "#F8F8F2", string: "#F1FA8C", constant: "#BD93F9", type: "#8BE9FD", @@ -106,6 +108,10 @@ 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], @@ -116,7 +122,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 e1dcf76533..8ad2c8a4c3 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], @@ -119,7 +125,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 e05b03bb27..8ea5e35b8b 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], @@ -119,7 +125,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 e86960d8c8..06ae5a1c59 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 dbcd67eba1..71c95dc1eb 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 2063e88d7a..8247e4f28f 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], @@ -101,7 +107,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 e43e2969d9..82365a6b3a 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,6 +21,7 @@ export const config = { variable: "#FD971F", parameter: "#FD971F", function: "#66D9EF", + memberProperty: "#f8f8f2", string: "#E6DB74", constant: "#AE81FF", type: "#66D9EF", @@ -113,6 +115,10 @@ 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], @@ -123,7 +129,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 c32479274b..c681f69f15 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 }, @@ -117,7 +123,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 60e85cb13b..97cf7c7bc0 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,6 +20,7 @@ export const config = { variable: "#d8dee9", parameter: "#d8dee9", function: "#8fbcbb", + memberProperty: "#d8dee9", string: "#a3be8c", constant: "#b48ead", type: "#88c0d0", @@ -91,6 +93,10 @@ 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], @@ -101,7 +107,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 088a200a64..168c8b1872 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 01dd678f5a..742918aa90 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 3ec2548e70..cee0c90dde 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,6 +22,7 @@ export const config = { variable: "#c0caf5", parameter: "#cfc9c2", function: "#7aa2f7", + memberProperty: "#7dcfff", string: "#9ece6a", constant: "#ff9e64", type: "#2ac3de", @@ -109,6 +111,10 @@ 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], @@ -119,7 +125,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 75a9fcf820..dd50d9ce8a 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,6 +21,7 @@ export const config = { variable: "#3760bf", parameter: "#3760bf", function: "#2e7de9", + memberProperty: "#007197", string: "#587539", constant: "#9854f1", type: "#07879d", @@ -106,6 +108,10 @@ 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], @@ -116,7 +122,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 0ff0d39c0b..e4102b871c 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,6 +23,7 @@ export const config = { variable: "#CC6666", parameter: "#DE935F", function: "#81A2BE", + memberProperty: "#C5C8C6", string: "#B5BD68", constant: "#DE935F", type: "#F0C674", @@ -114,6 +116,10 @@ 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], @@ -124,7 +130,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 26bfacf9de..68055c5ad5 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,6 +23,7 @@ export const config = { variable: "#D54E53", parameter: "#E78C45", function: "#7AA6DA", + memberProperty: "#DEDEDE", string: "#B9CA4A", constant: "#E78C45", type: "#E7C547", @@ -114,6 +116,10 @@ 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], @@ -124,7 +130,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 8790b32b19..a6107d9448 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 0000000000..829983722d --- /dev/null +++ b/tests/unit/javascriptHighlighting.test.ts @@ -0,0 +1,261 @@ +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 { memberProperty } from "cm/highlightTags"; +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(memberProperty)")).toEqual([ + "field", + "area", + ]); + }); + + it("keeps method calls tagged as function properties", () => { + const state = createState("options.uri.hashCode(); const { a: b } = o;"); + expect(textsWith(state, "function(propertyName)")).toEqual(["hashCode"]); + expect(textsWith(state, "memberProperty")).toEqual(["uri", "a"]); + }); +}); + +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); + } + }); + + it.each( + getThemes().map((theme) => [theme.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, memberProperty)).not.toBe(method); + expect(colorOf(state, t.definition(memberProperty))).not.toBe(method); + }); +});