mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 22:02:49 +08:00
feat(angular): a template's event bindings call the component's methods (#2114)
An Angular handler's only caller is its template: `(click)="toggleFavorite()"`,
`(ngSubmit)="submitForm()"`. With no edge, the method showed no callers,
impact stopped at it, and the Steps picture of a screen reached its
handlers only through class containment, with nothing to say what fires
them.
The template pass now reads each `(event)="…"` binding — not
`[(ngModel)]`'s two-way half — and links the component to every method of
its own the statement calls (`save()`, `this.toggle(x)`; not
`closed.emit()` or `form.reset()`), as a `calls` edge
(`synthesizedBy: 'angular-event'`) carrying the binding as
`metadata.trigger`: `{ kind: 'prop', name: '(click)', of: 'button' }`.
The binding is in the template, so it cannot be read back from the source
at the edge's line; Steps now takes a trigger an edge carries before
reading one at the site.
angular-realworld: 21 binding sites → 17 edges (3 repeats of a method and
event, 1 `delete.emit(true)`); Steps for /article/:slug draws
deleteArticle on (click) · <button> → DELETE and back to /, addComment on
(ngSubmit) · <form>, the favorite button's toggle → POST/DELETE or
/register. Ghostfolio 225 edges of 302 sites, ngx-admin 75 of 100 — the
rest are `$event.stopPropagation()`, output emits, assignments and
repeats. Controls (koel, proshop) byte-identical.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* Angular templates: what a component renders and where it links.
|
||||
*
|
||||
* An Angular component's markup is a template — a `templateUrl` file beside
|
||||
* it, or an inline `template:` string — that the index never parses, so two
|
||||
* it, or an inline `template:` string — that the index never parses, so three
|
||||
* things a reader relies on were missing from the graph:
|
||||
*
|
||||
* - **The component tree.** `<app-article-list [config]="listConfig">` in the
|
||||
@@ -18,6 +18,12 @@
|
||||
* `routerLinkAbout = publicRoutes.about.routerLink`) each become a
|
||||
* `navigates` edge from the component to the route it names.
|
||||
*
|
||||
* - **Event bindings.** `(click)="toggleFavorite()"` is the only caller a
|
||||
* handler method has. A `calls` edge from the component to its own method
|
||||
* (`synthesizedBy: 'angular-event'`) carries the binding as its
|
||||
* `trigger`, the label Steps puts on the hop — read from the template, so
|
||||
* it cannot be read back from the source at the edge's line.
|
||||
*
|
||||
* A child is matched by its element selector (`selector: 'app-article-list'`),
|
||||
* in the same app first; a selector two components share there is left
|
||||
* unmatched. A relative link and a destination no route serves draw nothing.
|
||||
@@ -132,6 +138,19 @@ export function angularComponents(ctx: ResolutionContext): AngularComponent[] {
|
||||
|
||||
/** `routerLink="…"` (plain) and `[routerLink]="…"` (bound), either quote. `routerLinkActive` is neither. */
|
||||
const ROUTER_LINK = /(\[routerLink\]|\brouterLink)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
||||
/** `(click)="save()"` / `(ngSubmit)="submitForm()"` — an event binding, not `[(ngModel)]`'s two-way half. */
|
||||
const EVENT_BINDING = /(?<!\[)\(([A-Za-z][\w.-]*)\)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
||||
/** A call a template statement makes on its component: `save()`, `this.toggle(item)` — not `form.reset()`. */
|
||||
const OWN_CALL = /(?<![\w$.])(?:this\s*\.\s*)?([A-Za-z_$][\w$]*)\s*\(/g;
|
||||
|
||||
/** The element a binding at `at` is written on: the last opening tag before it. */
|
||||
function elementAt(text: string, at: number): string | null {
|
||||
const open = text.lastIndexOf('<', at);
|
||||
if (open < 0) return null;
|
||||
const tag = /^<([a-zA-Z][\w-]*)/.exec(text.slice(open, open + 64));
|
||||
return tag ? tag[1]! : null;
|
||||
}
|
||||
|
||||
/** A `routerLink:` field of an object literal written in a class. */
|
||||
const ROUTER_LINK_FIELD = /(?<![\w$.])routerLink\s*:\s*/g;
|
||||
|
||||
@@ -228,6 +247,47 @@ export async function angularTemplateEdges(ctx: ResolutionContext, onYield: Mayb
|
||||
});
|
||||
}
|
||||
|
||||
// Event bindings: `(click)="toggleFavorite()"` runs the component's own
|
||||
// method when the user acts. The binding is the trigger Steps draws —
|
||||
// known only from the template, so it rides on the edge.
|
||||
const members = new Map(
|
||||
ctx
|
||||
.getNodesInFile(component.file)
|
||||
.filter((n) => n.kind === 'method' && n.qualifiedName.startsWith(`${component.node.qualifiedName}::`))
|
||||
.map((n) => [n.name, n])
|
||||
);
|
||||
let handlers = 0;
|
||||
EVENT_BINDING.lastIndex = 0;
|
||||
let ev: RegExpExecArray | null;
|
||||
while ((ev = EVENT_BINDING.exec(text)) !== null && handlers < MAX_CHILDREN_PER_COMPONENT) {
|
||||
const statement = ev[2] ?? ev[3] ?? '';
|
||||
const event = `(${ev[1]!})`;
|
||||
const element = elementAt(text, ev.index);
|
||||
OWN_CALL.lastIndex = 0;
|
||||
let c: RegExpExecArray | null;
|
||||
while ((c = OWN_CALL.exec(statement)) !== null) {
|
||||
const method = members.get(c[1]!);
|
||||
if (!method) continue;
|
||||
const key = `${component.node.id}>${method.id}>${event}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
handlers++;
|
||||
edges.push({
|
||||
source: component.node.id,
|
||||
target: method.id,
|
||||
kind: 'calls',
|
||||
line: siteLine(ev.index),
|
||||
provenance: 'heuristic',
|
||||
metadata: {
|
||||
synthesizedBy: 'angular-event',
|
||||
via: event,
|
||||
registeredAt: registeredAt(ev.index),
|
||||
trigger: { kind: 'prop', name: event, of: element },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const routes = angularRoutesFor(table, component.file);
|
||||
if (!routes || routes.exact.size === 0) continue;
|
||||
let links = 0;
|
||||
|
||||
@@ -394,6 +394,13 @@ interface StepRecord extends WireStep {
|
||||
root: Node | null;
|
||||
}
|
||||
|
||||
/** A trigger an edge carries in its metadata (a template binding), when it has one. */
|
||||
function siteTriggerOf(meta: Record<string, unknown>): SiteTrigger | null {
|
||||
const t = meta.trigger as Partial<SiteTrigger> | undefined;
|
||||
if (!t || typeof t !== 'object' || t.kind !== 'prop' || typeof t.name !== 'string') return null;
|
||||
return { kind: 'prop', name: t.name, of: typeof t.of === 'string' ? t.of : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The string a Java / Kotlin return expression builds, when it starts with a
|
||||
* literal: `"owners/ownerDetails"`, `"redirect:/owners/" + owner.getId()` →
|
||||
@@ -1296,7 +1303,10 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
// every call-shaped hop, so a store action or an effect fired by
|
||||
// a tap says so on its link too.
|
||||
const isCall = e.kind === 'calls' || e.kind === 'instantiates' || (e.kind === 'references' && meta.fnRef === true);
|
||||
const trigger = isCall ? await triggerAt(fold.node, { line: e.line, column: e.column }) : null;
|
||||
// An Angular template's `(click)` binding rides on its edge: the
|
||||
// template is a file of its own, not the source at `e.line`.
|
||||
const carried = siteTriggerOf(meta);
|
||||
const trigger = carried ? { ...carried, in: fold.node.name } : isCall ? await triggerAt(fold.node, { line: e.line, column: e.column }) : null;
|
||||
|
||||
// A server action, by its directive: a function in a `'use server'`
|
||||
// file (or opening with the directive) called from a file that is
|
||||
|
||||
Reference in New Issue
Block a user