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..4523159 --- /dev/null +++ b/testdata/zod/golden.ts @@ -0,0 +1,42 @@ +// Code generated by 'guts'. DO NOT EDIT. + +import { z } from "zod"; + +export type Base = z.infer; + +export const BaseSchema = z.object({ + id: z.string(), + created_at: z.string(), + updated_at: z.string() +}); + +export type CreateTicketRequest = z.infer; + +export const CreateTicketRequestSchema = z.object({ + title: z.string(), + description: z.string().optional(), + priority: PrioritySchema, + tags: z.array(z.string()).optional() +}); + +export type Priority = z.infer; + +export const PrioritySchema = z.union([z.literal(2), z.literal(0), z.literal(1)]); + +export type Status = z.infer; + +export const StatusSchema = z.enum(["active", "closed", "pending"]); + +export type Ticket = 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)) +}); + 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..85ddd16 --- /dev/null +++ b/zod/zod.go @@ -0,0 +1,410 @@ +// 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. +// +// 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 ( + "github.com/coder/guts" + "github.com/coder/guts/bindings" +) + +// 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) + } + } +} + +// 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 + "Schema", + 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, + }, + } +} + +// 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) + + objLit := buildFieldsObject(iface.Fields, typeName) + + 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) + + var initializer bindings.ExpressionType + if union, ok := alias.Type.(*bindings.UnionType); ok && isStringLiteralUnion(union) { + initializer = zMethod("enum", stringLiteralArray(union)) + } else { + initializer = exprToZod(alias.Type, typeName) + } + + 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 buildFieldsObject(fields []*bindings.PropertySignature, selfName bindings.Identifier) *bindings.ObjectLiteralExpression { + props := make([]*bindings.PropertyAssignment, 0, len(fields)) + for _, f := range fields { + expr := exprToZod(f.Type, selfName) + 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. selfName is the type currently being +// emitted; references back to it use z.lazy() to avoid +// reference-before-declaration errors. +func exprToZod(expr bindings.ExpressionType, selfName bindings.Identifier) 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 referenceToZod(e, selfName) + case *bindings.ArrayType: + return zMethod("array", exprToZod(e.Node, selfName)) + 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", exprToZod(e.Node, selfName)) + case *bindings.UnionType: + return unionToZod(e, selfName) + case *bindings.Null: + return zMethod("null") + case *bindings.TypeLiteralNode: + return typeLiteralToZod(e, selfName) + case *bindings.TypeIntersection: + return intersectionToZod(e, selfName) + case *bindings.OperatorNodeType: + // readonly/keyof/unique wrappers do not affect the Zod schema; + // unwrap and emit the inner type directly. + return exprToZod(e.Type, selfName) + 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. Bare +// references emit the paired `Schema` identifier. The Record +// generic becomes `z.record(K, V)`. Other utility-type generics (Omit, +// Pick, Partial, Required) are not yet modeled and fall back to +// z.unknown(). +func referenceToZod(ref *bindings.ReferenceType, selfName bindings.Identifier) bindings.ExpressionType { + name := ref.Name.Ref() + + if name == "Record" && len(ref.Arguments) == 2 { + return zMethod("record", + exprToZod(ref.Arguments[0], selfName), + exprToZod(ref.Arguments[1], selfName), + ) + } + switch name { + case "Omit", "Pick", "Partial", "Required": + return zMethod("unknown") + } + + if name == selfName.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 unionToZod(u *bindings.UnionType, selfName bindings.Identifier) 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(exprToZod(nonNull[0], selfName), "nullable") + } + if !hasNull && len(nonNull) == 1 { + return exprToZod(nonNull[0], selfName) + } + + args := make([]bindings.ExpressionType, 0, len(u.Types)) + for _, t := range u.Types { + args = append(args, exprToZod(t, selfName)) + } + 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 typeLiteralToZod(tl *bindings.TypeLiteralNode, selfName bindings.Identifier) bindings.ExpressionType { + props := make([]*bindings.PropertyAssignment, 0, len(tl.Members)) + for _, m := range tl.Members { + expr := exprToZod(m.Type, selfName) + 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 intersectionToZod(it *bindings.TypeIntersection, selfName bindings.Identifier) bindings.ExpressionType { + switch len(it.Types) { + case 0: + return zMethod("unknown") + case 1: + return exprToZod(it.Types[0], selfName) + } + out := exprToZod(it.Types[0], selfName) + for _, t := range it.Types[1:] { + out = zMethod("intersection", out, exprToZod(t, selfName)) + } + return out +} diff --git a/zod/zod_e2e_test.go b/zod/zod_e2e_test.go new file mode 100644 index 0000000..27c3a6e --- /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.Serialize() + 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..00b7af7 --- /dev/null +++ b/zod/zod_test.go @@ -0,0 +1,334 @@ +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 +} + +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)) +}