From d533750d1c8b5e33dcbea34f22a722320b762c53 Mon Sep 17 00:00:00 2001
From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Date: Fri, 6 Nov 2026 13:22:53 +0800
Subject: [PATCH] perf(angular): parse only text with Angular syntax as Angular
Every text node of a template got its own nested Angular parse, including
the whitespace between tags, which is most of them. Text without a `{`, `}`
or `@` holds no interpolation or block, so it stays HTML text. A full parse
of a 12k-line template takes about a fifth less work.
---
src/editor/core/cm/angular/angular.test.ts | 12 ++++++++++++
src/editor/core/cm/angular/index.ts | 19 +++++++++++++------
2 files changed, 25 insertions(+), 6 deletions(-)
diff --git a/src/editor/core/cm/angular/angular.test.ts b/src/editor/core/cm/angular/angular.test.ts
index e0085036d..646af4e1b 100644
--- a/src/editor/core/cm/angular/angular.test.ts
+++ b/src/editor/core/cm/angular/angular.test.ts
@@ -556,6 +556,18 @@ describe("Angular templates", () => {
}
});
+ it("parses only text with Angular syntax as Angular", () => {
+ const doc = "
\n - Tom & Jerry
\n - {{ a }} & {{ b }}
\n
\n@if (x) {\n}";
+ const tree = angularLanguage.parser.parse(doc);
+ const parentAt = (text: string) => tree.resolveInner(doc.indexOf(text) + 1, 1).parent?.name;
+ // Plain text and the whitespace between tags stay HTML text.
+ expect(parentAt("Tom")).toBe("Element");
+ expect(parentAt("\n ")).toBe("Element");
+ expect(parentAt("{{ a")).toBe("Interpolation");
+ expect(tokens(doc)).toContain("@if:control");
+ expect(tokens(doc)).toContain("b:var");
+ });
+
it("is generated from angular.grammar", () => {
const dir = import.meta.dirname;
const { parser, terms } = buildParserFile(readFileSync(resolve(dir, "angular.grammar"), "utf8"), {
diff --git a/src/editor/core/cm/angular/index.ts b/src/editor/core/cm/angular/index.ts
index 544880f41..652ccb5c0 100644
--- a/src/editor/core/cm/angular/index.ts
+++ b/src/editor/core/cm/angular/index.ts
@@ -93,8 +93,15 @@ const textMixed = { parser: textParser },
// document) holding it.
const TEXT_NODES = new Set(["Text", "EntityReference", "CharacterReference", "InvalidEntity"]);
-/** The runs of text directly in `node` that entities split, `null` without any. */
-function entityRuns(node: SyntaxNode): { from: number; to: number }[] | null {
+/**
+ * Whether text holds Angular syntax: interpolations, blocks and their braces all
+ * need a `{`, `}` or `@`. Other text (mostly the whitespace between tags) stays
+ * plain HTML text instead of costing a nested parse each.
+ */
+const ANGULAR_TEXT = /[{}@]/;
+
+/** The runs of Angular text directly in `node` that entities split, `null` without any. */
+function entityRuns(node: SyntaxNode, input: Input): { from: number; to: number }[] | null {
const runs: { from: number; to: number }[] = [];
let from = -1,
to = -1,
@@ -108,12 +115,12 @@ function entityRuns(node: SyntaxNode): { from: number; to: number }[] | null {
parts++;
continue;
}
- if (parts > 1) runs.push({ from, to });
+ if (parts > 1 && ANGULAR_TEXT.test(input.read(from, to))) runs.push({ from, to });
({ from, to } = child);
parts = 1;
} while (child.nextSibling());
}
- if (parts > 1) runs.push({ from, to });
+ if (parts > 1 && ANGULAR_TEXT.test(input.read(from, to))) runs.push({ from, to });
return runs.length ? runs : null;
}
@@ -125,10 +132,10 @@ function mixAngular(node: SyntaxNodeRef, input: Input): NestedParse | null {
return /^[*#([]|\{\{/.test(input.read(node.from, node.to)) ? attrMixed : null;
// Text in a run is covered by its parent's overlay, so not asked about.
case "Text":
- return textMixed;
+ return ANGULAR_TEXT.test(input.read(node.from, node.to)) ? textMixed : null;
case "Document":
case "Element": {
- const overlay = entityRuns(node.node);
+ const overlay = entityRuns(node.node, input);
return overlay && { parser: node.name == "Document" ? documentRunsParser : runsParser, overlay };
}
}