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 <[email protected]>
This commit is contained in:
Jaw authored and GitHub committed 2026-11-07 11:13:09 +08:00
1 parent 24ffb9f16e
commit 352efc02a7
9 files changed
+351 -9

No files matched your search

+8 -2
View File
@@ -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")));
}
+62
View File
@@ -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: `<div [title]="name">{{ name }}</div>\n @if (ready) { <p>Ready</p> }\n <r-card\n <section\n <article><\n `\n})\nexport class App {}\n',
);
files.set("/dev/acme-api/app.html", "<r-card\n<div\n<article><");
files.set("/dev/acme-api/plain.ts", "const data = { template: `<r-card/>` };");
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 [
["<r-card", "/", "<r-card/>"],
["<section", ">", "<section></section>"],
["<article><", "/", "<article></article>"],
]) {
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 [
["<r-card", "/", "<r-card/>"],
["<div", ">", "<div></div>"],
["<article><", "/", "<article></article>"],
]) {
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);
});
+4
View File
@@ -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
+17 -7
View File
@@ -119,6 +119,8 @@ interface MemDoc {
const EXT_LANG: Record<string, string> = {
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<string, string> = {}): 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` });
+33
View File
@@ -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: ["[", "{", '"'] },
+161
View File
@@ -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 = '<div [title]="name">{{ name }} @if (ready) {} @for (x of xs; track x) {} @let y = count;</div>';
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: `<div>{{ name }}</div>` };",
"@Other({ template: `<div>{{ name }}</div>` }) class App {}",
"@Component({ nested: { template: `<div>{{ name }}</div>` } }) class App {}",
"@Component({ templateUrl: `<div>{{ name }}</div>` }) class App {}",
"Component({ template: `<div>{{ name }}</div>` });",
"@Component({ template: `<div>${name}</div>` }) class App {}",
"@Component({ template: `<div>` + 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: `<div/>` }) 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": `<r-card/>` }) class App {}',
extensions: await inlineSupport(),
});
expect(tokens(state)).toContain("r-card:tag");
});
it.each([
"@Component(/* metadata */ { template /* key */ : /* value */ `<div/>` }) class App {}",
"@Component({ template: `<div",
"@Component({ template: '<div",
])("highlights unfinished and commented templates: %s", async (doc) => {
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: `<div>{{ name }}</div>` }) 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([
["<div|", ">", "<div></div>"],
["<div><|", "/", "<div></div>"],
["<r-card|", "/", "<r-card/>"],
["<r-card|>", "/", "<r-card/>"],
["<input|", ">", "<input>"],
['<r-card title="a/b|"', "/", '<r-card title="a/b/"'],
["<!-- <r-card| -->", "/", "<!-- <r-card/ -->"],
["{{ 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("<r-card\n<app-item");
const ranges = [doc.indexOf("<r-card") + 7, doc.indexOf("<app-item") + 9].map((p) => 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("<r-card/>\n<app-item/>"));
expect(undo(view)).toBe(true);
expect(view.state.doc.toString()).toBe(doc);
} finally {
view.destroy();
}
});
});
+47
View File
@@ -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]);
}
+5
View File
@@ -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<typeof styleTags>[0] = {
const EXTRA_TAGS: Record<string, Parameters<typeof styleTags>[0]> = {
JavaScript: JS_TAGS,
TypeScript: JS_TAGS,
"Angular TypeScript": JS_TAGS,
JSX: JS_TAGS,
TSX: JS_TAGS,
Java: JAVA_TAGS,
+14
View File
@@ -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: `<r-card/>` }) 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 {