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 {