diff --git a/src/editor/core/cm/angular/angular.grammar b/src/editor/core/cm/angular/angular.grammar index b21af301..c6ff1682 100644 --- a/src/editor/core/cm/angular/angular.grammar +++ b/src/editor/core/cm/angular/angular.grammar @@ -2,7 +2,8 @@ // From @codemirror/lang-angular 0.1.4 (MIT, see LICENSE). Changed by OpenIDEA: // control-flow blocks (`@if`, `@for`, `@switch`, `@defer`, ...) and `@let` // declarations in text, key and animation event names (`(keydown.enter)`, -// `(@fade.done)`) and animation bindings (`[@fade]`). Regenerate with +// `(@fade.done)`), animation bindings (`[@fade]`) and structural directives' +// microsyntax (`*ngFor="let item of items; index as i"`). Regenerate with // `pnpm gen:angular-grammar`. @top Content { @@ -22,12 +23,29 @@ // `class.active`, `keydown.enter`, and animations' `@fade`, `@fade.done`. bindingName { "@"? Identifier ("." Identifier)* } + // A structural directive's value: an expression, then `let` declarations, + // `as` aliases and keyed expressions (`of items`, `trackBy: byId`). A key's + // expression is optional, like Angular's at the end and before `let`. + @top Microsyntax { + (BindingExpression alias?)? (binding | ";" | ",")* + } + + binding { + reserved<"let"> VariableDefinition (Is VariableName)? | + VariableName alias | + BindingKey (":"? BindingExpression alias?)? + } + + alias { reserved<"as"> VariableDefinition } + + BindingKey { jsIdentifier } + // The header of a block, up to its `{`: its body and `}` are content and // HTML elements around it. Both braces are a `Brace`, which CodeMirror // matches like plain text. Block { - "@if" BlockParameters VariableDefinition)?> Brace | - "@else" (kw<"if"> BlockParameters VariableDefinition)?>)? Brace | + "@if" BlockParameters Brace | + "@else" (kw<"if"> BlockParameters)? Brace | "@for" BlockParameters Expression (";" forOption?)*> Brace | ("@switch" | "@case") BlockParameters Brace | ("@default" | "@empty" | "@error") Brace | @@ -41,7 +59,7 @@ forOption { kw<"track"> Expression | - kw<"let"> ForAlias ("," ForAlias)* + reserved<"let"> ForAlias ("," ForAlias)* } ForAlias { VariableDefinition Is VariableName } @@ -63,7 +81,12 @@ TriggerName { jsIdentifier } - kw { @specialize[@name={term}] } + // Keywords that are also names where a keyword can't be (`@if (x; as + // after)`). `let` and `as` never are: in microsyntax, `let x` and `as x` + // would also read as a key and its expression. + kw { @extend[@name={term}] } + + reserved { @specialize[@name={term}] } } AttributeValue[isolate] { @@ -86,7 +109,7 @@ Interpolation[isolate] { @external tokens time from "./tokens" { Time } -@external tokens expression from "./tokens" { Expression } +@external tokens expression from "./tokens" { Expression, BindingExpression } @external tokens attrSingle from "./tokens" { attributeContentSingle } @external tokens attrDouble from "./tokens" { attributeContentDouble } @@ -101,7 +124,8 @@ Interpolation[isolate] { InterpolationContent { (![}] | "}" ![}]) InterpolationContent? } - Identifier { (":" | "-" | @asciiLetter | @digit | "_" | $[À-\u{EFFFF}])+ } + // `%` for unit bindings: `[style.width.%]`. + Identifier { (":" | "-" | "%" | @asciiLetter | @digit | "_" | $[À-\u{EFFFF}])+ } jsIdentifier { (@asciiLetter | "_" | "$" | $[À-\u{EFFFF}]) (@asciiLetter | @digit | "_" | "$" | $[À-\u{EFFFF}])* @@ -112,7 +136,7 @@ Interpolation[isolate] { Is { "=" } "{{" "}}" "[" "]" "(" ")" - "*" "#" "@" ";" "," + "*" "#" "@" ";" "," ":" "@if" "@else" "@for" "@empty" "@switch" "@case" "@default" "@defer" "@placeholder" "@loading" "@error" "@let" diff --git a/src/editor/core/cm/angular/angular.test.ts b/src/editor/core/cm/angular/angular.test.ts index 0dbbd7a5..e0085036 100644 --- a/src/editor/core/cm/angular/angular.test.ts +++ b/src/editor/core/cm/angular/angular.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { EditorState } from "@codemirror/state"; -import { ensureSyntaxTree, matchBrackets } from "@codemirror/language"; +import { ensureSyntaxTree, foldable, matchBrackets } from "@codemirror/language"; import { NodeProp, type Tree } from "@lezer/common"; import { buildParserFile } from "@lezer/generator"; import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight"; @@ -200,12 +200,57 @@ describe("Angular templates", () => { }); }); - it("highlights key and animation binding names", () => { - const doc = ``; + it("folds a block from its `{` to its `}`", () => { + const doc = [ + "@if (a && b) {", + "

{{ x }}

", + "} @else if (c) {", + "
    ", + " @for (i of is; track i) {", + "
  • { i }
  • ", + " }", + "
", + "} @else {", + " none", + "}", + "@switch (m) {", + " @case (1) { one }", + " @default {", + " other", + " }", + "}", + ].join("\n"); + const state = EditorState.create({ doc, extensions: angular() }); + stateTree(state); + const folds = []; + for (let n = 1; n <= state.doc.lines; n++) { + const line = state.doc.line(n); + const fold = foldable(state, line.from, line.to); + if (fold) + folds.push([ + n, + state.doc.lineAt(fold.to).number, + state.sliceDoc(fold.from - 1, fold.from), + state.sliceDoc(fold.to, fold.to + 1), + ]); + } + expect(folds).toEqual([ + [1, 3, "{", "}"], + [3, 9, "{", "}"], + // The element's own fold. + [4, 8, ">", "<"], + [5, 7, "{", "}"], + [9, 11, "{", "}"], + [12, 17, "{", "}"], + [14, 16, "{", "}"], + ]); + }); + + it("highlights key, unit and animation binding names", () => { + const doc = ``; expect(tokens(doc)).toEqual([ "input:tag", - "keydown:event", - "enter:event", + "keydown.enter:event", "=:op", '":value', "save:call", @@ -215,15 +260,17 @@ describe("Angular templates", () => { '":value', "fit:call", '":value', - "@:attr", - "fade:attr", + "style.width.%:attr", + "=:op", + '":value', + "w:var", + '":value', + "@fade:attr", "=:op", '":value', "s:var", '":value', - "@:event", - "fade:event", - "done:event", + "@fade.done:event", "=:op", '":value', "end:call", @@ -231,6 +278,168 @@ describe("Angular templates", () => { ]); }); + it("highlights structural directives' microsyntax", () => { + expect(tokens(`
  • `)).toEqual([ + "li:tag", + "*ngFor:keyword", + "=:op", + '":value', + "let:let", + "item:def", + "of:keyword", + "items:call", + "index:var", + "as:keyword", + "i:def", + "trackBy:keyword", + "byId:var", + '":value', + "li:tag", + ]); + expect( + tokens( + `

    `, + ), + ).toEqual([ + "p:tag", + "*ngIf:keyword", + "=:op", + '":value', + "user$:var", + "|:op", + "async:var", + "as:keyword", + "user:def", + "else:keyword", + "loading:var", + '":value', + "p:tag", + "b:tag", + "*ngTemplateOutlet:keyword", + "=:op", + '":value', + "tpl:var", + "context:keyword", + "{:brace", + "$implicit:prop", + "x:var", + "}:brace", + '":value', + "b:tag", + ]); + expect(tokens(``)).toEqual([ + "i:tag", + "*cdkVirtualFor:keyword", + "=:op", + '":value', + "let:let", + "row:def", + "of:keyword", + "rows:var", + "|:op", + "slice:var", + "0:num", + "5:num", + "let:let", + "n:def", + "=:op", + "count:var", + '":value', + "i:tag", + ]); + }); + + it("ends a directive's expressions at keys without separators", () => { + expect(tokens(`

    `)).toEqual([ + "p:tag", + "*ngIf:keyword", + "=:op", + '":value', + "ok:var", + "then:keyword", + "yes:var", + "else:keyword", + "no:var", + '":value', + "p:tag", + ]); + expect(tokens(`

    `).slice(4, -2)).toEqual([ + "let:let", + "a:def", + "of:keyword", + "xs:var", + "let:let", + "i:def", + "=:op", + "index:var", + ]); + expect(tokens(`

    `).slice(4, -2)).toEqual([ + "let:let", + "a:def", + "of:keyword", + "xs:var", + "index:var", + "as:keyword", + "i:def", + ]); + }); + + it("parses directive values Angular accepts without errors", () => { + const values = [ + "let a of xs; let i = index; trackBy: byId", + "ok; then yes else no", + "'a' in obj && x?.y as z; else no", + "x, foo", + "x; foo let y = z", + "tpl; context: { a: [1, 2], b: f(c, d) }", + ]; + for (const value of values) { + const errors: string[] = []; + angularLanguage.parser.parse(`

    `).iterate({ + enter: (node) => void (node.type.isError && errors.push(node.name)), + }); + expect(errors, value).toEqual([]); + } + }); + + it("styles all of a binding's name and value", () => { + const doc = `

    `; + const tree = angularLanguage.parser.parse(doc); + const styled = new Uint8Array(doc.length); + highlightTree(tree, { style: (tags) => (tags.length ? "styled" : null) }, (from, to) => styled.fill(1, from, to)); + expect([...doc].filter((ch, i) => !styled[i] && ch != " ").join("")).toBe(""); + }); + + it("parses block keywords as the names they can also be", () => { + expect(tokens(`@if (change.after; as after) {} @loading (after 100ms; minimum 1s) {}`)).toEqual([ + "@if:control", + "change:var", + ".:op", + "after:prop", + "as:keyword", + "after:def", + "{:brace", + "}:brace", + "@loading:keyword", + "after:keyword", + "100ms:num", + "minimum:keyword", + "1s:num", + "{:brace", + "}:brace", + ]); + expect(tokens(`@for (on of track; track on) {}`)).toEqual([ + "@for:control", + "on:def", + "of:keyword", + "track:var", + "track:keyword", + "on:var", + "{:brace", + "}:brace", + ]); + }); + it("keeps HTML's script, style and event handler attribute languages", () => { expect(tokens(`x`)).toEqual([ "script:tag", diff --git a/src/editor/core/cm/angular/index.ts b/src/editor/core/cm/angular/index.ts index ffa415fc..544880f4 100644 --- a/src/editor/core/cm/angular/index.ts +++ b/src/editor/core/cm/angular/index.ts @@ -2,17 +2,20 @@ * Angular template language: HTML whose text and binding attributes are * Angular syntax (`angular.grammar`), expressions parsed as JavaScript. From * @codemirror/lang-angular 0.1.4 (MIT, see LICENSE); changed by OpenIDEA: - * control-flow blocks, `@let`, key and animation binding names, and runs of - * text that entities split (`a && b`) parsed as one. + * control-flow blocks, `@let`, key and animation binding names, structural + * directives' microsyntax, runs of text that entities split (`a && b`) + * parsed as one, and folding blocks. */ -import { LRLanguage, LanguageSupport } from "@codemirror/language"; +import { LRLanguage, LanguageSupport, foldService, matchBrackets, syntaxTree } from "@codemirror/language"; import { html } from "@codemirror/lang-html"; import { javascriptLanguage } from "@codemirror/lang-javascript"; import { styleTags, tags as t } from "@lezer/highlight"; import { IterMode, parseMixed, type Input, type NestedParse, type SyntaxNode, type SyntaxNodeRef } from "@lezer/common"; import { parser } from "./parser"; -const exprParser = javascriptLanguage.parser.configure({ top: "SingleExpression" }); +// @lezer/javascript styles `.` as a deref operator, but not `?.`. +const jsParser = javascriptLanguage.parser.configure({ props: [styleTags({ "?.": t.derefOperator })] }); +const exprParser = jsParser.configure({ top: "SingleExpression" }); const baseParser = parser.configure({ props: [ @@ -23,16 +26,17 @@ const baseParser = parser.configure({ "AttributeValue ExpressionAttributeValue StatementAttributeValue": t.attributeValue, Entity: t.character, InvalidEntity: t.invalid, - "BoundAttributeName/Identifier BoundAttributeName/@": t.attributeName, - "EventName/Identifier EventName/@": t.special(t.attributeName), + // A name's own style covers the `.`s between its parts. + "BoundAttributeName BoundAttributeName/Identifier BoundAttributeName/@": t.attributeName, + "EventName EventName/Identifier EventName/@": t.special(t.attributeName), "ReferenceName/Identifier": t.variableName, - "DirectiveName/Identifier": t.keyword, + 'DirectiveName DirectiveName/Identifier DirectiveName/"*"': t.keyword, "{{ }}": t.special(t.brace), "( )": t.paren, "[ ]": t.bracket, - "# '*'": t.punctuation, + "# :": t.punctuation, "@if @else @for @empty @switch @case @default if": t.controlKeyword, - "@defer @placeholder @loading @error of as track on when prefetch hydrate never minimum after TriggerName": + "@defer @placeholder @loading @error of as track on when prefetch hydrate never minimum after TriggerName BindingKey": t.keyword, "@let let": t.definitionKeyword, Brace: t.brace, @@ -45,12 +49,16 @@ const baseParser = parser.configure({ }); const exprMixed = { parser: exprParser }, - statementMixed = { parser: javascriptLanguage.parser }; + statementMixed = { parser: jsParser }; + +const EXPRESSIONS = new Set(["InterpolationContent", "Expression", "BindingExpression"]); const textParser = baseParser.configure({ - wrap: parseMixed((node) => (node.name == "InterpolationContent" || node.name == "Expression" ? exprMixed : null)), + wrap: parseMixed((node) => (EXPRESSIONS.has(node.name) ? exprMixed : null)), }); +const microsyntaxMixed = { parser: textParser.configure({ top: "Microsyntax" }) }; + // To reuse old inner trees, parseMixed takes those that the node at a mount's // start and its ancestors had with the same parser. So each kind of mount has // its own instance: the document's runs would take those of an element at its @@ -58,15 +66,20 @@ const textParser = baseParser.configure({ const runsParser = textParser.configure({}), documentRunsParser = textParser.configure({}); +/** The parser of an attribute's interpolation or bound value (`node`). */ +function attrValueMixed(node: SyntaxNode) { + const value = node.parent; + if (value?.name != "StatementAttributeValue") return exprMixed; + return value.parent?.firstChild?.name == "DirectiveName" ? microsyntaxMixed : statementMixed; +} + const attrParser = baseParser.configure({ wrap: parseMixed((node) => node.name == "InterpolationContent" ? exprMixed - : node.name != "AttributeInterpolation" - ? null - : node.node.parent?.name == "StatementAttributeValue" - ? statementMixed - : exprMixed, + : node.name == "AttributeInterpolation" + ? attrValueMixed(node.node) + : null, ), top: "Attribute", }); @@ -124,9 +137,27 @@ function mixAngular(node: SyntaxNodeRef, input: Input): NestedParse | null { export const angularLanguage = (baseHTML.language as LRLanguage).configure({ wrap: parseMixed(mixAngular) }, "angular"); +/** + * The body of the first block opened on a line and closed on a later one, up + * to its `}`, which stays visible: `@if (a) {…}`, `} @else {…}`. Its braces + * pair like plain text, across elements. + */ +const blockFolding = foldService.of((state, lineStart, lineEnd) => { + const text = state.sliceDoc(lineStart, lineEnd); + const tree = syntaxTree(state); + for (let i = text.indexOf("{"); i >= 0; i = text.indexOf("{", i + 1)) { + const open = tree.resolveInner(lineStart + i, 1); + if (open.name != "Brace" || open.parent?.name != "Block") continue; + const match = matchBrackets(state, open.from, 1, { brackets: "{}", maxScanDistance: state.doc.length }); + if (match?.matched && match.end && match.end.from > lineEnd) return { from: open.to, to: match.end.from }; + } + return null; +}); + export function angular(): LanguageSupport { return new LanguageSupport(angularLanguage, [ baseHTML.support, + blockFolding, baseHTML.language.data.of({ closeBrackets: { brackets: ["[", "{", '"'] }, indentOnInput: /^\s*[}\]]$/, diff --git a/src/editor/core/cm/angular/parser.terms.ts b/src/editor/core/cm/angular/parser.terms.ts index 44a62530..c68cf53f 100644 --- a/src/editor/core/cm/angular/parser.terms.ts +++ b/src/editor/core/cm/angular/parser.terms.ts @@ -4,35 +4,38 @@ export const Brace = 2, Time = 3, Expression = 4, - attributeContentSingle = 80, - attributeContentDouble = 81, - scriptAttributeContentSingle = 82, - scriptAttributeContentDouble = 83, - Content = 5, - Interpolation = 8, - InterpolationContent = 9, - Entity = 10, - InvalidEntity = 11, - Block = 12, - as = 18, - VariableDefinition = 19, - _if = 21, - of = 24, - track = 25, - _let = 26, - ForAlias = 27, - Is = 28, - VariableName = 29, - prefetch = 39, - hydrate = 40, - on = 41, - DeferTrigger = 42, - TriggerName = 43, - when = 44, - never = 45, - minimum = 49, - after = 50, - LetDeclaration = 51, - Attribute = 53, - Identifier = 57, - AttributeValue = 69 + BindingExpression = 5, + attributeContentSingle = 85, + attributeContentDouble = 86, + scriptAttributeContentSingle = 87, + scriptAttributeContentDouble = 88, + Content = 6, + Interpolation = 9, + InterpolationContent = 10, + Entity = 11, + InvalidEntity = 12, + Block = 13, + as = 19, + VariableDefinition = 20, + _if = 22, + of = 25, + track = 26, + _let = 27, + ForAlias = 28, + Is = 29, + VariableName = 30, + prefetch = 40, + hydrate = 41, + on = 42, + DeferTrigger = 43, + TriggerName = 44, + when = 45, + never = 46, + minimum = 50, + after = 51, + LetDeclaration = 52, + Attribute = 54, + Identifier = 58, + AttributeValue = 70, + Microsyntax = 71, + BindingKey = 72 diff --git a/src/editor/core/cm/angular/parser.ts b/src/editor/core/cm/angular/parser.ts index e84c8ffd..61d52d27 100644 --- a/src/editor/core/cm/angular/parser.ts +++ b/src/editor/core/cm/angular/parser.ts @@ -1,24 +1,24 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. import {LRParser} from "@lezer/lr" import {text, brace, time, expression, attrSingle, attrDouble, scriptAttrSingle, scriptAttrDouble} from "./tokens" -const spec_jsIdentifier = {__proto__:null,as:36, if:42, of:48, track:50, let:52, prefetch:78, hydrate:80, on:82, when:88, never:90, minimum:98, after:100} +const spec_jsIdentifier = {__proto__:null,as:38, if:45, of:51, track:53, let:54, prefetch:81, hydrate:83, on:85, when:91, never:93, minimum:101, after:103} export const parser = LRParser.deserialize({ version: 14, - states: "4jOVO&mOOO!^Q&jOOO!oO,UO'#CdO!wQ&jO'#ChO!|Q7^O'#ChO#UQ&jO'#ChO#ZQ&jO'#ChO#`Q&lO'#ChO#eQ&lO'#ChO#mQ&lO'#ChO#uQ7[O'#DaOOOR'#Dt'#DtQVO&mOOO#zQMhO'#DdO$VQ&jO'#DiO#}QMhO'#DnO$[Q&jO'#DoOOQO'#Dr'#DrQ$aQ&jOOQ$fQ&jOOQ$kQ&jOOOOO!T,59O,59OO$pO&jO,59OO$uQ&rO'#ClO$zQ&lO,59SOOOR,59S,59SO!wQ&jO,59SO#uQ7[O'#CsO%PQ&rO'#C}O%gQ7[O'#DSO%nQ7[O'#D^OOQO'#Co'#CoO%yQ&jO,59{OOOR-E7r-E7rO&OQ&jO'#EYO&ZQ&jO'#EYO&`Q&jO,5:OO#}QMhO,5:OOOQO,5:T,5:TO&eQ&jO,5:YOOQO,5:Z,5:ZO&jQ&jOOO&jQ&jOOO&rQ&jOOOOO!T1G.j1G.jO&zQ&jO,59WOOOR1G.n1G.nO'SQ&lO1G.nO'XQ7[O,59_O'^Q&jO,59iOOQQ,59n,59nO'cQ&jO,59nO'kQ7[O'#EWO'pQ&rO'#EWOOQO'#EW'#EWO'uQ7[O'#EWOOQQ,59x,59xO(QQ&jO,59xO(YQ&nO'#EXO(_Q&rO1G/gO(dQ&jO'#DzO(iQ&jO,5:tO(iQ&jO,5:tOOQO1G/j1G/jO(tQ&jO1G/jOOQO1G/t1G/tO(yO#tO'#E[O)OO!bO'#E[OOQO'#Dk'#DkQOQ&jOOOOQO'#Dq'#DqO)TO'[O'#DsO)fO&zO'#DsOOQQ1G.r1G.rO)wQ7[O1G.rOOOR7+$Y7+$YO)|Q&rO1G.yOOQQ1G/T1G/TO*RQ7[O'#DxO*]Q&jO1G/YOOQQ1G/Y1G/YOOQO'#DX'#DXO*eQ&jO'#DWO*sQ&jO,5:rOOQO,5:r,5:rO'kQ7[O,5:rO+OQ&rO,5:rO+TQ7[O'#DyO+cQ&jO1G/dOOQQ1G/d1G/dOOQO,5:s,5:sO+kQ&jO7+%ROOQO,5:f,5:fOOQO-E7x-E7xO+pQ&jO1G0`O+{Q&jO7+%UOOOO'#Dl'#DlO,QO&jO,5:vOOOO'#Dm'#DmO,VO&jO,5:vOOOp'#D{'#D{O,[O'[O,5:_OOQO,5:_,5:_OOO`'#D|'#D|O,mO&zO,5:_O#uQ7[O7+$^O-OQ&jO7+$eOOQO,5:d,5:dOOQO-E7v-E7vOOQQ7+$t7+$tO-WQ&vO,59rO'kQ7[O'#DwO-cQ&jO1G0^O-cQ&jO1G0^OOQO1G0^1G0^OOQO,5:e,5:eOOQO-E7w-E7wOOQQ7+%O7+%OOOOR<QVXQO#f$O#f#g>g#g#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR>lVXQO#c$O#c#d?R#d#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR?WVXQO#f$O#f#g?m#g#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR?tTtPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR@YVXQO#c$O#c#d@o#d#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR@tVXQO#f$O#f#gAZ#g#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORAbTfPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORAvVXQO#Y$O#Y#ZB]#Z#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORBdT]PXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORBxXXQO#X$O#X#YCe#Y#c$O#c#dDg#d#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORCjVXQO#h$O#h#iDP#i#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORDWT!UPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORDlVXQO#T$O#T#UER#U#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OREWVXQO#W$O#W#XEm#X#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORErVXQO#]$O#]#^FX#^#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORF^VXQO#b$O#b#cFs#c#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORFxVXQO#Z$O#Z#[G_#[#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORGfT!PPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORGzVXQO#`$O#`#aHa#a#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORHfVXQO#T$O#T#UH{#U#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORIQVXQO#V$O#V#WIg#W#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORIlVXQO#X$O#X#YJR#Y#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORJWVXQO#[$O#[#]Jm#]#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORJrVXQO#c$O#c#dKX#d#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORK^VXQO#`$O#`#aKs#a#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORKxVXQO#W$O#W#XL_#X#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORLdVXQO#X$O#X#YLy#Y#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORMOVXQO#f$O#f#gMe#g#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORMlT!OPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORNQVXQO#k$O#k#lNg#l#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORNlVXQO#]$O#]#^! R#^#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR! WVXQO#h$O#h#i! m#i#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR! rVXQO#V$O#V#W!!X#W#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR!!^VXQO#[$O#[#]!!s#]#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR!!zToPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$O_!#dd!ZXXQ!xSOt$Otu&tu}$O}!O-|!O!Q$O!Q![!#Z![!]-|!]!c$O!c!}!#Z!}#R$O#R#S!#Z#S#T$O#T#o!#Z#o#q$O#q#r$d#r%W$O%W;'S!#Z;'S;:j!$r;:j;=`$s<%lO!#Z_!$uP;=`<%l!#ZR!%PT!XPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR!%gT![PXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR!%{VXQO#o$O#o#p!&b#p#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR!&iTVPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$O~!&{TO#q$O#q#r!'[#r;'S$O;'S;=`$s<%lO$O~!'aOU~", + repeatNodeCount: 10, + tokenData: "!)R~R}OX$OXY$yYZ$yZ]$O]^$y^p$Opq$yqr$Ors%vst&^tu&tuv(Wvw)swx-Vxy-myz.Tz{.k{|$O|}/R}!O(W!O!P/i!P!Q$O!Q![(W![!]0P!]!^1h!^!_$O!_!`2O!`!b$O!b!c2f!c!}!$x!}#O!&j#O#P$O#P#Q!'Q#Q#R$O#R#S!$x#S#T$O#T#o!$x#o#p!'h#p#q$O#q#r!(j#r%W$O%W;'S!$x;'S;:j!&d;:j;=`$s<%lO!$xS$TTYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OS$gSO#q$O#r;'S$O;'S;=`$s<%lO$OS$vP;=`<%l$O_%Q[YS!|ZOX$OXY$yYZ$yZ]$O]^$y^p$Opq$yq#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT%}T#UPYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT&eT!_PYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OU&{aYS!}QOt$Otu&tu!Q$O!Q![&t![!c$O!c!}&t!}#R$O#R#S&t#S#T$O#T#o&t#o#q$O#q#r$d#r%W$O%W;'S&t;'S;:j(Q;:j;=`$s<%lO&tU(TP;=`<%l&t](_d![XYSOu$Ouv(Wv}$O}!O(W!O!Q$O!Q![(W![!](W!]!c$O!c!}(W!}#R$O#R#S(W#S#T$O#T#o(W#o#q$O#q#r$d#r%W$O%W;'S(W;'S;:j)m;:j;=`$s<%lO(W])pP;=`<%l(WT)zXYS[POp*gpq$Oq!]*g!]!^$O!^#q*g#q#r+o#r;'S*g;'S;=`-P<%lO*gT*lXYSOp*gpq$Oq!]*g!]!^+X!^#q*g#q#r+o#r;'S*g;'S;=`-P<%lO*gT+`TZPYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT+rXOp*gpq$Oq!]*g!]!^+X!^#q*g#q#r,_#r;'S*g;'S;=`-P<%lO*gP,bUOp,_q!],_!]!^,t!^;'S,_;'S;=`,y<%lO,_P,yOZPP,|P;=`<%l,_T-SP;=`<%l*gT-^T#VPYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$O]-tT`XYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OV.[T_RYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT.rT!ePYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OV/YToRYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT/pT#SPYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$O_0Yd!kQ![XYSOu$Ouv(Wv}$O}!O(W!O!Q$O!Q![(W![!](W!]!c$O!c!}(W!}#R$O#R#S(W#S#T$O#T#o(W#o#q$O#q#r$d#r%W$O%W;'S(W;'S;:j)m;:j;=`$s<%lO(WV1oTbRYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OV2VTYSmRO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$O]2mb!ZWYSO#V$O#V#W3u#W#X5}#X#Y;P#Y#ZAr#Z#]$O#]#^C`#^#`$O#`#aDb#a#d$O#d#eId#e#g$O#g#h! j#h#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT3zVYSO#T$O#T#U4a#U#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT4fVYSO#g$O#g#h4{#h#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT5QVYSO#X$O#X#Y5g#Y#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT5nTqPYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT6SVYSO#X$O#X#Y6i#Y#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT6nVYSO#Y$O#Y#Z7T#Z#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT7YXYSO#T$O#T#U7u#U#X$O#X#Y9}#Y#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT7zVYSO#i$O#i#j8a#j#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT8fVYSO#`$O#`#a8{#a#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT9QVYSO#h$O#h#i9g#i#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT9nTsPYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT:SVYSO#f$O#f#g:i#g#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT:pTvPYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT;UYYSO#`$O#`#a;t#a#b=b#b#f$O#f#g?j#g#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT;yVYSO#g$O#g#h<`#h#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTRVYSO#h$O#h#i>h#i#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT>mVYSO#m$O#m#n?S#n#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT?ZTtPYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT?oVYSO#f$O#f#g@U#g#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT@ZVYSO#c$O#c#d@p#d#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT@uVYSO#f$O#f#gA[#g#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTAcTuPYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTAwVYSO#c$O#c#dB^#d#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTBcVYSO#f$O#f#gBx#g#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTCPTgPYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTCeVYSO#Y$O#Y#ZCz#Z#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTDRT^PYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTDgXYSO#X$O#X#YES#Y#c$O#c#dFU#d#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTEXVYSO#h$O#h#iEn#i#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTEuT!VPYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTFZVYSO#T$O#T#UFp#U#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTFuVYSO#W$O#W#XG[#X#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTGaVYSO#]$O#]#^Gv#^#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTG{VYSO#b$O#b#cHb#c#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTHgVYSO#Z$O#Z#[H|#[#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTITT!QPYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTIiVYSO#`$O#`#aJO#a#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTJTVYSO#T$O#T#UJj#U#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTJoVYSO#V$O#V#WKU#W#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTKZVYSO#X$O#X#YKp#Y#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTKuVYSO#[$O#[#]L[#]#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTLaVYSO#c$O#c#dLv#d#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTL{VYSO#`$O#`#aMb#a#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTMgVYSO#W$O#W#XM|#X#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTNRVYSO#X$O#X#YNh#Y#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OTNmVYSO#f$O#f#g! S#g#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT! ZT!PPYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT! oVYSO#k$O#k#l!!U#l#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT!!ZVYSO#]$O#]#^!!p#^#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT!!uVYSO#h$O#h#i!#[#i#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT!#aVYSO#V$O#V#W!#v#W#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT!#{VYSO#[$O#[#]!$b#]#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT!$iTpPYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$O_!%Re![XYS!}QOt$Otu&tuv(Wv}$O}!O(W!O!Q$O!Q![!$x![!](W!]!c$O!c!}!$x!}#R$O#R#S!$x#S#T$O#T#o!$x#o#q$O#q#r$d#r%W$O%W;'S!$x;'S;:j!&d;:j;=`$s<%lO!$x_!&gP;=`<%l!$xT!&qT!YPYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT!'XT!]PYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT!'mVYSO#o$O#o#p!(S#p#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OT!(ZTWPYSO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$O~!(mTO#q$O#q#r!(|#r;'S$O;'S;=`$s<%lO$O~!)ROV~", tokenizers: [text, brace, time, expression, attrSingle, attrDouble, scriptAttrSingle, scriptAttrDouble, 0, 1, 2, 3], - topRules: {"Content":[0,5],"Attribute":[1,53]}, - specialized: [{term: 86, get: (value: keyof typeof spec_jsIdentifier) => spec_jsIdentifier[value] || -1}], + topRules: {"Content":[0,6],"Attribute":[1,54],"Microsyntax":[2,71]}, + specialized: [{term: 91, get: (value: keyof typeof spec_jsIdentifier) => spec_jsIdentifier[value] || -1}], tokenPrec: 0 }) diff --git a/src/editor/core/cm/angular/tokens.ts b/src/editor/core/cm/angular/tokens.ts index 9a131193..4b69d12f 100644 --- a/src/editor/core/cm/angular/tokens.ts +++ b/src/editor/core/cm/angular/tokens.ts @@ -1,10 +1,11 @@ /** * External tokens of `angular.grammar`. From @codemirror/lang-angular 0.1.4 - * (MIT, see LICENSE); text stopping at blocks, and the block tokens, added by - * OpenIDEA. + * (MIT, see LICENSE); text stopping at blocks, the block tokens and + * microsyntax expressions added by OpenIDEA. */ import { ExternalTokenizer, type InputStream } from "@lezer/lr"; import { + BindingExpression, Brace, Expression, Text, @@ -22,7 +23,9 @@ const NEWLINE = 10, SINGLE_QUOTE = 39, PAREN_L = 40, PAREN_R = 41, + COMMA = 44, DOT = 46, + COLON = 58, SEMICOLON = 59, AT = 64, BRACKET_L = 91, @@ -33,6 +36,12 @@ const NEWLINE = 10, BRACE_L = 123, BRACE_R = 125; +/** Characters ending an operand outside an identifier: brackets and quotes. */ +const CLOSERS = new Set([PAREN_R, BRACKET_R, BRACE_R, SINGLE_QUOTE, DOUBLE_QUOTE, BACKTICK]); + +/** Words that are operators rather than operands, unless after a `.`. */ +const OPERATORS = new Set(["in", "instanceof", "new", "typeof", "void"]); + const BLOCKS = new Set([ "if", "else", @@ -130,44 +139,75 @@ function blockOnNextLine(input: InputStream): boolean { return input.peek(i) == AT && blockAt(input, i); } +/** The word at offset `i`: `as` in `index as i`, `let` in `let x`. */ +function wordAt(input: InputStream, i: number): string { + let word = ""; + for (let ch = input.peek(i); isIdentifierChar(ch); ch = input.peek(++i)) word += String.fromCharCode(ch); + return word; +} + /** * A block parameter's or `@let`'s expression: up to a `;` or a closing bracket * outside brackets and strings, without the spaces around it. An unterminated * one (being typed) also ends before a child element or comment and before a * line starting with a block, so the blocks after it keep their highlighting. + * A structural directive's (`BindingExpression`) also ends at a `,`, an `as` + * alias or a word after an operand, the next key (`cond; then a else b`), and + * is not one starting with `let`, `as` or a key's `:`. */ -export const expression = new ExternalTokenizer((input) => { - if (isSpace(input.next)) return; - const start = input.pos; - let depth = 0, - quote = 0, - escaped = false, - end = start; - for (;;) { - const ch = input.next; - if (ch < 0 || (ch == NEWLINE && blockOnNextLine(input))) break; - if (escaped) { - escaped = false; - } else if (quote) { - // Strings but template literals end with their line, like in JavaScript. - if (ch == quote || (ch == NEWLINE && quote != BACKTICK)) quote = 0; - else escaped = ch == BACKSLASH; - } else if (ch == SINGLE_QUOTE || ch == DOUBLE_QUOTE || ch == BACKTICK) { - quote = ch; - } else if (ch == PAREN_L || ch == BRACKET_L || ch == BRACE_L) { - depth++; - } else if (ch == PAREN_R || ch == BRACKET_R || ch == BRACE_R) { - if (depth == 0) break; - depth--; - } else if (ch == SEMICOLON && depth == 0) { - break; +export const expression = new ExternalTokenizer( + (input, stack) => { + if (isSpace(input.next)) return; + const binding = stack.canShift(BindingExpression); + if (binding && (input.next == COLON || /^(let|as)$/.test(wordAt(input, 0)))) return; + const start = input.pos; + let depth = 0, + quote = 0, + escaped = false, + end = start, + prev = -1, + // In a word of `OPERATORS`. + operator = false, + // The last character outside spaces ends an operand: `a`, `)`, `'`. + operand = false; + for (;;) { + const ch = input.next; + if (ch < 0 || (ch == NEWLINE && blockOnNextLine(input))) break; + if (escaped) { + escaped = false; + } else if (quote) { + // Strings but template literals end with their line, like in JavaScript. + if (ch == quote || (ch == NEWLINE && quote != BACKTICK)) quote = 0; + else escaped = ch == BACKSLASH; + } else if (ch == SINGLE_QUOTE || ch == DOUBLE_QUOTE || ch == BACKTICK) { + quote = ch; + } else if (ch == PAREN_L || ch == BRACKET_L || ch == BRACE_L) { + depth++; + } else if (ch == PAREN_R || ch == BRACKET_R || ch == BRACE_R) { + if (depth == 0) break; + depth--; + } else if (depth == 0 && (ch == SEMICOLON || (binding && ch == COMMA))) { + break; + } else if (binding && isIdentifierStart(ch) && !isIdentifierChar(prev)) { + const word = wordAt(input, 0); + // After a space, an alias or, after an operand, the next binding's + // key: `user$ | async as user`, `items index as i`. + if (depth == 0 && end < input.pos && (word == "as" || (operand && !OPERATORS.has(word)))) break; + operator = prev != DOT && OPERATORS.has(word); + } + if (!isSpace(ch)) { + end = input.pos + 1; + operand = !quote && (CLOSERS.has(ch) || (isIdentifierChar(ch) && !operator)); + } + if (!isIdentifierChar(ch)) operator = false; + prev = ch; + if (!step(input)) break; } - if (!isSpace(ch)) end = input.pos + 1; - if (!step(input)) break; - } - // `end` is a document position: the stream skips the ranges' gaps. - if (end > start) input.acceptTokenTo(Expression, end); -}); + // `end` is a document position: the stream skips the ranges' gaps. + if (end > start) input.acceptTokenTo(binding ? BindingExpression : Expression, end); + }, + { contextual: true }, +); function attrContent(quote: number, token: number, script: boolean) { return new ExternalTokenizer((input) => { diff --git a/src/editor/core/cm/languages.test.ts b/src/editor/core/cm/languages.test.ts new file mode 100644 index 00000000..26101398 --- /dev/null +++ b/src/editor/core/cm/languages.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight"; +import { findLanguageDescription, loadSupport } from "./languages"; + +describe("language modes", () => { + it("styles `?.` like `.` in JavaScript and TypeScript", async () => { + const highlighter = tagHighlighter([{ tag: t.derefOperator, class: "deref" }]); + for (const [mode, path] of [ + ["JavaScript", "/p/a.js"], + ["TypeScript", "/p/a.ts"], + ["JSX", "/p/a.jsx"], + ["TSX", "/p/a.tsx"], + ] as const) { + const support = await loadSupport(findLanguageDescription(mode, path)!); + const doc = "a?.b.c?.();"; + const derefs: string[] = []; + highlightTree(support!.language.parser.parse(doc), highlighter, (from, to) => derefs.push(doc.slice(from, to))); + expect(derefs, mode).toEqual(["?.", ".", "?."]); + } + }); +}); diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index 51e78e1f..9b5662c9 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -200,8 +200,15 @@ const CSS_TAGS: Parameters[0] = { "ClassName IdName PseudoClassName ColorLiteral CallTag": t.tagName, }; +// @lezer/javascript styles `.` as a deref operator, but not `?.`. +const JS_TAGS: Parameters[0] = { "?.": t.derefOperator }; + /** Highlighting the stock grammars lack or colour unlike IntelliJ. */ const EXTRA_TAGS: Record[0]> = { + JavaScript: JS_TAGS, + TypeScript: JS_TAGS, + JSX: JS_TAGS, + TSX: JS_TAGS, // @lezer/java tags annotations as plain identifiers. Java: { "MarkerAnnotation/Identifier Annotation/Identifier MarkerAnnotation Annotation": t.annotation }, CSS: CSS_TAGS,