Skip to content

Commit 74e5061

Browse files
committed
generate schemas for injected map_with_pattern calls; update many fixtures accordingly. still one failing test
1 parent ca88a43 commit 74e5061

17 files changed

+754
-24
lines changed

packages/ts-transformers/src/closures/transformer.ts

Lines changed: 217 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,208 @@ function getCaptureName(expr: ts.Expression): string | undefined {
336336
return undefined;
337337
}
338338

339+
/**
340+
* Build a TypeNode for the callback parameter and register property TypeNodes in typeRegistry.
341+
* Returns a TypeLiteral representing { elem: T, index?: number, params: {...} }
342+
*/
343+
function buildCallbackParamTypeNode(
344+
mapCall: ts.CallExpression,
345+
elemParam: ts.ParameterDeclaration | undefined,
346+
indexParam: ts.ParameterDeclaration | undefined,
347+
capturedVarNames: Set<string>,
348+
captures: Map<string, ts.Expression>,
349+
context: TransformationContext,
350+
): ts.TypeNode {
351+
const { factory, checker } = context;
352+
const typeRegistry = context.options.typeRegistry;
353+
354+
// 1. Build elem type property
355+
let elemTypeNode: ts.TypeNode;
356+
let elemType: ts.Type | undefined;
357+
358+
// Check if we have an explicit type annotation that's not 'any'
359+
if (elemParam?.type) {
360+
const annotationType = checker.getTypeFromTypeNode(elemParam.type);
361+
if (!(annotationType.flags & ts.TypeFlags.Any)) {
362+
// Use the explicit annotation
363+
elemTypeNode = elemParam.type;
364+
elemType = annotationType;
365+
} else {
366+
// Annotation is 'any', try to infer from array
367+
const inferred = inferElementType(mapCall, context);
368+
elemTypeNode = inferred.typeNode;
369+
elemType = inferred.type;
370+
}
371+
} else {
372+
// No annotation, infer from array
373+
const inferred = inferElementType(mapCall, context);
374+
elemTypeNode = inferred.typeNode;
375+
elemType = inferred.type;
376+
}
377+
378+
// Register elem TypeNode if we have a Type
379+
if (typeRegistry && elemType) {
380+
typeRegistry.set(elemTypeNode, elemType);
381+
}
382+
383+
const callbackParamProperties: ts.TypeElement[] = [
384+
factory.createPropertySignature(
385+
undefined,
386+
factory.createIdentifier("elem"),
387+
undefined,
388+
elemTypeNode,
389+
),
390+
];
391+
392+
// 2. Add index property if present
393+
if (indexParam) {
394+
callbackParamProperties.push(
395+
factory.createPropertySignature(
396+
undefined,
397+
factory.createIdentifier("index"),
398+
factory.createToken(ts.SyntaxKind.QuestionToken),
399+
factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword),
400+
),
401+
);
402+
}
403+
404+
// 3. Build params object type with captured variables
405+
const paramsProperties: ts.TypeElement[] = [];
406+
for (const varName of capturedVarNames) {
407+
const expr = captures.get(varName);
408+
if (!expr) continue;
409+
410+
// Get the Type of the captured expression
411+
const exprType = checker.getTypeAtLocation(expr);
412+
413+
// Convert Type to TypeNode
414+
const typeNode = checker.typeToTypeNode(
415+
exprType,
416+
context.sourceFile,
417+
ts.NodeBuilderFlags.NoTruncation | ts.NodeBuilderFlags.UseStructuralFallback,
418+
) ?? factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword);
419+
420+
// Register this property's TypeNode with its Type
421+
if (typeRegistry) {
422+
typeRegistry.set(typeNode, exprType);
423+
}
424+
425+
paramsProperties.push(
426+
factory.createPropertySignature(
427+
undefined,
428+
factory.createIdentifier(varName),
429+
undefined,
430+
typeNode,
431+
),
432+
);
433+
}
434+
435+
// Add params property
436+
callbackParamProperties.push(
437+
factory.createPropertySignature(
438+
undefined,
439+
factory.createIdentifier("params"),
440+
undefined,
441+
factory.createTypeLiteralNode(paramsProperties),
442+
),
443+
);
444+
445+
return factory.createTypeLiteralNode(callbackParamProperties);
446+
}
447+
448+
/**
449+
* Infer the element type from an OpaqueRef<T[]> or Array<T> being mapped.
450+
*/
451+
function inferElementType(
452+
mapCall: ts.CallExpression,
453+
context: TransformationContext,
454+
): { typeNode: ts.TypeNode; type?: ts.Type } {
455+
const { factory, checker } = context;
456+
457+
if (!ts.isPropertyAccessExpression(mapCall.expression)) {
458+
return {
459+
typeNode: factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword),
460+
};
461+
}
462+
463+
const arrayExpr = mapCall.expression.expression;
464+
const arrayType = checker.getTypeAtLocation(arrayExpr);
465+
466+
// Handle OpaqueRef<T[]> which is an intersection type
467+
let actualArrayType = arrayType;
468+
if (arrayType.flags & ts.TypeFlags.Intersection) {
469+
const intersectionType = arrayType as ts.IntersectionType;
470+
// Look for the Reference type member (e.g., OpaqueRefMethods<T[]>)
471+
for (const type of intersectionType.types) {
472+
if (type.flags & ts.TypeFlags.Object) {
473+
const objType = type as ts.ObjectType;
474+
if (objType.objectFlags & ts.ObjectFlags.Reference) {
475+
actualArrayType = type;
476+
break;
477+
}
478+
}
479+
}
480+
}
481+
482+
// Get type arguments from the reference type
483+
let typeArgs: readonly ts.Type[] | undefined;
484+
if (actualArrayType.flags & ts.TypeFlags.Object) {
485+
const objectType = actualArrayType as ts.ObjectType;
486+
if (objectType.objectFlags & ts.ObjectFlags.Reference) {
487+
typeArgs = checker.getTypeArguments(objectType as ts.TypeReference);
488+
}
489+
}
490+
491+
if (typeArgs && typeArgs.length > 0) {
492+
const innerType = typeArgs[0];
493+
if (innerType) {
494+
// innerType is either T[] or T depending on the structure
495+
let elementType: ts.Type;
496+
if (checker.isArrayType(innerType)) {
497+
// It's T[], extract T
498+
const extracted = checker.getIndexTypeOfType(innerType, ts.IndexKind.Number);
499+
if (extracted) {
500+
elementType = extracted;
501+
} else {
502+
return {
503+
typeNode: factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword),
504+
};
505+
}
506+
} else {
507+
// It's already T
508+
elementType = innerType;
509+
}
510+
511+
// Convert Type to TypeNode
512+
const typeNode = checker.typeToTypeNode(
513+
elementType,
514+
context.sourceFile,
515+
ts.NodeBuilderFlags.NoTruncation | ts.NodeBuilderFlags.UseStructuralFallback,
516+
) ?? factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword);
517+
518+
return { typeNode, type: elementType };
519+
}
520+
}
521+
522+
// Fallback for plain Array<T>
523+
if (checker.isArrayType(arrayType)) {
524+
const elementType = checker.getIndexTypeOfType(arrayType, ts.IndexKind.Number);
525+
if (elementType) {
526+
const typeNode = checker.typeToTypeNode(
527+
elementType,
528+
context.sourceFile,
529+
ts.NodeBuilderFlags.NoTruncation | ts.NodeBuilderFlags.UseStructuralFallback,
530+
) ?? factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword);
531+
532+
return { typeNode, type: elementType };
533+
}
534+
}
535+
536+
return {
537+
typeNode: factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword),
538+
};
539+
}
540+
339541
/**
340542
* Transform a map callback that captures variables.
341543
*/
@@ -562,19 +764,28 @@ function transformMapCallback(
562764
transformedBody,
563765
);
564766

565-
// Wrap in recipe() using the proper imported identifier
767+
// Build a TypeNode for the callback parameter to pass as a type argument to recipe<T>()
768+
// The callback signature is: ({ elem, index?, params: { captured1, captured2, ... } }) => ...
769+
// Also register individual property TypeNodes in typeRegistry so SchemaGeneratorTransformer can resolve them
770+
const callbackParamTypeNode = buildCallbackParamTypeNode(
771+
mapCall,
772+
elemParam,
773+
indexParam,
774+
capturedVarNames,
775+
captures,
776+
context,
777+
);
778+
779+
// Wrap in recipe<T>() using type argument (SchemaInjectionTransformer will convert to toSchema<T>)
566780
const recipeIdentifier = getHelperIdentifier(
567781
factory,
568782
context.sourceFile,
569783
"recipe",
570784
);
571785
const recipeCall = factory.createCallExpression(
572786
recipeIdentifier,
573-
undefined,
574-
[
575-
factory.createStringLiteral("map with pattern including captures"),
576-
newCallback,
577-
],
787+
[callbackParamTypeNode], // Type argument
788+
[newCallback],
578789
);
579790

580791
// Create the params object

packages/ts-transformers/src/transformers/schema-generator.ts

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,25 @@ export class SchemaGeneratorTransformer extends Transformer {
5757
optionsObj = evaluateObjectLiteral(arg0, checker);
5858
}
5959

60-
const schema = generateSchema!(type, checker, typeArg);
60+
// If Type resolved to 'any' and we have a synthetic TypeNode, try analyzing its structure
61+
let schema: unknown;
62+
if (
63+
(type.flags & ts.TypeFlags.Any) &&
64+
typeArg.pos === -1 &&
65+
typeArg.end === -1
66+
) {
67+
// This is a synthetic TypeNode that didn't resolve to a proper Type
68+
// Analyze the TypeNode structure, checking typeRegistry for property Types
69+
schema = analyzeTypeNodeStructure(typeArg, checker, context.factory, typeRegistry);
70+
} else {
71+
// Normal path: use Type-based schema generation
72+
schema = generateSchema!(type, checker, typeArg);
73+
}
6174

6275
// Handle boolean schemas (true/false) - can't spread them
6376
const finalSchema = typeof schema === "boolean"
6477
? schema
65-
: { ...schema, ...optionsObj };
78+
: { ...(schema as Record<string, unknown>), ...optionsObj };
6679
const schemaAst = createSchemaAst(finalSchema, context.factory);
6780

6881
const constAssertion = context.factory.createAsExpression(
@@ -172,3 +185,100 @@ function evaluateExpression(
172185
if (constantValue !== undefined) return constantValue;
173186
return undefined;
174187
}
188+
189+
/**
190+
* Analyze a synthetic TypeNode's structure to generate a schema when Type resolution fails.
191+
* Checks typeRegistry for property Types before recursing.
192+
*/
193+
function analyzeTypeNodeStructure(
194+
typeNode: ts.TypeNode,
195+
checker: ts.TypeChecker,
196+
factory: ts.NodeFactory,
197+
typeRegistry?: import("../core/mod.ts").TypeRegistry,
198+
): unknown {
199+
// Handle TypeLiteral nodes (object types)
200+
if (ts.isTypeLiteralNode(typeNode)) {
201+
const properties: Record<string, unknown> = {};
202+
const required: string[] = [];
203+
204+
for (const member of typeNode.members) {
205+
if (
206+
ts.isPropertySignature(member) &&
207+
member.name &&
208+
ts.isIdentifier(member.name) &&
209+
member.type
210+
) {
211+
const propName = member.name.text;
212+
213+
// First, check if this property's TypeNode is in the typeRegistry
214+
let propType: ts.Type | undefined;
215+
if (typeRegistry && typeRegistry.has(member.type)) {
216+
propType = typeRegistry.get(member.type);
217+
} else {
218+
// Try to get Type from the property's TypeNode
219+
const resolvedType = checker.getTypeFromTypeNode(member.type);
220+
if (!(resolvedType.flags & ts.TypeFlags.Any)) {
221+
propType = resolvedType;
222+
}
223+
}
224+
225+
let propSchema: unknown;
226+
if (propType) {
227+
// We have a real Type - use normal schema generator
228+
if (!generateSchema) generateSchema = createSchemaTransformerV2();
229+
propSchema = generateSchema(propType, checker, member.type);
230+
} else {
231+
// No Type available - recurse on TypeNode structure
232+
propSchema = analyzeTypeNodeStructure(member.type, checker, factory, typeRegistry);
233+
}
234+
235+
properties[propName] = propSchema;
236+
237+
// Add to required if not optional
238+
if (!member.questionToken) {
239+
required.push(propName);
240+
}
241+
}
242+
}
243+
244+
const schema: Record<string, unknown> = {
245+
type: "object",
246+
properties,
247+
};
248+
249+
if (required.length > 0) {
250+
schema.required = required;
251+
}
252+
253+
return schema;
254+
}
255+
256+
// Handle keyword types (string, number, boolean, etc.)
257+
switch (typeNode.kind) {
258+
case ts.SyntaxKind.StringKeyword:
259+
return { type: "string" };
260+
case ts.SyntaxKind.NumberKeyword:
261+
return { type: "number" };
262+
case ts.SyntaxKind.BooleanKeyword:
263+
return { type: "boolean" };
264+
case ts.SyntaxKind.NullKeyword:
265+
return { type: "null" };
266+
case ts.SyntaxKind.UndefinedKeyword:
267+
case ts.SyntaxKind.VoidKeyword:
268+
case ts.SyntaxKind.AnyKeyword:
269+
case ts.SyntaxKind.UnknownKeyword:
270+
// Accept any value
271+
return true;
272+
}
273+
274+
// For other TypeNode kinds, try to resolve as Type
275+
const type = checker.getTypeFromTypeNode(typeNode);
276+
if (!(type.flags & ts.TypeFlags.Any)) {
277+
// Successfully resolved - use normal schema generator
278+
if (!generateSchema) generateSchema = createSchemaTransformerV2();
279+
return generateSchema(type, checker, typeNode);
280+
}
281+
282+
// Fallback: accept any value
283+
return true;
284+
}

packages/ts-transformers/test/fixtures/closures/cell-map-with-captures.expected.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,25 @@ export default recipe({
2323
const typedValues: Cell<number[]> = cell(state.values);
2424
return {
2525
[UI]: (<div>
26-
{typedValues.map_with_pattern(recipe("map with pattern including captures", ({ elem, params: { multiplier } }) => (<span>{elem * multiplier}</span>)), { multiplier: state.multiplier })}
26+
{typedValues.map_with_pattern(recipe({
27+
type: "object",
28+
properties: {
29+
elem: {
30+
type: "number"
31+
},
32+
params: {
33+
type: "object",
34+
properties: {
35+
multiplier: {
36+
type: "number",
37+
asOpaque: true
38+
}
39+
},
40+
required: ["multiplier"]
41+
}
42+
},
43+
required: ["elem", "params"]
44+
} as const satisfies JSONSchema, ({ elem, params: { multiplier } }) => (<span>{elem * multiplier}</span>)), { multiplier: state.multiplier })}
2745
</div>),
2846
};
2947
});

0 commit comments

Comments
 (0)