diff --git a/bindings/walk/walk.go b/bindings/walk/walk.go index 8daa2fc..00cf495 100644 --- a/bindings/walk/walk.go +++ b/bindings/walk/walk.go @@ -70,6 +70,32 @@ func Walk(v Visitor, node bindings.Node) { walkList(v, n.Members) case *bindings.TypeIntersection: walkList(v, n.Types) + case *bindings.ExpressionWithTypeArguments: + Walk(v, n.Expression) + walkList(v, n.Arguments) + case *bindings.ImportDeclaration: + walkList(v, n.Named) + case *bindings.ImportSpecifier: + // noop + case *bindings.IdentifierExpression: + // noop + case *bindings.PropertyAccessExpression: + Walk(v, n.Expression) + case *bindings.CallExpression: + Walk(v, n.Expression) + walkList(v, n.Arguments) + case *bindings.ObjectLiteralExpression: + walkList(v, n.Properties) + case *bindings.PropertyAssignment: + Walk(v, n.Initializer) + case *bindings.Parameter: + Walk(v, n.Type) + case *bindings.ArrowFunction: + walkList(v, n.Parameters) + Walk(v, n.ReturnType) + Walk(v, n.Body) + case *bindings.TypeQuery: + // noop default: panic(fmt.Sprintf("convert.Walk: unexpected node type %T", n)) } diff --git a/bindings/walk/walk_test.go b/bindings/walk/walk_test.go new file mode 100644 index 0000000..0780bb7 --- /dev/null +++ b/bindings/walk/walk_test.go @@ -0,0 +1,240 @@ +package walk_test + +import ( + "fmt" + "testing" + + "github.com/coder/guts/bindings" + "github.com/coder/guts/bindings/walk" +) + +// recordingVisitor stores every node it visits so tests can assert that +// Walk descends into expected child slots without panicking. +type recordingVisitor struct { + visited []bindings.Node +} + +func (r *recordingVisitor) Visit(node bindings.Node) walk.Visitor { + r.visited = append(r.visited, node) + return r +} + +// keyword returns a fresh pointer to a LiteralKeyword. LiteralKeyword is a +// string-typed Node, so callers need a pointer-to-string-typed-value, not +// a composite literal. +func keyword(k bindings.LiteralKeyword) *bindings.LiteralKeyword { + return &k +} + +// TestWalkCoversAllNodeTypes builds a synthetic tree that contains every +// concrete bindings.Node implementation and walks it. The default branch +// in Walk panics on unhandled types, so this test fails loudly if a new +// node is added to the bindings package without a matching case here. +func TestWalkCoversAllNodeTypes(t *testing.T) { + t.Parallel() + + // Construct one of each Node. Some nodes (PropertyAssignment, Parameter, + // HeritageClause, ImportSpecifier, EnumMember) only appear as children + // of a parent, so the parent's slot is the way Walk reaches them. + tree := &bindings.Interface{ + Name: bindings.Identifier{Name: "Root"}, + Parameters: []*bindings.TypeParameter{ + {Name: bindings.Identifier{Name: "T"}, Type: keyword(bindings.KeywordString)}, + }, + Heritage: []*bindings.HeritageClause{ + { + Args: []bindings.ExpressionType{ + &bindings.ExpressionWithTypeArguments{ + Expression: &bindings.IdentifierExpression{ + Name: bindings.Identifier{Name: "Base"}, + }, + Arguments: []bindings.ExpressionType{ + &bindings.ReferenceType{Name: bindings.Identifier{Name: "X"}}, + }, + }, + }, + }, + }, + Fields: []*bindings.PropertySignature{ + { + Name: "everything", + Type: &bindings.TypeIntersection{ + Types: []bindings.ExpressionType{ + &bindings.UnionType{ + Types: []bindings.ExpressionType{ + &bindings.LiteralType{Value: "a"}, + &bindings.Null{}, + keyword(bindings.KeywordString), + }, + }, + &bindings.TypeLiteralNode{ + Members: []*bindings.PropertySignature{ + {Name: "nested", Type: keyword(bindings.KeywordString)}, + }, + }, + &bindings.ArrayType{Node: keyword(bindings.KeywordString)}, + &bindings.TupleType{Node: keyword(bindings.KeywordString)}, + &bindings.ArrayLiteralType{ + Elements: []bindings.ExpressionType{&bindings.LiteralType{Value: "elt"}}, + }, + bindings.OperatorNode(bindings.KeywordReadonly, keyword(bindings.KeywordString)), + &bindings.TypeQuery{Name: bindings.Identifier{Name: "Other"}}, + }, + }, + }, + }, + } + + // Independent node group exercising the value-expression and import + // nodes added in PR 83 and PR 84. Walking each top-level node here + // touches the remaining bindings.Node implementations. + nodes := []bindings.Node{ + tree, + &bindings.Alias{ + Name: bindings.Identifier{Name: "MyAlias"}, + Type: keyword(bindings.KeywordString), + }, + &bindings.Enum{ + Name: bindings.Identifier{Name: "Color"}, + Members: []*bindings.EnumMember{ + {Name: "Red", Value: &bindings.LiteralType{Value: "red"}}, + }, + }, + &bindings.VariableStatement{ + Declarations: &bindings.VariableDeclarationList{ + Flags: bindings.NodeFlagsConstant, + Declarations: []*bindings.VariableDeclaration{ + { + Name: bindings.Identifier{Name: "Schema"}, + Type: &bindings.ReferenceType{Name: bindings.Identifier{Name: "Z"}}, + Initializer: &bindings.CallExpression{ + Expression: &bindings.PropertyAccessExpression{ + Expression: &bindings.IdentifierExpression{ + Name: bindings.Identifier{Name: "z"}, + }, + Name: "object", + }, + Arguments: []bindings.ExpressionType{ + &bindings.ObjectLiteralExpression{ + Properties: []*bindings.PropertyAssignment{ + { + Name: "id", + Initializer: &bindings.CallExpression{ + Expression: &bindings.PropertyAccessExpression{ + Expression: &bindings.IdentifierExpression{ + Name: bindings.Identifier{Name: "z"}, + }, + Name: "string", + }, + }, + }, + }, + }, + &bindings.ArrowFunction{ + Parameters: []*bindings.Parameter{ + {Name: "x", Type: keyword(bindings.KeywordString)}, + }, + ReturnType: keyword(bindings.KeywordString), + Body: &bindings.IdentifierExpression{ + Name: bindings.Identifier{Name: "x"}, + }, + }, + }, + }, + }, + }, + }, + }, + &bindings.ImportDeclaration{ + Module: "zod", + Named: []*bindings.ImportSpecifier{ + {Name: "z"}, + }, + }, + } + + v := &recordingVisitor{} + for _, node := range nodes { + walk.Walk(v, node) + } + + // Verify that Walk reached every new node type. The set of expected + // node types here is the union of nodes constructed above. If any are + // missing, Walk lost a child slot somewhere along the way. + seen := map[string]bool{} + for _, n := range v.visited { + seen[fmt.Sprintf("%T", n)] = true + } + + want := []string{ + "*bindings.Interface", + "*bindings.TypeParameter", + "*bindings.HeritageClause", + "*bindings.ExpressionWithTypeArguments", + "*bindings.IdentifierExpression", + "*bindings.ReferenceType", + "*bindings.PropertySignature", + "*bindings.TypeIntersection", + "*bindings.UnionType", + "*bindings.LiteralType", + "*bindings.Null", + "*bindings.LiteralKeyword", + "*bindings.TypeLiteralNode", + "*bindings.ArrayType", + "*bindings.TupleType", + "*bindings.ArrayLiteralType", + "*bindings.OperatorNodeType", + "*bindings.TypeQuery", + "*bindings.Alias", + "*bindings.Enum", + "*bindings.EnumMember", + "*bindings.VariableStatement", + "*bindings.VariableDeclarationList", + "*bindings.VariableDeclaration", + "*bindings.CallExpression", + "*bindings.PropertyAccessExpression", + "*bindings.ObjectLiteralExpression", + "*bindings.PropertyAssignment", + "*bindings.ArrowFunction", + "*bindings.Parameter", + "*bindings.ImportDeclaration", + "*bindings.ImportSpecifier", + } + for _, name := range want { + if !seen[name] { + t.Errorf("Walk did not visit %s", name) + } + } +} + +// TestWalkStopsWhenVisitReturnsNil verifies that Walk honours the Visitor +// contract: returning nil from Visit halts descent into that subtree. +func TestWalkStopsWhenVisitReturnsNil(t *testing.T) { + t.Parallel() + + leaf := keyword(bindings.KeywordString) + parent := &bindings.ArrayType{Node: leaf} + + v := &stoppingVisitor{stopAt: parent} + walk.Walk(v, parent) + + if len(v.visited) != 1 { + t.Fatalf("expected 1 visit, got %d", len(v.visited)) + } + if v.visited[0] != bindings.Node(parent) { + t.Fatalf("expected to visit parent, got %T", v.visited[0]) + } +} + +type stoppingVisitor struct { + stopAt bindings.Node + visited []bindings.Node +} + +func (s *stoppingVisitor) Visit(node bindings.Node) walk.Visitor { + s.visited = append(s.visited, node) + if node == s.stopAt { + return nil + } + return s +} diff --git a/go.mod b/go.mod index 01e44ae..7233b83 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect diff --git a/go.sum b/go.sum index 393f522..9ed434d 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= diff --git a/testdata/zod/golden.ts b/testdata/zod/golden.ts new file mode 100644 index 0000000..5dad07f --- /dev/null +++ b/testdata/zod/golden.ts @@ -0,0 +1,42 @@ +// Code generated by 'guts'. DO NOT EDIT. + +import { z } from "zod"; + +export const BaseSchema = z.object({ + id: z.string(), + created_at: z.string(), + updated_at: z.string() +}); + +export type Base = z.infer; + +export const PrioritySchema = z.union([z.literal(2), z.literal(0), z.literal(1)]); + +export type Priority = z.infer; + +export const CreateTicketRequestSchema = z.object({ + title: z.string(), + description: z.string().optional(), + priority: PrioritySchema, + tags: z.array(z.string()).optional() +}); + +export type CreateTicketRequest = z.infer; + +export const StatusSchema = z.enum(["active", "closed", "pending"]); + +export type Status = z.infer; + +export const TicketSchema = BaseSchema.extend({ + title: z.string(), + description: z.string().optional(), + status: StatusSchema, + priority: PrioritySchema, + assignee_id: z.string().optional(), + tags: z.array(z.string()), + metadata: z.record(z.string(), z.string()).nullable(), + children: z.array(z.lazy((): z.ZodType => TicketSchema)) +}); + +export type Ticket = z.infer; + diff --git a/testdata/zod/types.go b/testdata/zod/types.go new file mode 100644 index 0000000..2192480 --- /dev/null +++ b/testdata/zod/types.go @@ -0,0 +1,54 @@ +// Package zod provides sample types for testing the Zod mutation. +package zod + +import ( + "time" + + "github.com/google/uuid" +) + +type Status string + +const ( + StatusActive Status = "active" + StatusPending Status = "pending" + StatusClosed Status = "closed" +) + +type Priority int + +const ( + PriorityLow Priority = 0 + PriorityMedium Priority = 1 + PriorityHigh Priority = 2 +) + +// Base is embedded by Ticket to test heritage/extend. +type Base struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Ticket demonstrates a realistic struct with enums, nullable +// pointers, embedded structs, arrays, and maps. +type Ticket struct { + Base + + Title string `json:"title"` + Description *string `json:"description,omitempty"` + Status Status `json:"status"` + Priority Priority `json:"priority"` + AssigneeID *uuid.UUID `json:"assignee_id,omitempty"` + Tags []string `json:"tags"` + Metadata map[string]string `json:"metadata"` + Children []Ticket `json:"children"` +} + +// CreateTicketRequest demonstrates a request body type. +type CreateTicketRequest struct { + Title string `json:"title"` + Description string `json:"description,omitempty"` + Priority Priority `json:"priority"` + Tags []string `json:"tags,omitempty"` +} diff --git a/testdata/zod/zod.ts b/testdata/zod/zod.ts new file mode 100644 index 0000000..63d979e --- /dev/null +++ b/testdata/zod/zod.ts @@ -0,0 +1,48 @@ +// Code generated by 'guts'. DO NOT EDIT. + +// From zod/types.go +/** + * Base is embedded by Ticket to test heritage/extend. + */ +export interface Base { + readonly id: string; + readonly created_at: string; + readonly updated_at: string; +} + +// From zod/types.go +/** + * CreateTicketRequest demonstrates a request body type. + */ +export interface CreateTicketRequest { + readonly title: string; + readonly description?: string; + readonly priority: Priority; + readonly tags?: readonly string[]; +} + +export const Priorities: Priority[] = [2, 0, 1]; + +// From zod/types.go +export type Priority = 2 | 0 | 1; + +// From zod/types.go +export type Status = "active" | "closed" | "pending"; + +export const Statuses: Status[] = ["active", "closed", "pending"]; + +// From zod/types.go +/** + * Ticket demonstrates a realistic struct with enums, nullable + * pointers, embedded structs, arrays, and maps. + */ +export interface Ticket extends Base { + readonly title: string; + readonly description?: string | null; + readonly status: Status; + readonly priority: Priority; + readonly assignee_id?: string | null; + readonly tags: readonly string[]; + readonly metadata: Record | null; + readonly children: readonly Ticket[]; +} diff --git a/zod/zod.go b/zod/zod.go new file mode 100644 index 0000000..cc11e3e --- /dev/null +++ b/zod/zod.go @@ -0,0 +1,658 @@ +// Package zod converts the guts intermediate TypeScript AST into Zod v4 +// schema declarations. +// +// The package exposes a single mutation, AsSchemas, that walks every +// Interface and Alias in a *guts.Typescript and replaces each one with: +// +// - a VariableStatement for `const FooSchema = z.;`, and +// - an Alias for `type Foo = z.infer;`. +// +// It also injects `import { z } from "zod"` so the generated file is +// self-contained. +// +// # Ordering +// +// Zod schemas must be declared before any other schema references them +// because `const` bindings are not hoisted. AsSchemas does not reorder +// nodes itself; the caller is expected to pass SortByDependencies to +// Typescript.SerializeInOrder: +// +// ts.ApplyMutations(zod.AsSchemas) +// out, err := ts.SerializeInOrder(zod.SortByDependencies) +// +// SortByDependencies performs a Kahn's-algorithm topological sort over +// the schema VariableStatements, then pairs each schema with its inferred +// type alias. Self-references inside the same declaration are already +// broken by z.lazy in convertInterface and convertAlias, so the sort +// treats arrow-function bodies as non-dependencies. +// +// # Pipeline +// +// AsSchemas composes with the rest of the config mutations. The intended +// pipeline is: +// +// ts.ApplyMutations( +// config.EnumAsTypes, // int and string enums -> union of literals +// config.SimplifyOmitEmpty, // omitempty -> drop null, keep optional +// zod.AsSchemas, // rewrite Interface/Alias into Zod +// config.ExportTypes, // add `export` to the new declarations +// ) +// +// Other mutations that walk Interface or Alias (ExportTypes, ReadOnly, +// etc.) should run after AsSchemas because the originals are replaced. +package zod + +import ( + "sort" + "strings" + + "github.com/coder/guts" + "github.com/coder/guts/bindings" + "github.com/coder/guts/bindings/walk" +) + +// AsSchemas is the mutation entry point. It walks ts.typescriptNodes and +// rewrites each Interface and Alias into a VariableStatement + Alias pair +// expressed in Zod, and appends `import { z } from "zod"`. +func AsSchemas(ts *guts.Typescript) { + ts.AppendImport(&bindings.ImportDeclaration{ + Module: "zod", + Named: []*bindings.ImportSpecifier{{Name: "z"}}, + }) + + // Collect keys before mutating so the map iteration is not invalidated + // when we Replace and Set during conversion. + var keys []string + ts.ForEach(func(name string, _ bindings.Node) { + keys = append(keys, name) + }) + + for _, key := range keys { + node, ok := ts.Node(key) + if !ok { + continue + } + switch n := node.(type) { + case *bindings.Interface: + convertInterface(ts, key, n) + case *bindings.Alias: + convertAlias(ts, key, n) + } + } +} + +// schemaSuffix is the suffix appended to a type name to produce its +// schema binding. `Foo` becomes `FooSchema`. +const schemaSuffix = "Schema" + +// schemaIdent returns the Identifier for the schema binding paired with a +// type. `Foo` becomes `FooSchema`, with Package and Prefix preserved so +// cross-package disambiguation flows through .Ref() to the emitted name. +func schemaIdent(typeName bindings.Identifier) bindings.Identifier { + return bindings.Identifier{ + Name: typeName.Name + schemaSuffix, + Package: typeName.Package, + Prefix: typeName.Prefix, + } +} + +// inferAlias builds `type = z.infer>` for a +// single converted declaration. +func inferAlias(typeName bindings.Identifier) *bindings.Alias { + return &bindings.Alias{ + Name: typeName, + Type: &bindings.ReferenceType{ + Name: bindings.Identifier{Name: "z.infer"}, + Arguments: []bindings.ExpressionType{ + &bindings.TypeQuery{Name: schemaIdent(typeName)}, + }, + }, + } +} + +// constSchema builds `const = ` with no +// modifiers. The Export mutation, if applied afterwards, adds `export`. +func constSchema(schemaName bindings.Identifier, initializer bindings.ExpressionType) *bindings.VariableStatement { + return &bindings.VariableStatement{ + Modifiers: []bindings.Modifier{}, + Declarations: &bindings.VariableDeclarationList{ + Flags: bindings.NodeFlagsConstant, + Declarations: []*bindings.VariableDeclaration{ + { + Name: schemaName, + Initializer: initializer, + }, + }, + }, + } +} + +// zMethod builds `z.(args...)` as an expression. It is the most +// common shape Zod schemas need. +func zMethod(name string, args ...bindings.ExpressionType) *bindings.CallExpression { + return &bindings.CallExpression{ + Expression: &bindings.PropertyAccessExpression{ + Expression: &bindings.IdentifierExpression{Name: bindings.Identifier{Name: "z"}}, + Name: name, + }, + Arguments: args, + } +} + +// chain wraps `.()` to extend a schema with a refinement +// like `.optional()` or `.nullable()`. +func chain(expr bindings.ExpressionType, method string) *bindings.CallExpression { + return &bindings.CallExpression{ + Expression: &bindings.PropertyAccessExpression{ + Expression: expr, + Name: method, + }, + } +} + +// converter holds the per-declaration conversion state. Methods on +// converter recurse through a TypeScript type expression and emit the +// equivalent Zod expression with access to the surrounding type's name +// (so self-references can become z.lazy) and to its generic type +// parameters (so a reference to a type parameter becomes z.unknown). +type converter struct { + self bindings.Identifier + typeParams map[string]bool +} + +// newConverter builds a converter for one declaration. params may be nil +// when the declaration has no generic parameters. +func newConverter(self bindings.Identifier, params []*bindings.TypeParameter) *converter { + tp := make(map[string]bool, len(params)) + for _, p := range params { + tp[p.Name.Ref()] = true + } + return &converter{self: self, typeParams: tp} +} + +// convertInterface rewrites an Interface into a schema VariableStatement +// plus an inferred type alias. The original key in ts.typescriptNodes is +// reused for the alias; the schema is added under Schema. +func convertInterface(ts *guts.Typescript, key string, iface *bindings.Interface) { + typeName := iface.Name + schemaName := schemaIdent(typeName) + c := newConverter(typeName, iface.Parameters) + + objLit := c.buildFieldsObject(iface.Fields) + + var initializer bindings.ExpressionType + if base, ok := heritageBase(iface); ok { + // BaseSchema.extend({...}) + initializer = &bindings.CallExpression{ + Expression: &bindings.PropertyAccessExpression{ + Expression: &bindings.IdentifierExpression{Name: schemaIdent(base)}, + Name: "extend", + }, + Arguments: []bindings.ExpressionType{objLit}, + } + } else { + // z.object({...}) + initializer = zMethod("object", objLit) + } + + ts.ReplaceNode(key, inferAlias(typeName)) + _ = ts.SetNode(schemaName.Ref(), constSchema(schemaName, initializer)) +} + +// convertAlias rewrites an Alias into a schema VariableStatement plus an +// inferred type alias. +func convertAlias(ts *guts.Typescript, key string, alias *bindings.Alias) { + typeName := alias.Name + schemaName := schemaIdent(typeName) + c := newConverter(typeName, alias.Parameters) + + var initializer bindings.ExpressionType + if union, ok := alias.Type.(*bindings.UnionType); ok && isStringLiteralUnion(union) { + initializer = zMethod("enum", stringLiteralArray(union)) + } else { + initializer = c.exprToZod(alias.Type) + } + + ts.ReplaceNode(key, inferAlias(typeName)) + _ = ts.SetNode(schemaName.Ref(), constSchema(schemaName, initializer)) +} + +// heritageBase returns the single heritage base of an Interface as an +// Identifier, if any. Zod's `.extend()` only models single inheritance, +// so multiple heritage clauses cause a panic to surface the mismatch +// rather than silently dropping one. +func heritageBase(iface *bindings.Interface) (bindings.Identifier, bool) { + var base bindings.Identifier + found := false + for _, h := range iface.Heritage { + for _, arg := range h.Args { + ident, ok := heritageArgIdent(arg) + if !ok { + continue + } + if found { + panic("zod: multiple heritage bases on " + iface.Name.Ref() + " (Zod has no multiple inheritance)") + } + base = ident + found = true + } + } + return base, found +} + +// heritageArgIdent unwraps a heritage argument to the underlying +// Identifier when it is a plain type reference. Other shapes are not +// modeled and return false. +func heritageArgIdent(arg bindings.ExpressionType) (bindings.Identifier, bool) { + switch n := arg.(type) { + case *bindings.ExpressionWithTypeArguments: + if rt, ok := n.Expression.(*bindings.ReferenceType); ok { + return rt.Name, true + } + case *bindings.ReferenceType: + return n.Name, true + } + return bindings.Identifier{}, false +} + +// buildFieldsObject collects an Interface's fields into a single +// ObjectLiteralExpression whose values are zod expressions. +func (c *converter) buildFieldsObject(fields []*bindings.PropertySignature) *bindings.ObjectLiteralExpression { + props := make([]*bindings.PropertyAssignment, 0, len(fields)) + for _, f := range fields { + expr := c.exprToZod(f.Type) + if f.QuestionToken { + expr = chain(expr, "optional") + } + props = append(props, &bindings.PropertyAssignment{ + Name: f.Name, + Initializer: expr, + }) + } + return &bindings.ObjectLiteralExpression{Properties: props} +} + +// isStringLiteralUnion reports whether every member of a union is a +// string literal. Such unions become z.enum([...]) rather than +// z.union([z.literal(...), ...]) for readability. +func isStringLiteralUnion(u *bindings.UnionType) bool { + if len(u.Types) == 0 { + return false + } + for _, t := range u.Types { + lit, ok := t.(*bindings.LiteralType) + if !ok { + return false + } + if _, ok := lit.Value.(string); !ok { + return false + } + } + return true +} + +// stringLiteralArray collects the string values from a string-literal +// union into an ArrayLiteralType suitable for `z.enum([...])`. +func stringLiteralArray(u *bindings.UnionType) *bindings.ArrayLiteralType { + elems := make([]bindings.ExpressionType, 0, len(u.Types)) + for _, t := range u.Types { + if lit, ok := t.(*bindings.LiteralType); ok { + elems = append(elems, &bindings.LiteralType{Value: lit.Value}) + } + } + return &bindings.ArrayLiteralType{Elements: elems} +} + +// exprToZod recursively converts a TypeScript type expression into the +// equivalent Zod schema expression. References back to the surrounding +// type use z.lazy() to avoid reference-before-declaration errors. +func (c *converter) exprToZod(expr bindings.ExpressionType) bindings.ExpressionType { + if expr == nil { + return zMethod("unknown") + } + switch e := expr.(type) { + case *bindings.LiteralKeyword: + return keywordToZod(e) + case *bindings.LiteralType: + return zMethod("literal", &bindings.LiteralType{Value: e.Value}) + case *bindings.ReferenceType: + return c.referenceToZod(e) + case *bindings.ArrayType: + return zMethod("array", c.exprToZod(e.Node)) + case *bindings.TupleType: + // Tuples are emitted as arrays today. A future variant could + // switch on TupleType.Length to emit a true z.tuple(). + return zMethod("array", c.exprToZod(e.Node)) + case *bindings.UnionType: + return c.unionToZod(e) + case *bindings.Null: + return zMethod("null") + case *bindings.TypeLiteralNode: + return c.typeLiteralToZod(e) + case *bindings.TypeIntersection: + return c.intersectionToZod(e) + case *bindings.OperatorNodeType: + // readonly/keyof/unique wrappers do not affect the Zod schema; + // unwrap and emit the inner type directly. + return c.exprToZod(e.Type) + default: + return zMethod("unknown") + } +} + +// keywordToZod maps a TypeScript keyword to its z.() form. +func keywordToZod(kw *bindings.LiteralKeyword) bindings.ExpressionType { + switch *kw { + case bindings.KeywordString: + return zMethod("string") + case bindings.KeywordNumber: + return zMethod("number") + case bindings.KeywordBoolean: + return zMethod("boolean") + case bindings.KeywordAny, bindings.KeywordUnknown: + return zMethod("unknown") + case bindings.KeywordVoid, bindings.KeywordUndefined: + return zMethod("undefined") + case bindings.KeywordNever: + return zMethod("never") + default: + return zMethod("unknown") + } +} + +// referenceToZod converts a type reference to a Zod expression. +// +// Resolution order: +// +// 1. References to a generic type parameter on the surrounding +// declaration fall back to z.unknown(). Zod has no runtime +// equivalent for an unbound type parameter. +// 2. Record becomes z.record(K, V). +// 3. Other utility-type generics (Omit, Pick, Partial, Required) are not +// yet modeled and fall back to z.unknown(). +// 4. A reference to the surrounding declaration emits z.lazy to break +// the value-position cycle. +// 5. Anything else emits the paired `Schema` identifier. +func (c *converter) referenceToZod(ref *bindings.ReferenceType) bindings.ExpressionType { + name := ref.Name.Ref() + + if c.typeParams[name] { + return zMethod("unknown") + } + + if name == "Record" && len(ref.Arguments) == 2 { + return zMethod("record", + c.exprToZod(ref.Arguments[0]), + c.exprToZod(ref.Arguments[1]), + ) + } + switch name { + case "Omit", "Pick", "Partial", "Required": + return zMethod("unknown") + } + + if name == c.self.Ref() { + // z.lazy((): z.ZodType => SelfSchema) breaks a value-position + // reference cycle without making the surrounding type lazy. + return zMethod("lazy", &bindings.ArrowFunction{ + ReturnType: bindings.Reference(bindings.Identifier{Name: "z.ZodType"}), + Body: &bindings.IdentifierExpression{Name: schemaIdent(ref.Name)}, + }) + } + + return &bindings.IdentifierExpression{Name: schemaIdent(ref.Name)} +} + +// unionToZod handles three union shapes: +// - T | null collapses to .nullable(). +// - A union with a single non-null member emits just that member; the +// null is dropped because the surrounding optional marker covers it. +// - Anything else becomes z.union([...]). +func (c *converter) unionToZod(u *bindings.UnionType) bindings.ExpressionType { + nonNull := make([]bindings.ExpressionType, 0, len(u.Types)) + hasNull := false + for _, t := range u.Types { + if _, ok := t.(*bindings.Null); ok { + hasNull = true + continue + } + nonNull = append(nonNull, t) + } + + if hasNull && len(nonNull) == 1 { + return chain(c.exprToZod(nonNull[0]), "nullable") + } + if !hasNull && len(nonNull) == 1 { + return c.exprToZod(nonNull[0]) + } + + args := make([]bindings.ExpressionType, 0, len(u.Types)) + for _, t := range u.Types { + args = append(args, c.exprToZod(t)) + } + return zMethod("union", &bindings.ArrayLiteralType{Elements: args}) +} + +// typeLiteralToZod inlines an object type literal as a `z.object({...})` +// expression. Members carry through the same optional-marker handling +// as top-level interface fields. +func (c *converter) typeLiteralToZod(tl *bindings.TypeLiteralNode) bindings.ExpressionType { + props := make([]*bindings.PropertyAssignment, 0, len(tl.Members)) + for _, m := range tl.Members { + expr := c.exprToZod(m.Type) + if m.QuestionToken { + expr = chain(expr, "optional") + } + props = append(props, &bindings.PropertyAssignment{ + Name: m.Name, + Initializer: expr, + }) + } + return zMethod("object", &bindings.ObjectLiteralExpression{Properties: props}) +} + +// intersectionToZod folds an intersection into a left-associative chain +// of z.intersection(a, b) calls so the schema preserves intersection +// semantics for arbitrary member counts. +func (c *converter) intersectionToZod(it *bindings.TypeIntersection) bindings.ExpressionType { + switch len(it.Types) { + case 0: + return zMethod("unknown") + case 1: + return c.exprToZod(it.Types[0]) + } + out := c.exprToZod(it.Types[0]) + for _, t := range it.Types[1:] { + out = zMethod("intersection", out, c.exprToZod(t)) + } + return out +} + +// SortByDependencies returns the nodes from a Typescript map ordered so +// that each schema's dependencies are emitted before the schema itself. +// It is intended to be passed to Typescript.SerializeInOrder when +// emitting Zod output: +// +// out, err := ts.SerializeInOrder(zod.SortByDependencies) +// +// The algorithm: +// +// 1. Partition the input into schema VariableStatements (keys ending in +// "Schema"), their paired type aliases, and other nodes. +// 2. Build a dependency graph by scanning each schema's initializer for +// IdentifierExpression references that name another schema. Bodies +// of ArrowFunction nodes are skipped, so z.lazy(() => OtherSchema) +// does not create a hard dependency on OtherSchema. This lets users +// break cross-type cycles manually with z.lazy. +// 3. Topologically sort using Kahn's algorithm with alphabetical +// tie-breaking so the output is deterministic. +// 4. Anything left in a cycle is appended in alphabetical order. The +// resulting TypeScript will compile only if those nodes use z.lazy +// to defer their references. +// +// Each schema is emitted immediately followed by its paired alias so the +// `type Foo = z.infer` line stays next to its schema. +// Other nodes (anything not matching the schema-plus-alias shape) are +// emitted first in alphabetical order so they do not interleave with +// the sorted schemas. +func SortByDependencies(nodes map[string]bindings.Node) []bindings.Node { + schemaKeys, aliasOf, otherKeys := partitionNodes(nodes) + + indegree, outEdges := buildDependencyGraph(nodes, schemaKeys) + + sorted := kahnSort(schemaKeys, indegree, outEdges) + + out := make([]bindings.Node, 0, len(nodes)) + for _, k := range otherKeys { + out = append(out, nodes[k]) + } + for _, k := range sorted { + out = append(out, nodes[k]) + if alias, ok := aliasOf[k]; ok { + out = append(out, nodes[alias]) + } + } + return out +} + +// partitionNodes splits a Typescript node map into three groups: +// - schemaKeys: keys of VariableStatement nodes ending in "Schema", +// sorted alphabetically for deterministic seed order. +// - aliasOf: a map from each schema key to its paired alias key +// (e.g. "FooSchema" -> "Foo"), present only when both exist. +// - otherKeys: all remaining keys, sorted alphabetically. +// +// Aliases that are paired with a schema are not included in otherKeys +// because SortByDependencies emits them next to their schema. +func partitionNodes(nodes map[string]bindings.Node) (schemaKeys []string, aliasOf map[string]string, otherKeys []string) { + aliasOf = map[string]string{} + schemaSet := map[string]bool{} + for k, n := range nodes { + if _, ok := n.(*bindings.VariableStatement); !ok { + continue + } + if !strings.HasSuffix(k, schemaSuffix) { + continue + } + schemaSet[k] = true + schemaKeys = append(schemaKeys, k) + aliasName := strings.TrimSuffix(k, schemaSuffix) + if _, ok := nodes[aliasName]; ok { + aliasOf[k] = aliasName + } + } + pairedAlias := map[string]bool{} + for _, v := range aliasOf { + pairedAlias[v] = true + } + for k := range nodes { + if schemaSet[k] || pairedAlias[k] { + continue + } + otherKeys = append(otherKeys, k) + } + sort.Strings(schemaKeys) + sort.Strings(otherKeys) + return schemaKeys, aliasOf, otherKeys +} + +// buildDependencyGraph walks each schema's initializer and records edges +// from dependency to dependent. outEdges[dep] lists the schemas that +// must be emitted after dep, and indegree[schema] counts how many +// schemas it depends on. ArrowFunction bodies are skipped so z.lazy +// references do not contribute hard dependencies. +func buildDependencyGraph(nodes map[string]bindings.Node, schemaKeys []string) (indegree map[string]int, outEdges map[string][]string) { + schemaSet := make(map[string]bool, len(schemaKeys)) + for _, k := range schemaKeys { + schemaSet[k] = true + } + + indegree = make(map[string]int, len(schemaKeys)) + outEdges = make(map[string][]string, len(schemaKeys)) + for _, k := range schemaKeys { + indegree[k] = 0 + } + for _, k := range schemaKeys { + vs := nodes[k].(*bindings.VariableStatement) + deps := collectSchemaDeps(vs, schemaSet, k) + for dep := range deps { + outEdges[dep] = append(outEdges[dep], k) + indegree[k]++ + } + } + for k := range outEdges { + sort.Strings(outEdges[k]) + } + return indegree, outEdges +} + +// kahnSort runs Kahn's algorithm with alphabetical tie-breaking. Nodes +// remaining in a cycle after the queue drains are appended in +// alphabetical order. Callers must still break cross-type cycles with +// z.lazy; this fallback only keeps Serialize from dropping nodes. +func kahnSort(schemaKeys []string, indegree map[string]int, outEdges map[string][]string) []string { + queue := make([]string, 0, len(schemaKeys)) + for _, k := range schemaKeys { + if indegree[k] == 0 { + queue = append(queue, k) + } + } + sort.Strings(queue) + + sorted := make([]string, 0, len(schemaKeys)) + for len(queue) > 0 { + head := queue[0] + queue = queue[1:] + sorted = append(sorted, head) + for _, dep := range outEdges[head] { + indegree[dep]-- + if indegree[dep] == 0 { + queue = append(queue, dep) + } + } + sort.Strings(queue) + } + + placed := make(map[string]bool, len(sorted)) + for _, k := range sorted { + placed[k] = true + } + for _, k := range schemaKeys { + if !placed[k] { + sorted = append(sorted, k) + } + } + return sorted +} + +// collectSchemaDeps returns the schema keys referenced as +// IdentifierExpression inside a VariableStatement, excluding the schema +// itself and excluding references inside ArrowFunction bodies (which is +// how z.lazy is emitted). +func collectSchemaDeps(vs *bindings.VariableStatement, schemas map[string]bool, self string) map[string]bool { + deps := map[string]bool{} + walk.Walk(&depVisitor{deps: deps, schemas: schemas, self: self}, vs) + return deps +} + +type depVisitor struct { + deps map[string]bool + schemas map[string]bool + self string +} + +func (d *depVisitor) Visit(node bindings.Node) walk.Visitor { + if _, ok := node.(*bindings.ArrowFunction); ok { + // Skip arrow function bodies. z.lazy(() => Other) defers its + // reference at runtime, so it should not force Other to be + // declared first. + return nil + } + if ident, ok := node.(*bindings.IdentifierExpression); ok { + name := ident.Name.Ref() + if name != d.self && d.schemas[name] { + d.deps[name] = true + } + } + return d +} diff --git a/zod/zod_e2e_test.go b/zod/zod_e2e_test.go new file mode 100644 index 0000000..8d3c965 --- /dev/null +++ b/zod/zod_e2e_test.go @@ -0,0 +1,64 @@ +//go:build !windows +// +build !windows + +package zod_test + +import ( + "flag" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/guts" + "github.com/coder/guts/config" + "github.com/coder/guts/zod" +) + +var updateGolden = flag.Bool("update", false, "update the zod e2e golden file") + +// TestAsSchemasEndToEnd runs the full guts pipeline on testdata/zod and +// compares the Zod output against a golden file. Run with -update to +// regenerate after intentional schema changes. +// +// The pipeline mirrors the recommended composition: EnumAsTypes lowers Go +// int and string enums into unions of literals, SimplifyOmitEmpty removes +// the redundant null from optional fields, zod.AsSchemas rewrites every +// Interface and Alias into a Zod schema plus inferred type, and +// ExportTypes adds the `export` modifier. +func TestAsSchemasEndToEnd(t *testing.T) { + t.Parallel() + + gen, err := guts.NewGolangParser() + require.NoError(t, err) + + err = gen.IncludeGenerate("github.com/coder/guts/testdata/zod") + require.NoError(t, err) + + gen.IncludeCustomDeclaration(config.StandardMappings()) + + ts, err := gen.ToTypescript() + require.NoError(t, err) + + ts.ApplyMutations( + config.EnumAsTypes, + config.SimplifyOmitEmpty, + zod.AsSchemas, + config.ExportTypes, + ) + + output, err := ts.SerializeInOrder(zod.SortByDependencies) + require.NoError(t, err) + + golden := filepath.Join("..", "testdata", "zod", "golden.ts") + if *updateGolden { + err = os.WriteFile(golden, []byte(output), 0o644) + require.NoError(t, err) + return + } + + expected, err := os.ReadFile(golden) + require.NoError(t, err, "run with -update to generate the golden file") + require.Equal(t, string(expected), output) +} diff --git a/zod/zod_test.go b/zod/zod_test.go new file mode 100644 index 0000000..5f52b47 --- /dev/null +++ b/zod/zod_test.go @@ -0,0 +1,548 @@ +package zod_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/guts" + "github.com/coder/guts/bindings" + "github.com/coder/guts/zod" +) + +// newTS constructs a fresh *guts.Typescript with no Go-derived nodes so a +// test can install its own minimal fixture with SetNode. +func newTS(t *testing.T) *guts.Typescript { + t.Helper() + gen, err := guts.NewGolangParser() + require.NoError(t, err) + ts, err := gen.ToTypescript() + require.NoError(t, err) + return ts +} + +func runZod(t *testing.T, ts *guts.Typescript) string { + t.Helper() + ts.ApplyMutations(zod.AsSchemas) + out, err := ts.Serialize() + require.NoError(t, err) + return out +} + +// runZodInOrder runs AsSchemas and then serializes with +// SortByDependencies so tests can assert ordering as well as content. +func runZodInOrder(t *testing.T, ts *guts.Typescript) string { + t.Helper() + ts.ApplyMutations(zod.AsSchemas) + out, err := ts.SerializeInOrder(zod.SortByDependencies) + require.NoError(t, err) + return out +} + +func ident(name string) bindings.Identifier { return bindings.Identifier{Name: name} } + +func kw(k bindings.LiteralKeyword) *bindings.LiteralKeyword { return &k } + +// TestObjectFields exercises the keyword-to-z mappings inside an interface +// (z.string, z.number, z.boolean) and the optional refinement attached to +// fields with a question token. +func TestObjectFields(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("User", &bindings.Interface{ + Name: ident("User"), + Fields: []*bindings.PropertySignature{ + {Name: "id", Type: kw(bindings.KeywordString)}, + {Name: "name", Type: kw(bindings.KeywordString), QuestionToken: true}, + {Name: "age", Type: kw(bindings.KeywordNumber)}, + {Name: "active", Type: kw(bindings.KeywordBoolean)}, + }, + })) + + out := runZod(t, ts) + require.Contains(t, out, "const UserSchema = z.object(") + require.Contains(t, out, "id: z.string()") + require.Contains(t, out, "name: z.string().optional()") + require.Contains(t, out, "age: z.number()") + require.Contains(t, out, "active: z.boolean()") + require.Contains(t, out, "type User = z.infer") +} + +// TestStringLiteralUnionBecomesEnum checks that an Alias whose Type is a +// union of string literals collapses to z.enum([...]) rather than the more +// verbose z.union of z.literal calls. +func TestStringLiteralUnionBecomesEnum(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Status", &bindings.Alias{ + Name: ident("Status"), + Type: bindings.Union( + &bindings.LiteralType{Value: "active"}, + &bindings.LiteralType{Value: "inactive"}, + &bindings.LiteralType{Value: "banned"}, + ), + })) + + out := runZod(t, ts) + require.Contains(t, out, "const StatusSchema = z.enum(") + require.Contains(t, out, `"active"`) + require.Contains(t, out, `"inactive"`) + require.Contains(t, out, `"banned"`) + require.NotContains(t, out, "z.literal", "string-literal union must not fall back to z.literal+z.union") +} + +// TestNullableField checks the union-with-null collapse: `T | null` becomes +// `T.nullable()` instead of `z.union([T, z.null()])`. +func TestNullableField(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Thing", &bindings.Interface{ + Name: ident("Thing"), + Fields: []*bindings.PropertySignature{ + { + Name: "error", + Type: bindings.Union(kw(bindings.KeywordString), &bindings.Null{}), + }, + }, + })) + + out := runZod(t, ts) + require.Contains(t, out, "error: z.string().nullable()") + require.NotContains(t, out, "z.union", "T|null must not emit z.union") +} + +// TestOptionalNullableField checks that QuestionToken and a `| null` union +// both apply, in nullable-then-optional order. +func TestOptionalNullableField(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Thing", &bindings.Interface{ + Name: ident("Thing"), + Fields: []*bindings.PropertySignature{ + { + Name: "parent_id", + Type: bindings.Union(kw(bindings.KeywordString), &bindings.Null{}), + QuestionToken: true, + }, + }, + })) + + out := runZod(t, ts) + require.Contains(t, out, "parent_id: z.string().nullable().optional()") +} + +// TestArrayField checks that a TypeScript array maps to z.array. +func TestArrayField(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Thing", &bindings.Interface{ + Name: ident("Thing"), + Fields: []*bindings.PropertySignature{ + {Name: "tags", Type: bindings.Array(kw(bindings.KeywordString))}, + }, + })) + + require.Contains(t, runZod(t, ts), "tags: z.array(z.string())") +} + +// TestReferenceField checks that a bare type reference resolves to the +// paired Schema identifier. +func TestReferenceField(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("ErrorInfo", &bindings.Interface{ + Name: ident("ErrorInfo"), + Fields: []*bindings.PropertySignature{ + {Name: "message", Type: kw(bindings.KeywordString)}, + }, + })) + require.NoError(t, ts.SetNode("Chat", &bindings.Interface{ + Name: ident("Chat"), + Fields: []*bindings.PropertySignature{ + {Name: "last_error", Type: bindings.Reference(ident("ErrorInfo"))}, + }, + })) + + require.Contains(t, runZod(t, ts), "last_error: ErrorInfoSchema") +} + +// TestRecordField checks that Record maps to z.record(K, V). +func TestRecordField(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Thing", &bindings.Interface{ + Name: ident("Thing"), + Fields: []*bindings.PropertySignature{ + { + Name: "labels", + Type: bindings.Reference(ident("Record"), + kw(bindings.KeywordString), + kw(bindings.KeywordString), + ), + }, + }, + })) + + require.Contains(t, runZod(t, ts), "labels: z.record(z.string(), z.string())") +} + +// TestInlineObjectLiteral checks that an inline object type produces an +// inline z.object expression rather than a free-standing schema. +func TestInlineObjectLiteral(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Outer", &bindings.Interface{ + Name: ident("Outer"), + Fields: []*bindings.PropertySignature{ + { + Name: "nested", + Type: &bindings.TypeLiteralNode{ + Members: []*bindings.PropertySignature{ + {Name: "x", Type: kw(bindings.KeywordNumber)}, + {Name: "y", Type: kw(bindings.KeywordNumber)}, + }, + }, + }, + }, + })) + + out := runZod(t, ts) + require.Contains(t, out, "nested: z.object(") + require.Contains(t, out, "x: z.number()") + require.Contains(t, out, "y: z.number()") +} + +// TestSelfReferenceLazy checks that a field whose type references the +// enclosing type wraps the schema in z.lazy() so the value-position +// reference does not fire before the binding exists. +func TestSelfReferenceLazy(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Tree", &bindings.Interface{ + Name: ident("Tree"), + Fields: []*bindings.PropertySignature{ + {Name: "value", Type: kw(bindings.KeywordNumber)}, + {Name: "children", Type: bindings.Array(bindings.Reference(ident("Tree")))}, + }, + })) + + out := runZod(t, ts) + require.Contains(t, out, "children: z.array(z.lazy((): z.ZodType => TreeSchema))") +} + +// TestHeritageExtend checks that single-base heritage maps to +// `BaseSchema.extend({...})`. +func TestHeritageExtend(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Base", &bindings.Interface{ + Name: ident("Base"), + Fields: []*bindings.PropertySignature{ + {Name: "id", Type: kw(bindings.KeywordString)}, + }, + })) + require.NoError(t, ts.SetNode("Child", &bindings.Interface{ + Name: ident("Child"), + Heritage: []*bindings.HeritageClause{ + {Args: []bindings.ExpressionType{bindings.Reference(ident("Base"))}}, + }, + Fields: []*bindings.PropertySignature{ + {Name: "extra", Type: kw(bindings.KeywordString)}, + }, + })) + + out := runZod(t, ts) + require.Contains(t, out, "const ChildSchema = BaseSchema.extend(") + require.Contains(t, out, "extra: z.string()") +} + +// TestAppendsZodImport pins that AsSchemas appends the zod import so the +// generated file is self-contained without the caller having to know to +// add config.InjectImport("zod", "z") separately. +func TestAppendsZodImport(t *testing.T) { + t.Parallel() + + ts := newTS(t) + out := runZod(t, ts) + require.Contains(t, out, `import { z } from "zod";`) +} + +// TestMixedUnionStaysUnion checks that a union that is not all string +// literals and not just T|null keeps the z.union shape. +func TestMixedUnionStaysUnion(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("MixedUnion", &bindings.Alias{ + Name: ident("MixedUnion"), + Type: bindings.Union( + kw(bindings.KeywordString), + kw(bindings.KeywordNumber), + ), + })) + + out := runZod(t, ts) + require.Contains(t, out, "z.union(") + require.Contains(t, out, "z.string()") + require.Contains(t, out, "z.number()") +} + +// TestSingleMemberUnionUnwraps checks the single-non-null-member shortcut: +// `union { T }` collapses to just `T` rather than `z.union([T])`. +func TestSingleMemberUnionUnwraps(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Wrapped", &bindings.Alias{ + Name: ident("Wrapped"), + Type: bindings.Union(kw(bindings.KeywordString)), + })) + + out := runZod(t, ts) + require.Contains(t, out, "const WrappedSchema = z.string()") + require.NotContains(t, out, "z.union", "single-member union must not wrap in z.union") +} + +// TestPrefixedReference pins the cross-package prefix passthrough. An +// Identifier with a Prefix must flow through schemaIdent and emit the +// prefixed Schema name in both the schema declaration and the reference. +func TestPrefixedReference(t *testing.T) { + t.Parallel() + + prefixed := bindings.Identifier{Name: "Item", Prefix: "External"} + + ts := newTS(t) + require.NoError(t, ts.SetNode(prefixed.Ref(), &bindings.Interface{ + Name: prefixed, + Fields: []*bindings.PropertySignature{ + {Name: "id", Type: kw(bindings.KeywordString)}, + }, + })) + require.NoError(t, ts.SetNode("Holder", &bindings.Interface{ + Name: ident("Holder"), + Fields: []*bindings.PropertySignature{ + {Name: "item", Type: bindings.Reference(prefixed)}, + }, + })) + + out := runZod(t, ts) + require.Contains(t, out, "const ExternalItemSchema = z.object(") + require.Contains(t, out, "item: ExternalItemSchema") + require.Contains(t, out, "type ExternalItem = z.infer", + strings.TrimSpace(out)) +} + +// TestGenericTypeParameterFallsBackToUnknown checks that a reference to a +// generic type parameter on the surrounding declaration emits z.unknown(). +// Zod has no runtime equivalent for an unbound type parameter, so the +// fallback is the most useful schema that still type-checks. +func TestGenericTypeParameterFallsBackToUnknown(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("IDPSyncMapping", &bindings.Interface{ + Name: ident("IDPSyncMapping"), + Parameters: []*bindings.TypeParameter{ + {Name: ident("ResourceIdType")}, + }, + Fields: []*bindings.PropertySignature{ + {Name: "id", Type: bindings.Reference(ident("ResourceIdType"))}, + {Name: "name", Type: kw(bindings.KeywordString)}, + }, + })) + + out := runZod(t, ts) + require.Contains(t, out, "id: z.unknown()", + "reference to type parameter ResourceIdType must fall back to z.unknown()") + require.NotContains(t, out, "ResourceIdTypeSchema", + "a non-existent ResourceIdTypeSchema reference must not be emitted") + require.Contains(t, out, "name: z.string()") +} + +// TestGenericTypeParameterOnAlias is the alias-side equivalent of +// TestGenericTypeParameterFallsBackToUnknown. +func TestGenericTypeParameterOnAlias(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Wrapper", &bindings.Alias{ + Name: ident("Wrapper"), + Parameters: []*bindings.TypeParameter{ + {Name: ident("T")}, + }, + Type: bindings.Reference(ident("T")), + })) + + out := runZod(t, ts) + require.Contains(t, out, "const WrapperSchema = z.unknown()") + require.NotContains(t, out, "TSchema") +} + +// TestSortByDependenciesForwardReference pins the ordering guarantee: +// a schema that references another schema must be emitted after it, +// regardless of alphabetical order. +func TestSortByDependenciesForwardReference(t *testing.T) { + t.Parallel() + + ts := newTS(t) + // Define Foo first; it references Bar via heritage. Alphabetically + // FooSchema would precede BarSchema, but topologically it must + // follow it. + require.NoError(t, ts.SetNode("Foo", &bindings.Interface{ + Name: ident("Foo"), + Heritage: []*bindings.HeritageClause{ + {Args: []bindings.ExpressionType{bindings.Reference(ident("Bar"))}}, + }, + Fields: []*bindings.PropertySignature{ + {Name: "x", Type: kw(bindings.KeywordString)}, + }, + })) + require.NoError(t, ts.SetNode("Bar", &bindings.Interface{ + Name: ident("Bar"), + Fields: []*bindings.PropertySignature{ + {Name: "y", Type: kw(bindings.KeywordString)}, + }, + })) + + out := runZodInOrder(t, ts) + barIdx := strings.Index(out, "const BarSchema") + fooIdx := strings.Index(out, "const FooSchema") + require.NotEqual(t, -1, barIdx, "BarSchema must be emitted") + require.NotEqual(t, -1, fooIdx, "FooSchema must be emitted") + require.Less(t, barIdx, fooIdx, + "BarSchema must precede FooSchema because Foo extends Bar") +} + +// TestSortByDependenciesAlphabeticalTiebreak verifies that independent +// schemas are emitted alphabetically. This keeps output deterministic +// when there are no dependency edges between two schemas. +func TestSortByDependenciesAlphabeticalTiebreak(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Beta", &bindings.Interface{ + Name: ident("Beta"), + Fields: []*bindings.PropertySignature{ + {Name: "x", Type: kw(bindings.KeywordString)}, + }, + })) + require.NoError(t, ts.SetNode("Alpha", &bindings.Interface{ + Name: ident("Alpha"), + Fields: []*bindings.PropertySignature{ + {Name: "x", Type: kw(bindings.KeywordString)}, + }, + })) + + out := runZodInOrder(t, ts) + alphaIdx := strings.Index(out, "const AlphaSchema") + betaIdx := strings.Index(out, "const BetaSchema") + require.NotEqual(t, -1, alphaIdx) + require.NotEqual(t, -1, betaIdx) + require.Less(t, alphaIdx, betaIdx, + "independent schemas must be emitted alphabetically") +} + +// TestSortByDependenciesPairsAlias verifies that each schema's inferred +// type alias is emitted immediately after the schema itself, so the +// pair stays visually grouped in the output. +func TestSortByDependenciesPairsAlias(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Foo", &bindings.Interface{ + Name: ident("Foo"), + Fields: []*bindings.PropertySignature{ + {Name: "x", Type: kw(bindings.KeywordString)}, + }, + })) + + out := runZodInOrder(t, ts) + schemaIdx := strings.Index(out, "const FooSchema") + aliasIdx := strings.Index(out, "type Foo = z.infer") + require.NotEqual(t, -1, schemaIdx) + require.NotEqual(t, -1, aliasIdx) + require.Less(t, schemaIdx, aliasIdx, + "alias must follow the schema it infers from") + // And no other declaration may sit between the pair. Start scanning + // after the schema's own `const ` so we do not match itself. + between := out[schemaIdx+len("const "):aliasIdx] + require.NotContains(t, between, "const ", "schema and its alias must be adjacent") +} + +// TestSortByDependenciesLazyBreaksCycle exercises the cross-type cycle +// path. Two schemas that reference each other through z.lazy must both +// be emitted; the lazy reference removes the hard dependency edge. +// +// This test simulates what a user would do to break a true cycle: wrap +// each cross-reference in z.lazy. The deps walker skips ArrowFunction +// bodies, so neither schema depends on the other, and Kahn's algorithm +// emits both in alphabetical order. +func TestSortByDependenciesLazyBreaksCycle(t *testing.T) { + t.Parallel() + + ts := newTS(t) + // Hand-build the resulting schemas so we can be sure both + // references are inside ArrowFunctions. + lazyRef := func(target string) bindings.ExpressionType { + return &bindings.CallExpression{ + Expression: &bindings.PropertyAccessExpression{ + Expression: &bindings.IdentifierExpression{Name: ident("z")}, + Name: "lazy", + }, + Arguments: []bindings.ExpressionType{ + &bindings.ArrowFunction{ + Body: &bindings.IdentifierExpression{Name: ident(target)}, + }, + }, + } + } + makeSchema := func(name string, other string) *bindings.VariableStatement { + return &bindings.VariableStatement{ + Declarations: &bindings.VariableDeclarationList{ + Flags: bindings.NodeFlagsConstant, + Declarations: []*bindings.VariableDeclaration{ + { + Name: ident(name), + Initializer: &bindings.CallExpression{ + Expression: &bindings.PropertyAccessExpression{ + Expression: &bindings.IdentifierExpression{Name: ident("z")}, + Name: "object", + }, + Arguments: []bindings.ExpressionType{ + &bindings.ObjectLiteralExpression{ + Properties: []*bindings.PropertyAssignment{ + {Name: "ref", Initializer: lazyRef(other)}, + }, + }, + }, + }, + }, + }, + }, + } + } + require.NoError(t, ts.SetNode("ASchema", makeSchema("ASchema", "BSchema"))) + require.NoError(t, ts.SetNode("BSchema", makeSchema("BSchema", "ASchema"))) + + out, err := ts.SerializeInOrder(zod.SortByDependencies) + require.NoError(t, err) + aIdx := strings.Index(out, "const ASchema") + bIdx := strings.Index(out, "const BSchema") + require.NotEqual(t, -1, aIdx, "ASchema must be emitted") + require.NotEqual(t, -1, bIdx, "BSchema must be emitted") + // Lazy references should not count as dependencies, so alphabetical + // order is the natural fallback. + require.Less(t, aIdx, bIdx, + "lazy-only references must not create a hard dependency") +}