From 352efc02a79a66755857cd57ae978234e6a9c402 Mon Sep 17 00:00:00 2001
From: Jaw <53564781+jaw52@users.noreply.github.com>
Date: Sat, 7 Nov 2026 11:13:09 +0800
Subject: [PATCH] feat(angular): highlight inline templates and auto-close tags
(#1897)
Angular component templates in @Component({ template }) were parsed as TypeScript strings, so template highlighting and tag closing never applied. TypeScript files in Angular projects (angular.json or @angular/core) now use an Angular TypeScript mode that mounts the Angular template parser on literal template strings; other strings and non-Angular projects are unchanged. Inline and external templates both close tags on /, with multiple cursors and undo.
Closes #1895
Co-authored-by: jaw52 <53564781+jaw52@users.noreply.github.com>
---
crates/ide-language/src/registry.rs | 10 +-
e2e/angularInline.spec.ts | 62 +++++++++
languages/typescript.toml | 4 +
src/editor/core/backend.ts | 24 +++-
src/editor/core/cm/angular/index.ts | 33 +++++
src/editor/core/cm/angular/inline.test.ts | 161 ++++++++++++++++++++++
src/editor/core/cm/angular/inline.ts | 47 +++++++
src/editor/core/cm/languages.ts | 5 +
src/editor/core/model/document.test.ts | 14 ++
9 files changed, 351 insertions(+), 9 deletions(-)
create mode 100644 e2e/angularInline.spec.ts
create mode 100644 src/editor/core/cm/angular/inline.test.ts
create mode 100644 src/editor/core/cm/angular/inline.ts
diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs
index 258539f0..091eb7f3 100644
--- a/crates/ide-language/src/registry.rs
+++ b/crates/ide-language/src/registry.rs
@@ -602,6 +602,7 @@ mod tests {
"MsGenny",
"Vue",
"Angular Template",
+ "Angular TypeScript",
"ERB",
"JSP",
"Astro",
@@ -718,7 +719,7 @@ mod tests {
}
#[test]
- fn html_of_angular_projects_is_an_angular_template() {
+ fn angular_projects_use_framework_modes() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
@@ -729,7 +730,12 @@ mod tests {
assert_eq!(mode("html", "apps/web/src/app/app.html").as_deref(), Some("Angular Template"));
assert_eq!(mode("html", "apps/admin/src/index.html").as_deref(), Some("Angular Template"));
assert_eq!(mode("html", "docs/index.html"), None);
- assert_eq!(mode("typescript", "apps/web/src/app/app.ts"), None);
+ for file in ["apps/web/src/app/app.ts", "apps/admin/src/app.mts", "apps/admin/src/app.cts"] {
+ assert_eq!(mode("typescript", file).as_deref(), Some("Angular TypeScript"), "{file}");
+ }
+ assert_eq!(mode("typescript", "docs/example.ts"), None);
+ assert_eq!(mode("javascript", "apps/web/src/app/app.js"), None);
+ assert_eq!(mode("typescriptreact", "apps/web/src/app/app.tsx"), None);
assert!(reg.is_framework_marker(&root.join("apps/admin/angular.json")));
assert!(!reg.is_framework_marker(&root.join("apps/admin/package.json")));
}
diff --git a/e2e/angularInline.spec.ts b/e2e/angularInline.spec.ts
new file mode 100644
index 00000000..e31feaf8
--- /dev/null
+++ b/e2e/angularInline.spec.ts
@@ -0,0 +1,62 @@
+import { expect, test } from "./fixtures";
+import type { Page } from "@playwright/test";
+
+async function placeCaret(page: Page, marker: string) {
+ await page.locator(".cm-content").evaluate((element, marker) => {
+ const view = (element as HTMLElement & { cmTile: { view: import("@codemirror/view").EditorView } }).cmTile.view;
+ const anchor = view.state.doc.toString().indexOf(marker) + marker.length;
+ view.dispatch({ selection: { anchor } });
+ view.focus();
+ }, marker);
+}
+
+test("Angular templates highlight and close tags in inline and external documents", async ({ workbench: page }) => {
+ await page.evaluate(async () => {
+ const backendPath = "/src/editor/core/backend.ts";
+ const contributionsPath = "/src/workbench/contributions.ts";
+ const { docBackend } = await import(backendPath);
+ const { editorService } = await import(contributionsPath);
+ const files = docBackend().files;
+ files.set("/dev/acme-api/angular.json", "{}");
+ files.set(
+ "/dev/acme-api/app.ts",
+ '@Component({\n template: `
{{ name }}
\n @if (ready) { Ready
}\n <\n `\n})\nexport class App {}\n',
+ );
+ files.set("/dev/acme-api/app.html", "<");
+ files.set("/dev/acme-api/plain.ts", "const data = { template: `` };");
+ await editorService().openFile("/dev/acme-api/app.ts");
+ });
+ const inline = page.locator('.cm-editor[data-language="Angular TypeScript"]');
+ await expect(inline).toBeVisible();
+ await expect(inline.locator(".cm-content span").filter({ hasText: /^div$/ }).first()).toBeVisible();
+ await expect(inline.locator(".cm-content span").filter({ hasText: /^@if$/ })).toBeVisible();
+ for (const [marker, text, result] of [
+ [""],
+ ["", ""],
+ ["<", "/", ""],
+ ]) {
+ await placeCaret(page, marker!);
+ await page.keyboard.type(text!);
+ await expect(inline.locator(".cm-content")).toContainText(result!);
+ }
+ await page.evaluate(async () => {
+ const path = "/src/workbench/contributions.ts";
+ await (await import(path)).editorService().openFile("/dev/acme-api/app.html");
+ });
+ const external = page.locator('.cm-editor[data-language="Angular Template"]');
+ await expect(external).toBeVisible();
+ for (const [marker, text, result] of [
+ [""],
+ ["", "
"],
+ ["
<", "/", ""],
+ ]) {
+ await placeCaret(page, marker!);
+ await page.keyboard.type(text!);
+ await expect(external.locator(".cm-content")).toContainText(result!);
+ }
+ await page.evaluate(async () => {
+ const path = "/src/workbench/contributions.ts";
+ await (await import(path)).editorService().openFile("/dev/acme-api/plain.ts");
+ });
+ await expect(page.locator(".cm-content span").filter({ hasText: /^r-card$/ })).toHaveCount(0);
+});
diff --git a/languages/typescript.toml b/languages/typescript.toml
index 33bed030..f1cf198f 100644
--- a/languages/typescript.toml
+++ b/languages/typescript.toml
@@ -8,6 +8,10 @@ indent = { tab-size = 2, insert-spaces = true }
ui-mode = "TypeScript"
tree-sitter = "typescript"
+[[framework-ui-modes]]
+ui-mode = "Angular TypeScript"
+root-markers = ["angular.json", "node_modules/@angular/core"]
+
[[language-servers]]
name = "vtsls"
tsserver-project = true
diff --git a/src/editor/core/backend.ts b/src/editor/core/backend.ts
index 6a687012..4407f1b2 100644
--- a/src/editor/core/backend.ts
+++ b/src/editor/core/backend.ts
@@ -119,6 +119,8 @@ interface MemDoc {
const EXT_LANG: Record = {
ts: "typescript",
+ mts: "typescript",
+ cts: "typescript",
tsx: "typescriptreact",
js: "javascript",
jsx: "javascriptreact",
@@ -155,7 +157,7 @@ const MEM_LANGUAGES: LanguageInfo[] = [
{
id: "typescript",
name: "TypeScript",
- extensions: ["ts"],
+ extensions: ["ts", "mts", "cts"],
uiMode: "TypeScript",
lineComment: "//",
blockComment: ["/*", "*/"],
@@ -377,12 +379,20 @@ export function createMemoryBackend(files: Record = {}): MemoryB
...(d.uri ? { uri: d.uri } : {}),
...(d.uiMode ? { uiMode: d.uiMode } : {}),
});
- // An Angular project's templates (an `angular.json` above them), like the backend's `framework-ui-modes`.
- const uiModeOf = (path: string) =>
- path.endsWith(".html") &&
- [...store.keys()].some((f) => f.endsWith("/angular.json") && path.startsWith(f.slice(0, -"angular.json".length)))
- ? "Angular Template"
- : null;
+ // Framework modes mirror the backend's `framework-ui-modes` definitions.
+ const uiModeOf = (path: string) => {
+ const angular = [...store.keys()].some((f) => {
+ const root = f.endsWith("/angular.json")
+ ? f.slice(0, -"angular.json".length)
+ : f.includes("/node_modules/@angular/core/")
+ ? f.slice(0, f.indexOf("/node_modules/@angular/core/") + 1)
+ : null;
+ return root !== null && path.startsWith(root);
+ });
+ if (!angular) return null;
+ if (path.endsWith(".html")) return "Angular Template";
+ return /\.(?:ts|mts|cts)$/.test(path) ? "Angular TypeScript" : null;
+ };
const get = (doc: DocId) => {
const d = docs.get(doc);
if (!d) throw new IpcError({ kind: "notFound", message: `document ${doc} is not open` });
diff --git a/src/editor/core/cm/angular/index.ts b/src/editor/core/cm/angular/index.ts
index 27122554..8402742b 100644
--- a/src/editor/core/cm/angular/index.ts
+++ b/src/editor/core/cm/angular/index.ts
@@ -7,6 +7,8 @@
* parsed as one, and folding blocks.
*/
import { LRLanguage, LanguageSupport, foldService, matchBrackets, syntaxTree } from "@codemirror/language";
+import { EditorSelection } from "@codemirror/state";
+import { EditorView } from "@codemirror/view";
import { html } from "@codemirror/lang-html";
import { javascriptLanguage } from "@codemirror/lang-javascript";
import { styleTags, tags as t } from "@lezer/highlight";
@@ -230,9 +232,40 @@ const blockFolding = foldService.of((state, lineStart, lineEnd) => {
return null;
});
+const selfCloseTags = EditorView.inputHandler.of((view, from, to, text, insertTransaction) => {
+ const { state } = view;
+ if (view.composing || state.readOnly || from !== to || text !== "/") return false;
+ const heads = state.selection.ranges
+ .filter((range) => {
+ if (!range.empty || !angularLanguage.isActiveAt(state, range.head, -1)) return false;
+ const node = syntaxTree(state).resolveInner(range.head, -1);
+ return node.name === "TagName" && node.to === range.head && node.parent?.name === "OpenTag";
+ })
+ .map((range) => range.head);
+ if (!heads.length) return false;
+ const base = insertTransaction();
+ const mappedHeads = new Set(heads.map((head) => base.changes.mapPos(head, 1)));
+ const closing = base.state.changeByRange((range) => {
+ if (!range.empty || !mappedHeads.has(range.head) || base.state.sliceDoc(range.head - 1, range.head) !== "/")
+ return { range };
+ return {
+ range: EditorSelection.cursor(range.head + 1),
+ changes: {
+ from: range.head,
+ to: range.head + (base.state.sliceDoc(range.head, range.head + 1) === ">" ? 1 : 0),
+ insert: ">",
+ },
+ };
+ });
+ if (closing.changes.empty) return false;
+ view.dispatch([base, base.state.update(closing, { userEvent: "input.type", scrollIntoView: true })]);
+ return true;
+});
+
export function angular(): LanguageSupport {
return new LanguageSupport(angularLanguage, [
baseHTML.support,
+ selfCloseTags,
blockFolding,
baseHTML.language.data.of({
closeBrackets: { brackets: ["[", "{", '"'] },
diff --git a/src/editor/core/cm/angular/inline.test.ts b/src/editor/core/cm/angular/inline.test.ts
new file mode 100644
index 00000000..4c613fd2
--- /dev/null
+++ b/src/editor/core/cm/angular/inline.test.ts
@@ -0,0 +1,161 @@
+import { describe, expect, it } from "vitest";
+import { EditorSelection, EditorState, type Extension } from "@codemirror/state";
+import { history, undo } from "@codemirror/commands";
+import { EditorView } from "@codemirror/view";
+import { ensureSyntaxTree } from "@codemirror/language";
+import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight";
+import { findLanguageDescription, loadSupport } from "../languages";
+import { angular } from "./index";
+
+async function inlineSupport() {
+ return (await loadSupport(findLanguageDescription("Angular TypeScript", "/p/app.ts")!))!;
+}
+
+const highlighter = tagHighlighter([
+ { tag: t.tagName, class: "tag" },
+ { tag: t.attributeName, class: "attr" },
+ { tag: t.variableName, class: "var" },
+ { tag: t.controlKeyword, class: "control" },
+ { tag: t.definitionKeyword, class: "definition" },
+ { tag: t.string, class: "string" },
+]);
+
+function tokens(state: EditorState) {
+ const out: string[] = [];
+ highlightTree(ensureSyntaxTree(state, state.doc.length, 5000)!, highlighter, (from, to, cls) =>
+ out.push(`${state.sliceDoc(from, to)}:${cls}`),
+ );
+ return out;
+}
+
+function type(view: EditorView, text: string) {
+ const { from, to } = view.state.selection.main;
+ const insert = () => view.state.update(view.state.replaceSelection(text), { userEvent: "input.type" });
+ if (!view.state.facet(EditorView.inputHandler).some((h) => h(view, from, to, text, insert))) view.dispatch(insert());
+}
+
+function editor(marked: string, extensions: Extension) {
+ const pos = marked.indexOf("|");
+ return new EditorView({
+ state: EditorState.create({
+ doc: marked.replace("|", ""),
+ selection: { anchor: pos },
+ extensions,
+ }),
+ });
+}
+
+describe("Angular inline templates", () => {
+ it.each(["`", "'", '"'])("highlights a %s-quoted component template", async (quote) => {
+ const template = '{{ name }} @if (ready) {} @for (x of xs; track x) {} @let y = count;
';
+ const body = quote === '"' ? template.replaceAll('"', "'") : template;
+ const state = EditorState.create({
+ doc: `@Component({ template: ${quote}${body}${quote} }) class App {}`,
+ extensions: await inlineSupport(),
+ });
+ expect(tokens(state)).toEqual(
+ expect.arrayContaining(["div:tag", "title:attr", "name:var", "@if:control", "@for:control", "@let:definition"]),
+ );
+ });
+
+ it.each([
+ "const x = { template: `{{ name }}
` };",
+ "@Other({ template: `{{ name }}
` }) class App {}",
+ "@Component({ nested: { template: `{{ name }}
` } }) class App {}",
+ "@Component({ templateUrl: `{{ name }}
` }) class App {}",
+ "Component({ template: `{{ name }}
` });",
+ "@Component({ template: `${name}
` }) class App {}",
+ "@Component({ template: `` + suffix }) class App {}",
+ ])("keeps unrelated strings plain: %s", async (doc) => {
+ expect(tokens(EditorState.create({ doc, extensions: await inlineSupport() }))).not.toContain("div:tag");
+ });
+
+ it("keeps ordinary TypeScript projects plain", async () => {
+ const support = await loadSupport(findLanguageDescription("TypeScript", "/p/app.ts")!);
+ expect(
+ tokens(EditorState.create({ doc: "@Component({ template: `
` }) class App {}", extensions: support! })),
+ ).not.toContain("div:tag");
+ });
+
+ it("supports quoted metadata keys and namespace decorators", async () => {
+ const state = EditorState.create({
+ doc: '@ng.Component({ "template": `
` }) class App {}',
+ extensions: await inlineSupport(),
+ });
+ expect(tokens(state)).toContain("r-card:tag");
+ });
+
+ it.each([
+ "@Component(/* metadata */ { template /* key */ : /* value */ `
` }) class App {}",
+ "@Component({ template: `
{
+ expect(tokens(EditorState.create({ doc, extensions: await inlineSupport() }))).toContain("div:tag");
+ });
+
+ it("matches a fresh parse after editing templates and decorator context", async () => {
+ const extensions = await inlineSupport();
+ let state = EditorState.create({
+ doc: "@Component({ template: `
{{ name }}
` }) class App {}",
+ extensions,
+ });
+ for (const [search, replacement] of [
+ ["name", "user.name"],
+ ["Component", "Other"],
+ ["Other", "Component"],
+ ["template:", "other:"],
+ ]) {
+ const from = state.doc.toString().indexOf(search!);
+ state = state.update({ changes: { from, to: from + search!.length, insert: replacement } }).state;
+ expect(tokens(state)).toEqual(tokens(EditorState.create({ doc: state.doc, extensions })));
+ }
+ });
+});
+
+describe.each(["inline", "external"])("Angular tag closing (%s)", (mode) => {
+ async function support() {
+ return mode === "inline" ? inlineSupport() : angular();
+ }
+ function wrap(template: string) {
+ return mode === "inline" ? `@Component({ template: \`${template}\` }) class App {}` : template;
+ }
+
+ it.each([
+ ["
", "
"],
+ ["
<|", "/", "
"],
+ ["
"],
+ ["
", "/", ""],
+ ["", ""],
+ ['", "/", ""],
+ ["{{ count |}}", "/", "{{ count /}}"],
+ ])("handles %s + %s", async (before, text, after) => {
+ const view = editor(wrap(before), await support());
+ try {
+ type(view, text);
+ expect(view.state.doc.toString()).toBe(wrap(after));
+ } finally {
+ view.destroy();
+ }
+ });
+
+ it("closes tags at multiple cursors in one undoable input", async () => {
+ const doc = wrap(" EditorSelection.cursor(p));
+ const view = new EditorView({
+ state: EditorState.create({
+ doc,
+ selection: EditorSelection.create(ranges),
+ extensions: [await support(), history(), EditorState.allowMultipleSelections.of(true)],
+ }),
+ });
+ try {
+ type(view, "/");
+ expect(view.state.doc.toString()).toBe(wrap("\n"));
+ expect(undo(view)).toBe(true);
+ expect(view.state.doc.toString()).toBe(doc);
+ } finally {
+ view.destroy();
+ }
+ });
+});
diff --git a/src/editor/core/cm/angular/inline.ts b/src/editor/core/cm/angular/inline.ts
new file mode 100644
index 00000000..5e496fd6
--- /dev/null
+++ b/src/editor/core/cm/angular/inline.ts
@@ -0,0 +1,47 @@
+import { LanguageSupport, LRLanguage } from "@codemirror/language";
+import { javascript } from "@codemirror/lang-javascript";
+import { parseMixed, type Input, type NestedParse, type SyntaxNode, type SyntaxNodeRef } from "@lezer/common";
+import { angular, angularLanguage } from "./index";
+
+function nextCode(node: SyntaxNode): SyntaxNode | null {
+ let next = node.nextSibling;
+ while (next && (next.name === "BlockComment" || next.name === "LineComment")) next = next.nextSibling;
+ return next;
+}
+
+function mixTemplate(node: SyntaxNodeRef, input: Input): NestedParse | null {
+ if (node.name !== "Decorator") return null;
+ const call = node.node.getChild("CallExpression");
+ const callee = call?.firstChild;
+ const name = callee?.name === "MemberExpression" ? callee.lastChild : callee;
+ if (!name || !["VariableName", "PropertyName"].includes(name.name) || input.read(name.from, name.to) !== "Component")
+ return null;
+ const args = call?.getChild("ArgList");
+ const metadata = args?.firstChild && nextCode(args.firstChild);
+ if (metadata?.name !== "ObjectExpression") return null;
+ for (const property of metadata.getChildren("Property")) {
+ const key = property.firstChild;
+ if (!key || !["template", "'template'", '"template"'].includes(input.read(key.from, key.to))) continue;
+ const colon = nextCode(key);
+ const value = colon?.name === ":" ? nextCode(colon) : null;
+ if (!value || nextCode(value) || !["String", "TemplateString"].includes(value.name)) continue;
+ // A dynamic JavaScript template retains its interpolation expressions.
+ if (value.getChild("Interpolation")) continue;
+ const quote = input.read(value.from, value.from + 1);
+ const from = value.from + 1;
+ const to = value.to - (!value.lastChild?.type.isError && input.read(value.to - 1, value.to) === quote ? 1 : 0);
+ if (from < to) return { parser: angularLanguage.parser, overlay: [{ from, to }] };
+ }
+ return null;
+}
+
+const typescript = javascript({ typescript: true });
+
+export function angularTypescript(): LanguageSupport {
+ // Mount on the decorator so changing its name or metadata invalidates the template too.
+ const language = (typescript.language as LRLanguage).configure(
+ { wrap: parseMixed(mixTemplate) },
+ "angular-typescript",
+ );
+ return new LanguageSupport(language, [typescript.support, angular().support]);
+}
diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts
index a323060c..51f06c17 100644
--- a/src/editor/core/cm/languages.ts
+++ b/src/editor/core/cm/languages.ts
@@ -113,6 +113,10 @@ const OWN_MODES = [
},
}),
),
+ LanguageDescription.of({
+ name: "Angular TypeScript",
+ load: () => import("./angular/inline").then((m) => m.angularTypescript()),
+ }),
LanguageDescription.of({ name: "Angular Template", load: () => import("./angular").then((m) => m.angular()) }),
LanguageDescription.of({
name: "ERB",
@@ -274,6 +278,7 @@ export const JAVA_TAGS: Parameters[0] = {
const EXTRA_TAGS: Record[0]> = {
JavaScript: JS_TAGS,
TypeScript: JS_TAGS,
+ "Angular TypeScript": JS_TAGS,
JSX: JS_TAGS,
TSX: JS_TAGS,
Java: JAVA_TAGS,
diff --git a/src/editor/core/model/document.test.ts b/src/editor/core/model/document.test.ts
index 9ba5feeb..0700d97d 100644
--- a/src/editor/core/model/document.test.ts
+++ b/src/editor/core/model/document.test.ts
@@ -231,6 +231,20 @@ describe("DocumentManager + slots", () => {
expect(slot.currentState().facet(languageFacet)?.name).not.toBe("angular");
});
+ it.each(["ts", "mts", "cts"])("loads Angular inline templates for .%s documents", async (ext) => {
+ const path = `/p/src/app.${ext}`;
+ const { manager } = setup({
+ "/p/angular.json": "{}",
+ [path]: "@Component({ template: `` }) class App {}",
+ });
+ const model = await manager.acquire(path);
+ const slot = new EditorSlot(model, "t1", host);
+ expect(model.getMeta()).toMatchObject({ language: "typescript", uiMode: "Angular TypeScript" });
+ expect(slot.currentState().facet(languageFacet)?.name).toBe("angular-typescript");
+ slot.dispose();
+ manager.stop();
+ });
+
it("Save All rejects a failed document save after saving the remaining dirty documents", async () => {
const { backend, manager } = setup({ "/p/a.txt": "a\n", "/p/b.txt": "b\n" }, { files: { autoSave: false } });
try {