import {
AST,
ASTWithSource,
Binary,
Call,
ImplicitReceiver,
ParseError,
parseTemplate,
PropertyRead,
RecursiveAstVisitor,
ThisReceiver,
TmplAstBoundAttribute,
TmplAstBoundDeferredTrigger,
TmplAstBoundEvent,
TmplAstBoundText,
TmplAstDeferredBlock,
TmplAstDeferredBlockTriggers,
TmplAstElement,
TmplAstForLoopBlock,
TmplAstIfBlock,
TmplAstLetDeclaration,
TmplAstNode,
TmplAstRecursiveVisitor,
TmplAstSwitchBlock,
TmplAstTemplate,
} from '@angular/compiler';
import MagicString from 'magic-string';
import { ALL_MEMBER_NAMES, FUNCTION_VALUED_MEMBERS, MEMBERS_BY_SELECTOR } from './input-signal-targets';
import { toSnippet } from './manual-review';
export interface TemplateMemberSet {
members: Set<string>;
}
export interface TemplateManualItem {
/** 1-based within the template text; the caller maps it onto the containing file. */
line: number;
snippet: string;
reason: string;
}
export interface TemplateMigrationResult {
text: string | null;
manualItems: TemplateManualItem[];
}
const TEMPLATE_MANUAL_REASONS = {
readonlyWrite: 'assignment to a signal input — signal inputs are read-only (no setter); resolve manually',
twoWayBinding: 'two-way binding to a signal input — signal inputs are read-only; bind the input one-way and emit the change back instead',
} as const;
/** Angular's parse messages can carry a long trailing URL. */
const MAX_PARSE_MESSAGE_LENGTH = 160;
/** Insert `()` after a member name. Writes are never rewritten, so there is no write patch. */
interface TemplatePatch {
readEnd: number;
}
// Comparison operators also end in `=` but are not assignments.
const COMPARISON_OPERATORS = new Set(['==', '===', '!=', '!==', '<=', '>=']);
/**
* Records the reads of a single binding expression that need unwrapping: reads on the component
* instance (implicit receiver or `this`) whose name is a host member, and reads through a
* template reference variable bound to a target element (`<pfe-error-message #err>{{ err.message }}`).
*/
class ExpressionVisitor extends RecursiveAstVisitor {
constructor(
private readonly members: TemplateMemberSet,
private readonly refVarMembers: ReadonlyMap<string, ReadonlySet<string>>,
private readonly getScopedLocals: () => string[],
private readonly patches: TemplatePatch[],
private readonly manualOffsets: ManualOffset[]
) {
super();
}
override visitBinary(ast: Binary, context: unknown): void {
// The LHS of an assignment must never be unwrapped as a read (`member() = rhs`).
if (isAssignment(ast.operation) && ast.left instanceof PropertyRead && (this.isTargetRead(ast.left) || this.isRefVarRead(ast.left))) {
this.manualOffsets.push({
start: ast.sourceSpan.start,
end: ast.sourceSpan.end,
reason: TEMPLATE_MANUAL_REASONS.readonlyWrite,
});
// Still migrate reads inside the right-hand side.
ast.right.visit(this, context);
return;
}
super.visitBinary(ast, context);
}
override visitPropertyRead(ast: PropertyRead, context: unknown): void {
// A ref-var read (`err.message`) is itself a PropertyRead whose receiver is the ref-var read
// (`err`). Handle it before descending, so that receiver is not visited as a candidate read.
if (this.isRefVarRead(ast)) {
this.patches.push({ readEnd: ast.nameSpan.end });
return;
}
// Receiver first, so nested reads (`a.b.member`) are handled.
ast.receiver.visit(this, context);
if (this.isTargetRead(ast)) {
this.patches.push({ readEnd: ast.nameSpan.end });
}
}
override visitCall(ast: Call, context: unknown): void {
// An already-migrated read is a Call whose receiver is the target PropertyRead (`member()`);
// don't unwrap that inner read again. A function-valued input is deliberately excluded: the
// old code already called it to invoke the callback (`nxNextAction()`), and after the
// migration reading the signal and invoking the value are two calls (`nxNextAction()()`).
// An existing `()` therefore does NOT mean "already migrated" for those members, which is
// why this migration is not idempotent.
if (ast.receiver instanceof PropertyRead && !FUNCTION_VALUED_MEMBERS.has(ast.receiver.name)) {
if (this.isTargetRead(ast.receiver)) {
ast.receiver.receiver.visit(this, context);
ast.args.forEach((arg) => arg.visit(this, context));
return;
}
if (this.isRefVarRead(ast.receiver)) {
ast.args.forEach((arg) => arg.visit(this, context));
return;
}
}
super.visitCall(ast, context);
}
private isTargetRead(ast: PropertyRead): boolean {
// The component instance is the implicit receiver (`member`) or `this.member`.
const onComponentInstance = ast.receiver instanceof ImplicitReceiver || ast.receiver instanceof ThisReceiver;
if (!onComponentInstance) return false;
if (!this.members.members.has(ast.name)) return false;
// Shadowed by a template-local of the same name → not the component member.
return !this.getScopedLocals().includes(ast.name);
}
private isRefVarRead(ast: PropertyRead): boolean {
if (!(ast.receiver instanceof PropertyRead)) return false;
const receiver = ast.receiver;
// The ref var itself is read off the component instance (implicit receiver).
if (!(receiver.receiver instanceof ImplicitReceiver || receiver.receiver instanceof ThisReceiver)) {
return false;
}
if (this.getScopedLocals().includes(receiver.name)) return false;
return this.refVarMembers.get(receiver.name)?.has(ast.name) ?? false;
}
}
/**
* Walks the `TmplAst*` node tree, descending into every place an expression can live —
* including structural directives and control-flow blocks, which the default recursive visitor
* does not enter — and tracks the template-locals in scope so shadowed names are ignored.
*/
class TemplateReferenceVisitor extends TmplAstRecursiveVisitor {
private readonly scopedLocals: string[] = [];
constructor(
private readonly members: TemplateMemberSet,
private readonly refVarMembers: ReadonlyMap<string, ReadonlySet<string>>,
private readonly patches: TemplatePatch[],
private readonly manualOffsets: ManualOffset[]
) {
super();
}
override visitBoundText(text: TmplAstBoundText): void {
this.walkExpression(text.value);
}
override visitBoundAttribute(attribute: TmplAstBoundAttribute): void {
this.handleBoundAttribute(attribute);
}
override visitBoundEvent(event: TmplAstBoundEvent): void {
// A two-way binding synthesizes a change event whose handler is the bare member; the paired
// input is reported in visitBoundAttribute, so skip the synthesized event.
if (isTwoWayEvent(event)) return;
this.walkExpression(event.handler);
}
override visitTemplate(template: TmplAstTemplate): void {
// Structural directives (`*ngFor`, `*ngIf`) desugar onto a Template node. The bound
// expressions live in `templateAttrs`; local bindings that shadow component members live
// in `variables` / `references`.
const introduced = [...template.variables.map((variable) => variable.name), ...template.references.map((reference) => reference.name)];
this.withLocals(introduced, () => {
for (const attribute of template.templateAttrs) {
if (attribute instanceof TmplAstBoundAttribute) this.walkExpression(attribute.value);
}
// Route inputs/outputs through `visit` rather than walking their expressions directly, so
// the two-way-binding guards apply here too: walking `outputs` directly would migrate the
// bare member in the synthesized change event of `<input *ngIf="c" [(ngModel)]="message">`.
for (const input of template.inputs) input.visit(this);
for (const output of template.outputs) output.visit(this);
template.children.forEach((child) => child.visit(this));
});
}
/**
* `[message]="expr"` migrates the value side only — a signal input is bound exactly like a
* decorator input, so an attribute *name* is never rewritten. A two-way binding splits:
* `[(x)]="member"` targets the read-only input itself and is only reported, while
* `[(x)]="member.prop"` targets a sub-property, so `member` is read and gets unwrapped.
*/
private handleBoundAttribute(attribute: TmplAstBoundAttribute): void {
if (!isTwoWayBinding(attribute)) {
this.walkExpression(attribute.value);
return;
}
const ast = attribute.value instanceof ASTWithSource ? attribute.value.ast : attribute.value;
if (ast instanceof PropertyRead && this.isTargetRead(ast)) {
this.manualOffsets.push({
start: attribute.sourceSpan.start.offset,
end: attribute.sourceSpan.end.offset,
reason: TEMPLATE_MANUAL_REASONS.twoWayBinding,
});
return;
}
this.walkExpression(attribute.value);
}
override visitForLoopBlock(block: TmplAstForLoopBlock): void {
// `@for (item of expr; track ...)` — `expr` is evaluated in the outer scope, but `item` and
// the implicit context variables shadow members in the body.
this.walkExpression(block.expression);
const introduced = [block.item.name, ...block.contextVariables.map((variable) => variable.name)];
this.withLocals(introduced, () => {
this.walkExpression(block.trackBy);
block.children.forEach((child) => child.visit(this));
});
// `@empty` is a sibling view (no loop variables in scope) but can declare its own `@let`.
if (block.empty) this.withScope(() => block.empty?.visit(this));
}
override visitIfBlock(block: TmplAstIfBlock): void {
for (const branch of block.branches) {
if (branch.expression) this.walkExpression(branch.expression);
// `@if (expr; as alias)` binds `alias` inside the branch body.
const introduced = branch.expressionAlias ? [branch.expressionAlias.name] : [];
this.withLocals(introduced, () => {
branch.children.forEach((child) => child.visit(this));
});
}
}
override visitSwitchBlock(block: TmplAstSwitchBlock): void {
this.walkExpression(block.expression);
// `@case` expressions live under `groups[].cases`, their bodies under `groups[].children`.
// Each body is its own view, so a `@let` in it must not leak to a sibling case.
for (const group of block.groups) {
for (const switchCase of group.cases) {
if (switchCase.expression) this.walkExpression(switchCase.expression);
}
this.withScope(() => group.children.forEach((child) => child.visit(this)));
}
}
override visitDeferredBlock(block: TmplAstDeferredBlock): void {
// `@defer (when expr; prefetch when expr; hydrate when expr)` — the base recursive visitor
// treats triggers as no-ops, so a `when` expression referencing an input member is never
// unwrapped. Since the member became a getter *function*, an un-unwrapped trigger is always
// truthy (the block loads eagerly).
this.walkDeferredTriggers(block.triggers);
this.walkDeferredTriggers(block.prefetchTriggers);
this.walkDeferredTriggers(block.hydrateTriggers);
// The main block and each of `@placeholder`/`@loading`/`@error` is its own view, but the
// base visitor walks them in a shared scope — so isolate each one.
this.withScope(() => block.children.forEach((child) => child.visit(this)));
if (block.placeholder) this.withScope(() => block.placeholder?.visit(this));
if (block.loading) this.withScope(() => block.loading?.visit(this));
if (block.error) this.withScope(() => block.error?.visit(this));
}
private walkDeferredTriggers(triggers: TmplAstDeferredBlockTriggers): void {
// Only `when` (a BoundDeferredTrigger) carries a bindable expression; `on` triggers
// (idle/timer/hover/…) never reference component members.
const when: TmplAstBoundDeferredTrigger | undefined = triggers.when;
if (when) this.walkExpression(when.value);
}
override visitLetDeclaration(declaration: TmplAstLetDeclaration): void {
// The `@let name = value;` initializer is evaluated before `name` is bound.
this.walkExpression(declaration.value);
if (this.isShadowable(declaration.name)) this.scopedLocals.push(declaration.name);
}
/**
* Truncating to the saved depth (rather than popping a fixed count) is what makes `@let`
* scoping correct: `visitLetDeclaration` pushes onto the shared stack, so without this a
* block-scoped `@let` shadow would leak to later siblings.
*/
private withScope(run: () => void): void {
const savedDepth = this.scopedLocals.length;
try {
run();
} finally {
this.scopedLocals.length = savedDepth;
}
}
private withLocals(names: string[], run: () => void): void {
this.withScope(() => {
for (const name of names) {
if (this.isShadowable(name)) this.scopedLocals.push(name);
}
run();
});
}
private isShadowable(name: string): boolean {
return this.members.members.has(name) || this.refVarMembers.has(name);
}
private walkExpression(value: AST | null | undefined): void {
if (!value) return;
const ast = value instanceof ASTWithSource ? value.ast : value;
if (!ast) return;
ast.visit(new ExpressionVisitor(this.members, this.refVarMembers, () => this.scopedLocals, this.patches, this.manualOffsets));
}
/** Mirror of ExpressionVisitor.isTargetRead, used for two-way binding detection. */
private isTargetRead(ast: PropertyRead): boolean {
const onComponentInstance = ast.receiver instanceof ImplicitReceiver || ast.receiver instanceof ThisReceiver;
if (!onComponentInstance) return false;
if (!this.members.members.has(ast.name)) return false;
return !this.scopedLocals.includes(ast.name);
}
}
interface ManualOffset {
start: number;
end: number;
reason: string;
}
/**
* Rewrites signal-input member reads inside an Angular template (`null` when nothing changed).
*
* `hostMembers` are the inputs the backing component inherits from a target and drive the
* implicit-receiver / `this.x` reads; it is empty for a plain consumer template, whose ref-var
* reads resolve from the template itself.
*
* Reads on template-locals (`*ngFor="let x of ..."`, `@for`, `@if ... as x`, `@let x = ...`)
* shadow the component member and are skipped — the template analogue of the subclass
* member-shadowing rule on the TypeScript side.
*/
export function migrateTemplate(templateText: string, hostMembers: TemplateMemberSet): TemplateMigrationResult {
const empty: TemplateMigrationResult = { text: null, manualItems: [] };
// A ref-var read can appear in any template, so the pre-filter checks every migrated name
// rather than just the host's.
if (![...ALL_MEMBER_NAMES].some((member) => templateText.includes(member))) return empty;
const parsed = parseTemplate(templateText, 'template.html', {
// Keep the source byte-for-byte, so AST spans line up with the raw text being patched.
preserveWhitespaces: true,
preserveLineEndings: true,
});
// The template does mention an input member (pre-filter above) and an un-unwrapped read is a
// getter function, so it would silently evaluate as always-truthy — report instead.
if (parsed.errors && parsed.errors.length > 0) {
return { text: null, manualItems: [toParseErrorItem(parsed.errors[0])] };
}
const refVarMembers = collectTargetRefVars(parsed.nodes);
if (hostMembers.members.size === 0 && refVarMembers.size === 0) return empty;
const patches: TemplatePatch[] = [];
const manualOffsets: ManualOffset[] = [];
const visitor = new TemplateReferenceVisitor(hostMembers, refVarMembers, patches, manualOffsets);
parsed.nodes.forEach((node) => node.visit(visitor));
const manualItems = resolveManualItems(templateText, manualOffsets);
const text = patches.length === 0 ? null : applyPatches(templateText, patches);
return { text, manualItems };
}
/**
* Maps each ref-var name bound to a target element onto that element's members —
* `<pfe-error-message #err>` binds `err` to `{hideError, message, hideClose}`.
*
* The whole node tree is scanned up front (rather than per-scope) because a ref var is visible
* to its siblings and their descendants, so a read can legitimately precede the declaration in
* document order. A name declared on both a target and a non-target element is dropped: which
* element a read resolves to depends on view scoping we do not model.
*/
function collectTargetRefVars(nodes: TmplAstNode[]): ReadonlyMap<string, ReadonlySet<string>> {
const byName = new Map<string, ReadonlySet<string>>();
const ambiguous = new Set<string>();
const register = (tagName: string, references: { name: string; value: string }[]): void => {
const members = MEMBERS_BY_SELECTOR.get(tagName);
for (const reference of references) {
// `#x="ngModel"` points at a directive's `exportAs`, not at the component instance.
if (reference.value || !members) {
ambiguous.add(reference.name);
continue;
}
const existing = byName.get(reference.name);
if (existing && existing !== members) ambiguous.add(reference.name);
byName.set(reference.name, members);
}
};
class RefVarVisitor extends TmplAstRecursiveVisitor {
override visitElement(element: TmplAstElement): void {
register(element.name, element.references);
super.visitElement(element);
}
override visitTemplate(template: TmplAstTemplate): void {
// A ref var on an `<ng-template>` binds the TemplateRef, not a component, so it is never a
// target read — but it still has to be registered, so a same-named ref var on a real target
// element becomes ambiguous rather than silently migrated. (A structural directive such as
// `<pfe-error-message #err *ngIf>` keeps its ref var on the desugared inner element, which
// `visitElement` already covers.)
register(template.tagName ?? '', template.references);
super.visitTemplate(template);
}
}
const visitor = new RefVarVisitor();
nodes.forEach((node) => node.visit(visitor));
for (const name of ambiguous) byName.delete(name);
return byName;
}
/**
* Only the first error of a template is reported: the later ones are usually cascade noise from
* the same root cause (one unescaped `{` yields both an "unexpected EOF" and an "invalid ICU").
*/
function toParseErrorItem(error: ParseError): TemplateManualItem {
const message = toSnippet(error.msg);
const truncated = message.length > MAX_PARSE_MESSAGE_LENGTH ? `${message.slice(0, MAX_PARSE_MESSAGE_LENGTH - 1)}…` : message;
// Surround the error offset with source context, so the report stays actionable even after
// the file is edited.
const context = error.span.start.getContext(30, 1);
const snippet = context
? toSnippet(`${context.before}${context.after}`)
: (error.span.start.file.content.split('\n')[error.span.start.line]?.trim() ?? '');
return {
// `ParseLocation.line` is 0-based; manual-review items are 1-based.
line: error.span.start.line + 1,
snippet,
reason: `template could not be parsed, so it was left untouched — Angular reported: ${truncated}`,
};
}
function resolveManualItems(templateText: string, offsets: ManualOffset[]): TemplateManualItem[] {
const seen = new Set<number>();
const items: TemplateManualItem[] = [];
for (const offset of offsets) {
if (seen.has(offset.start)) continue;
seen.add(offset.start);
const line = templateText.slice(0, offset.start).split('\n').length;
const snippet = toSnippet(templateText.slice(offset.start, offset.end));
items.push({ line, snippet, reason: offset.reason });
}
return items.sort((a, b) => a.line - b.line);
}
/** Point insertions are order-independent with `appendLeft`, so no right-to-left sort is needed. */
function applyPatches(templateText: string, patches: TemplatePatch[]): string {
const magic = new MagicString(templateText);
const applied = new Set<number>();
for (const patch of patches) {
if (applied.has(patch.readEnd)) continue;
applied.add(patch.readEnd);
magic.appendLeft(patch.readEnd, '()');
}
return magic.toString();
}
function isTwoWayBinding(attribute: TmplAstBoundAttribute): boolean {
// BindingType.TwoWay === 5; compare numerically to avoid importing the enum.
return (attribute.type as unknown as number) === 5;
}
function isTwoWayEvent(event: TmplAstBoundEvent): boolean {
// ParsedEventType.TwoWay === 2; compare numerically to avoid importing the enum.
return (event.type as unknown as number) === 2;
}
/** Angular parses `=`, `+=`, `??=`, … as Binary nodes, alongside comparisons ending in `=`. */
function isAssignment(operation: string): boolean {
return operation.endsWith('=') && !COMPARISON_OPERATORS.has(operation);
}