diff --git a/src/editor/core/cm/angular/angular.test.ts b/src/editor/core/cm/angular/angular.test.ts index 646af4e1b..f24390120 100644 --- a/src/editor/core/cm/angular/angular.test.ts +++ b/src/editor/core/cm/angular/angular.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { EditorState } from "@codemirror/state"; import { ensureSyntaxTree, foldable, matchBrackets } from "@codemirror/language"; -import { NodeProp, type Tree } from "@lezer/common"; +import { IterMode, NodeProp, type Tree } from "@lezer/common"; import { buildParserFile } from "@lezer/generator"; import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight"; import { findLanguageDescription, loadSupport } from "../languages"; @@ -556,6 +556,27 @@ describe("Angular templates", () => { } }); + it("reuses the trees of identical text and attributes wherever they are", () => { + const part = '

{{ a + 1 }} @if (b) {

\n'; + tokens(part); + // The same nodes at other offsets come from the memo; tokens are sliced at their positions. + const doc = `
${part}
\n${part}x${part}`; + const all = tokens(doc); + expect(all.filter((tok) => tok == "a:var")).toHaveLength(3); + expect(all.filter((tok) => tok == "title:attr")).toHaveLength(3); + expect(all.filter((tok) => tok == "@if:control")).toHaveLength(3); + const mounts: unknown[] = []; + angularLanguage.parser.parse(doc).iterate({ + mode: IterMode.IgnoreMounts, + enter: (n) => { + const mount = n.name == "Text" && n.tree?.prop(NodeProp.mounted); + if (mount) mounts.push(mount.tree); + }, + }); + expect(mounts).toHaveLength(3); + expect(new Set(mounts).size).toBe(1); + }); + it("parses only text with Angular syntax as Angular", () => { const doc = "\n@if (x) {\n}"; const tree = angularLanguage.parser.parse(doc); diff --git a/src/editor/core/cm/angular/index.ts b/src/editor/core/cm/angular/index.ts index 652ccb5c0..f787dd2d0 100644 --- a/src/editor/core/cm/angular/index.ts +++ b/src/editor/core/cm/angular/index.ts @@ -10,7 +10,18 @@ import { LRLanguage, LanguageSupport, foldService, matchBrackets, syntaxTree } f 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 { + IterMode, + Parser, + parseMixed, + type Input, + type NestedParse, + type PartialParse, + type SyntaxNode, + type SyntaxNodeRef, + type Tree, + type TreeFragment, +} from "@lezer/common"; import { parser } from "./parser"; // @lezer/javascript styles `.` as a deref operator, but not `?.`. @@ -84,8 +95,66 @@ const attrParser = baseParser.configure({ top: "Attribute", }); -const textMixed = { parser: textParser }, - attrMixed = { parser: attrParser }; +/** + * `inner` with its trees remembered by text. Most text and attribute nodes + * an edit recreates (the document's own text, nodes up to the viewport) are + * unchanged, but the mixed parser only reuses nested trees of nodes it kept, so + * each would be parsed again on every keystroke. Their parse depends on + * nothing but their text, and trees are relative to their start. + */ +class TextMemo extends Parser { + private readonly trees = new Map(); + private chars = 0; + constructor(private readonly inner: Parser) { + super(); + } + createParse( + input: Input, + fragments: readonly TreeFragment[], + ranges: readonly { from: number; to: number }[], + ): PartialParse { + const range = ranges.length == 1 ? ranges[0]! : null; + if (!range || range.to - range.from > MEMO_MAX_TEXT) return this.inner.startParse(input, fragments, ranges); + const text = input.read(range.from, range.to); + const known = this.trees.get(text); + if (known) return { advance: () => known, parsedPos: range.to, stoppedAt: null, stopAt() {} }; + const parse = this.inner.startParse(input, fragments, ranges); + const remember = (tree: Tree) => this.remember(text, tree); + return { + advance() { + const tree = parse.advance(); + // Stopping past the text (the outer parse's stop point) still parses all of it. + if (tree && (parse.stoppedAt == null || parse.stoppedAt >= range.to)) remember(tree); + return tree; + }, + get parsedPos() { + return parse.parsedPos; + }, + get stoppedAt() { + return parse.stoppedAt; + }, + stopAt: (pos) => parse.stopAt(pos), + }; + } + private remember(text: string, tree: Tree) { + if (this.trees.has(text)) return; + this.trees.set(text, tree); + this.chars += text.length; + // Oldest first. + for (const [old] of this.trees) { + if (this.chars <= MEMO_CHARS) break; + this.trees.delete(old); + this.chars -= old.length; + } + } +} + +/** Longer texts are parsed incrementally as usual; the memo holds about this much text. */ +const MEMO_MAX_TEXT = 4096, + MEMO_CHARS = 2_000_000; + +const textMixed = { parser: new TextMemo(textParser) }, + attrMixed = { parser: new TextMemo(attrParser) }; // Text is parsed per node, like upstream, so an unfinished `{{` or `@if (` // ends with it. But HTML splits text at each `&` (`@if (a && b) {`), so a run