import ts from "typescript"; import type { TransformationContext } from "../../core/mod.ts"; import type { ClosureTransformationStrategy } from "./strategy.ts"; import { isOpaqueRefType } from "../../transformers/opaque-ref/opaque-ref.ts"; import { getTypeAtLocationWithFallback, isFunctionLikeExpression, } from "../../ast/mod.ts"; import { buildHierarchicalParamsValue } from "../../utils/capture-tree.ts"; import type { CaptureTreeNode } from "../../utils/capture-tree.ts"; import { createPropertyName, normalizeBindingName, reserveIdentifier, } from "../../utils/identifiers.ts"; import { analyzeElementBinding, rewriteCallbackBody } from "./map-utils.ts"; import type { ComputedAliasInfo } from "./map-utils.ts"; import { CaptureCollector } from "../capture-collector.ts"; import { RecipeBuilder } from "../utils/recipe-builder.ts"; import { SchemaFactory } from "../utils/schema-factory.ts"; export class MapStrategy implements ClosureTransformationStrategy { canTransform( node: ts.Node, context: TransformationContext, ): boolean { return ts.isCallExpression(node) && isOpaqueRefArrayMapCall( node, context.checker, context.options.typeRegistry, context.options.logger, ); } transform( node: ts.Node, context: TransformationContext, visitor: ts.Visitor, ): ts.Node | undefined { if (!ts.isCallExpression(node)) return undefined; const callback = node.arguments[0]; if (callback && isFunctionLikeExpression(callback)) { if (shouldTransformMap(node, context)) { return transformMapCallback(node, callback, context, visitor); } } return undefined; } } /** * Helper to check if a type's type argument is an array. * Handles unions and intersections recursively, similar to isOpaqueRefType. */ function hasArrayTypeArgument( type: ts.Type, checker: ts.TypeChecker, ): boolean { // Handle unions - check if any member has an array type argument if (type.flags & ts.TypeFlags.Union) { return (type as ts.UnionType).types.some((t: ts.Type) => hasArrayTypeArgument(t, checker) ); } // Handle intersections - check if any member has an array type argument if (type.flags & ts.TypeFlags.Intersection) { return (type as ts.IntersectionType).types.some((t: ts.Type) => hasArrayTypeArgument(t, checker) ); } // Handle object types with type references (e.g., OpaqueRef) if (type.flags & ts.TypeFlags.Object) { const objectType = type as ts.ObjectType; if (objectType.objectFlags & ts.ObjectFlags.Reference) { const typeRef = objectType as ts.TypeReference; if (typeRef.typeArguments && typeRef.typeArguments.length > 0) { const innerType = typeRef.typeArguments[0]; if (!innerType) return false; // Check if inner type is an array or tuple return checker.isArrayType(innerType) || checker.isTupleType(innerType); } } } return false; } /** * Check if an expression is a derive call (synthetic or user-written). * derive() always returns OpaqueRef at runtime, but we register the * unwrapped callback return type in the type registry. This helper lets * us detect derive calls syntactically to work around that limitation. */ function isDeriveCall(expr: ts.Expression): boolean { if (!ts.isCallExpression(expr)) return false; const callee = expr.expression; // Check for `derive(...)` direct call if (ts.isIdentifier(callee) && callee.text === "derive") { return true; } // Check for `__ctHelpers.derive(...)` qualified call if ( ts.isPropertyAccessExpression(callee) && callee.name.text === "derive" ) { return true; } return false; } /** * Checks if this is an OpaqueRef or Cell map call. * Only transforms map calls on reactive arrays (OpaqueRef/Cell), not plain arrays. */ export function isOpaqueRefArrayMapCall( node: ts.CallExpression, checker: ts.TypeChecker, typeRegistry?: WeakMap, logger?: (message: string) => void, ): boolean { // Check if this is a property access expression with name "map" if (!ts.isPropertyAccessExpression(node.expression)) return false; if (node.expression.name.text !== "map") return false; // Get the type of the target (what we're calling .map on) const target = node.expression.expression; // Special case: derive() always returns OpaqueRef at runtime. // We can't register OpaqueRef in the type registry (only the unwrapped T), // so detect derive calls syntactically. if (isDeriveCall(target)) { return true; } const targetType = getTypeAtLocationWithFallback( target, checker, typeRegistry, logger, ); if (!targetType) { return false; } // Type-based check: target is OpaqueRef or Cell return isOpaqueRefType(targetType, checker) && hasArrayTypeArgument(targetType, checker); } /** * Build property assignments for captured variables from a capture tree. * Used by map, handler, and derive transformations to build params/input objects. */ export function buildCapturePropertyAssignments( captureTree: Map, factory: ts.NodeFactory, ): ts.PropertyAssignment[] { const properties: ts.PropertyAssignment[] = []; for (const [rootName, node] of captureTree) { properties.push( factory.createPropertyAssignment( createPropertyName(rootName, factory), buildHierarchicalParamsValue(node, rootName, factory), ), ); } return properties; } /** * Check if a map call should be transformed to mapWithPattern. * * Type-based approach with one special case: * 1. derive() calls always return OpaqueRef at runtime -> TRANSFORM * 2. Otherwise, transform iff the target has an opaque type -> TRANSFORM * * The derive special case exists because we register the unwrapped callback * return type in the type registry (not OpaqueRef), so type-based detection * doesn't work for derive results. */ function shouldTransformMap( mapCall: ts.CallExpression, context: TransformationContext, ): boolean { if (!ts.isPropertyAccessExpression(mapCall.expression)) return false; const mapTarget = mapCall.expression.expression; // Special case: derive() always returns OpaqueRef at runtime if (isDeriveCall(mapTarget)) { return true; } // Get the type of the map target from registry (preferred) or checker const targetType = getTypeAtLocationWithFallback( mapTarget, context.checker, context.options.typeRegistry, context.options.logger, ); if (!targetType) return false; // Transform iff the target is a cell-like type return isOpaqueRefType(targetType, context.checker); } /** * Create the final recipe call with params object. */ /** * Create the final recipe call with params object. */ function createRecipeCallWithParams( mapCall: ts.CallExpression, callback: ts.ArrowFunction | ts.FunctionExpression, transformedBody: ts.ConciseBody, elemParam: ts.ParameterDeclaration | undefined, indexParam: ts.ParameterDeclaration | undefined, arrayParam: ts.ParameterDeclaration | undefined, captureTree: Map, context: TransformationContext, visitor: ts.Visitor, ): ts.CallExpression { const { factory } = context; const usedBindingNames = new Set(); const createBindingIdentifier = (name: string): ts.Identifier => { return reserveIdentifier(name, usedBindingNames, factory); }; // Analyze element binding to handle computed aliases const elementAnalysis = analyzeElementBinding( elemParam, captureTree, context, usedBindingNames, createBindingIdentifier, ); // Filter out computed aliases from params - they'll be declared as local consts instead const computedAliasNames = new Set( elementAnalysis.computedAliases.map((alias) => alias.aliasName), ); const filteredCaptureTree = new Map( Array.from(captureTree.entries()).filter( ([key]) => !computedAliasNames.has(key), ), ); // Initialize RecipeBuilder const builder = new RecipeBuilder(context); builder.registerUsedNames(usedBindingNames); builder.setCaptureTree(filteredCaptureTree); // Add element parameter builder.addParameter( "element", elementAnalysis.bindingName, elementAnalysis.bindingName.kind === ts.SyntaxKind.Identifier && elementAnalysis.bindingName.text === "element" ? undefined : "element", ); // Add index parameter if present if (indexParam) { builder.addParameter( "index", normalizeBindingName(indexParam.name, factory, usedBindingNames), ); } // Add array parameter if present if (arrayParam) { builder.addParameter( "array", normalizeBindingName(arrayParam.name, factory, usedBindingNames), ); } // Rewrite body to handle computed aliases const visitedAliases: ComputedAliasInfo[] = elementAnalysis .computedAliases.map((info) => { const keyExpression = ts.visitNode( info.keyExpression, visitor, ts.isExpression, ) ?? info.keyExpression; return { ...info, keyExpression }; }); const rewrittenBody = rewriteCallbackBody( transformedBody, { bindingName: elementAnalysis.bindingName, elementIdentifier: elementAnalysis.elementIdentifier, destructureStatement: elementAnalysis.destructureStatement, computedAliases: visitedAliases, }, context, ); // Build the new callback const newCallback = builder.buildCallback(callback, rewrittenBody, "params"); context.markAsMapCallback(newCallback); // Build schema using SchemaFactory const schemaFactory = new SchemaFactory(context); const callbackParamTypeNode = schemaFactory.createMapCallbackSchema( mapCall, elemParam, indexParam, arrayParam, filteredCaptureTree, ); // Infer result type const { checker } = context; const typeRegistry = context.options.typeRegistry; let resultTypeNode: ts.TypeNode | undefined; // Check for explicit return type annotation if (callback.type) { resultTypeNode = callback.type; // Ensure type is registered if possible if (typeRegistry) { const type = getTypeAtLocationWithFallback( callback.type, checker, typeRegistry, ); if (type) { typeRegistry.set(callback.type, type); } } } else { // Infer from callback signature const signature = checker.getSignatureFromDeclaration(callback); if (signature) { const resultType = signature.getReturnType(); const isTypeParam = (resultType.flags & ts.TypeFlags.TypeParameter) !== 0; if (!isTypeParam) { resultTypeNode = checker.typeToTypeNode( resultType, context.sourceFile, ts.NodeBuilderFlags.NoTruncation | ts.NodeBuilderFlags.UseStructuralFallback, ); if (resultTypeNode && typeRegistry) { typeRegistry.set(resultTypeNode, resultType); } } } } // Create recipe call const recipeExpr = context.ctHelpers.getHelperExpr("recipe"); const typeArgs = [callbackParamTypeNode]; if (resultTypeNode) { typeArgs.push(resultTypeNode); } const recipeCall = factory.createCallExpression( recipeExpr, typeArgs, [newCallback], ); // Create params object const paramProperties = buildCapturePropertyAssignments( filteredCaptureTree, factory, ); const paramsObject = factory.createObjectLiteralExpression( paramProperties, paramProperties.length > 0, ); if (!ts.isPropertyAccessExpression(mapCall.expression)) { throw new Error( "Expected mapCall.expression to be a PropertyAccessExpression", ); } // Visit the array expression const visitedArrayExpr = ts.visitNode( mapCall.expression.expression, visitor, ts.isExpression, ) ?? mapCall.expression.expression; const mapWithPatternAccess = factory.createPropertyAccessExpression( visitedArrayExpr, factory.createIdentifier("mapWithPattern"), ); const args: ts.Expression[] = [recipeCall, paramsObject]; if (mapCall.arguments.length > 1) { const thisArg = ts.visitNode( mapCall.arguments[1], visitor, ts.isExpression, ); if (thisArg) { args.push(thisArg); } } return factory.createCallExpression( mapWithPatternAccess, mapCall.typeArguments, args, ); } /** * Transform a map callback for OpaqueRef arrays. * Always transforms to use recipe + mapWithPattern, even with no captures, * to ensure callback parameters become opaque. */ export function transformMapCallback( mapCall: ts.CallExpression, callback: ts.ArrowFunction | ts.FunctionExpression, context: TransformationContext, visitor: ts.Visitor, ): ts.CallExpression { const { checker } = context; // Collect captured variables from the callback const collector = new CaptureCollector(checker); const { captureTree } = collector.analyze(callback); // Get callback parameters const originalParams = callback.parameters; const elemParam = originalParams[0]; const indexParam = originalParams[1]; // May be undefined const arrayParam = originalParams[2]; // May be undefined // IMPORTANT: First, recursively transform any nested map callbacks BEFORE we change // parameter names. This ensures nested callbacks can properly detect captures from // parent callback scope. Reuse the same visitor for consistency. const transformedBody = ts.visitNode( callback.body, visitor, ) as ts.ConciseBody; // Create the final recipe call with params return createRecipeCallWithParams( mapCall, callback, transformedBody, elemParam, indexParam, arrayParam, captureTree, context, visitor, ); }