mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
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:
9 files changed
+350
-8
No files matched your search
@@ -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")));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
// 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` });
|
||||
|
||||
@@ -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: ["[", "{", '"'] },
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user