import ts from "typescript"; import type { DataFlowGraph, DataFlowNode } from "./dataflow.ts"; import { getExpressionText } from "./utils.ts"; export interface NormalizedDataFlow { readonly canonicalKey: string; readonly expression: ts.Expression; readonly occurrences: readonly DataFlowNode[]; readonly scopeId: number; } export interface NormalizedDataFlowSet { readonly all: readonly NormalizedDataFlow[]; readonly byCanonicalKey: ReadonlyMap; } export function normalizeDataFlows( graph: DataFlowGraph, requestedDataFlows?: ts.Expression[], ): NormalizedDataFlowSet { const nodesById = new Map(); for (const node of graph.nodes) nodesById.set(node.id, node); // If specific dataFlows were requested, only process nodes corresponding to those expressions // This prevents suppressing nodes that are explicitly needed as dependencies let nodesToProcess = graph.nodes; if (requestedDataFlows && requestedDataFlows.length > 0) { const requestedTexts = new Set( requestedDataFlows.map((expr) => getExpressionText(expr)), ); nodesToProcess = graph.nodes.filter((node) => requestedTexts.has(getExpressionText(node.expression)) ); } const grouped = new Map(); const nodeToGroup = new Map(); const normalizeExpression = (node: DataFlowNode): ts.Expression => { let current: ts.Expression = node.expression; // Only normalize away truly meaningless wrappers that don't change semantics while (true) { // Remove parentheses - purely syntactic, no semantic difference if (ts.isParenthesizedExpression(current)) { current = current.expression; continue; } // Remove type assertions - don't affect runtime behavior if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { current = current.expression; continue; } // Remove non-null assertions - don't affect runtime behavior if (ts.isNonNullExpression(current)) { current = current.expression; continue; } // Special case: for method calls like obj.method(), we need to normalize // back to the object so the transformation can wrap it properly // e.g., state.user.name.toUpperCase() -> state.user.name if (ts.isCallExpression(current)) { const callee = current.expression; if (ts.isPropertyAccessExpression(callee)) { // This is a method call - normalize to the object current = callee.expression; continue; } } // Also handle property access when it's being called as a method // e.g., when we see state.user.name.toUpperCase (without the call), // but it's the callee of a call expression if (ts.isPropertyAccessExpression(current)) { if ( current.parent && ts.isCallExpression(current.parent) && current.parent.expression === current ) { // This property is being called as a method current = current.expression; continue; } } // That's it! Keep all other meaningful distinctions: // - state.items vs state.items.length (different reactive dependencies) // - array[0] vs array (different values) break; } return current; }; for (const node of nodesToProcess) { const expression = normalizeExpression(node); const key = `${node.scopeId}:${getExpressionText(expression)}`; let group = grouped.get(key); if (!group) { group = { expression, nodes: [], scopeId: node.scopeId, }; grouped.set(key, group); } group.nodes.push(node); nodeToGroup.set(node.id, key); } const suppressed = new Set(); // Parent suppression: suppress parents that have more specific children // BUT: If we're working with explicitly requested dataFlows, don't suppress any of them // They were all explicitly requested as dependencies if (!requestedDataFlows || requestedDataFlows.length === 0) { for (const [canonicalKey, group] of grouped.entries()) { // Check if any node in this group has an explicit child // If so, this parent should be suppressed in favor of the more specific child for (const node of group.nodes) { let hasExplicitChild = false; // Check all nodes to see if any child is explicit for (const potentialChild of graph.nodes) { if ( potentialChild.parentId === node.id && potentialChild.isExplicit ) { hasExplicitChild = true; break; } } if (hasExplicitChild) { suppressed.add(canonicalKey); break; } } } } const filtered = Array.from(grouped.entries()) .filter(([canonicalKey]) => !suppressed.has(canonicalKey)); const all: NormalizedDataFlow[] = filtered.map(([canonicalKey, value]) => ({ canonicalKey, expression: value.expression, occurrences: value.nodes, scopeId: value.scopeId, })).sort((a, b) => { const aId = a.occurrences[0]?.id ?? -1; const bId = b.occurrences[0]?.id ?? -1; return aId - bId; }); return { all, byCanonicalKey: new Map(all.map((dependency) => [ dependency.canonicalKey, dependency, ])), }; } const isWithin = (outer: ts.Node, inner: ts.Node): boolean => { return inner.pos >= outer.pos && inner.end <= outer.end; }; export function selectDataFlowsWithin( set: NormalizedDataFlowSet, node: ts.Node, ): NormalizedDataFlow[] { return set.all.filter((dataFlow) => dataFlow.occurrences.some((occurrence) => isWithin(node, occurrence.expression) ) ); } /** * Selects data flows that are referenced (used) within a node, based on expression text matching. * This is complementary to selectDataFlowsWithin, which uses position-based checking. * * This is particularly useful for finding parameter references where the parameter declaration * is outside the node (e.g., in conditional branches of ternary expressions). * * @param set The normalized data flow set to filter * @param node The node to check for references within * @returns Data flows whose expressions are referenced within the node */ export function selectDataFlowsReferencedIn( set: NormalizedDataFlowSet, node: ts.Node, ): NormalizedDataFlow[] { const referencedExpressions = new Set(); // Find all expressions used in the node const visit = (n: ts.Node) => { if (ts.isExpression(n)) { referencedExpressions.add(getExpressionText(n)); } ts.forEachChild(n, visit); }; visit(node); // Return data flows whose expression text matches any referenced expression return set.all.filter((dataFlow) => { const flowExprText = getExpressionText(dataFlow.expression); return referencedExpressions.has(flowExprText); }); }