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..20c0a09 --- /dev/null +++ b/testdata/zod/golden.ts @@ -0,0 +1,40 @@ +// 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 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 PrioritySchema = z.union([z.literal(2), z.literal(0), z.literal(1)]); +export type Priority = 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..79f6ad1 --- /dev/null +++ b/testdata/zod/types.go @@ -0,0 +1,54 @@ +// Package zod provides sample types for testing the Zod serializer. +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..54e32f5 --- /dev/null +++ b/zod/zod.go @@ -0,0 +1,345 @@ +// Package zod serializes a guts TypeScript AST into Zod v4 schema +// declarations. It walks the same intermediate representation that +// the TypeScript serializer uses, so all type mappings, mutations, +// and overrides applied via guts pipelines are reflected in the +// output. +package zod + +import ( + "fmt" + "sort" + "strings" + + "github.com/coder/guts" + "github.com/coder/guts/bindings" +) + +// Serialize walks all nodes in ts and returns Zod v4 schema code. +// Nodes are emitted in alphabetical order. +func Serialize(ts *guts.Typescript) string { + nodes := make(map[string]bindings.Node) + ts.ForEach(func(name string, node bindings.Node) { + nodes[name] = node + }) + + names := make([]string, 0, len(nodes)) + for name := range nodes { + names = append(names, name) + } + sort.Strings(names) + + var b strings.Builder + b.WriteString("// Code generated by 'guts'. DO NOT EDIT.\n") + b.WriteString("import { z } from \"zod\";\n\n") + + for _, name := range names { + s := serializeNode(name, nodes[name]) + if s != "" { + b.WriteString(s) + b.WriteString("\n") + } + } + return b.String() +} + +// SerializeFilter is like Serialize but only emits nodes for which +// the filter function returns true. +func SerializeFilter(ts *guts.Typescript, filter func(name string) bool) string { + nodes := make(map[string]bindings.Node) + ts.ForEach(func(name string, node bindings.Node) { + if filter(name) { + nodes[name] = node + } + }) + + names := make([]string, 0, len(nodes)) + for name := range nodes { + names = append(names, name) + } + sort.Strings(names) + + var b strings.Builder + b.WriteString("// Code generated by 'guts'. DO NOT EDIT.\n") + b.WriteString("import { z } from \"zod\";\n\n") + + for _, name := range names { + s := serializeNode(name, nodes[name]) + if s != "" { + b.WriteString(s) + b.WriteString("\n") + } + } + return b.String() +} + +func serializeNode(name string, node bindings.Node) string { + switch n := node.(type) { + case *bindings.Interface: + return serializeInterface(name, n) + case *bindings.Alias: + return serializeAlias(name, n) + case *bindings.VariableStatement: + return serializeVariableStatement(name, n) + default: + return "" + } +} + +func serializeInterface(name string, iface *bindings.Interface) string { + schema := schemaName(name) + var b strings.Builder + + // Handle struct embedding (heritage clauses) via .extend(). + base := "" + for _, h := range iface.Heritage { + for _, arg := range h.Args { + ref := "" + if ewta, ok := arg.(*bindings.ExpressionWithTypeArguments); ok { + if rt, ok := ewta.Expression.(*bindings.ReferenceType); ok { + ref = schemaName(rt.Name.Ref()) + } + } + if rt, ok := arg.(*bindings.ReferenceType); ok { + ref = schemaName(rt.Name.Ref()) + } + if ref != "" { + if base != "" { + panic(fmt.Sprintf("multiple heritage bases for %s: %s and %s (Zod has no multiple inheritance)", name, base, ref)) + } + base = ref + } + } + } + + if base != "" && len(iface.Fields) > 0 { + b.WriteString(fmt.Sprintf("export const %s = %s.extend({\n", schema, base)) + } else if base != "" { + b.WriteString(fmt.Sprintf("export const %s = %s;\n", schema, base)) + b.WriteString(fmt.Sprintf("export type %s = z.infer;\n", name, schema)) + return b.String() + } else { + b.WriteString(fmt.Sprintf("export const %s = z.object({\n", schema)) + } + for _, f := range iface.Fields { + zodType := exprToZod(f.Type, name) + if f.QuestionToken { + zodType += ".optional()" + } + b.WriteString(fmt.Sprintf(" %s: %s,\n", f.Name, zodType)) + } + b.WriteString("});\n") + b.WriteString(fmt.Sprintf("export type %s = z.infer;\n", name, schema)) + return b.String() +} + +func serializeAlias(name string, alias *bindings.Alias) string { + // Enums (union of string literals) become z.enum([...]). + if union, ok := alias.Type.(*bindings.UnionType); ok { + if isStringLiteralUnion(union) { + return serializeStringEnum(name, union) + } + } + + schema := schemaName(name) + zodType := exprToZod(alias.Type, name) + var b strings.Builder + b.WriteString(fmt.Sprintf("export const %s = %s;\n", schema, zodType)) + b.WriteString(fmt.Sprintf("export type %s = z.infer;\n", name, schema)) + return b.String() +} + +func serializeStringEnum(name string, union *bindings.UnionType) string { + schema := schemaName(name) + var values []string + for _, t := range union.Types { + if lit, ok := t.(*bindings.LiteralType); ok { + values = append(values, fmt.Sprintf(" %q", lit.Value)) + } + } + var b strings.Builder + b.WriteString(fmt.Sprintf("export const %s = z.enum([\n", schema)) + b.WriteString(strings.Join(values, ",\n")) + b.WriteString(",\n]);\n") + b.WriteString(fmt.Sprintf("export type %s = z.infer;\n", name, schema)) + return b.String() +} + +func serializeVariableStatement(name string, vs *bindings.VariableStatement) string { + _ = vs + return "" +} + +// exprToZod converts an AST expression to Zod code. selfName is +// the name of the type currently being serialized, used to detect +// self-references and emit z.lazy(). +func exprToZod(expr bindings.ExpressionType, selfName string) string { + if expr == nil { + return "z.unknown()" + } + switch e := expr.(type) { + case *bindings.LiteralKeyword: + return keywordToZod(e) + case *bindings.LiteralType: + return literalToZod(e) + case *bindings.ReferenceType: + return referenceToZod(e, selfName) + case *bindings.ArrayType: + return fmt.Sprintf("z.array(%s)", exprToZod(e.Node, selfName)) + case *bindings.UnionType: + return unionToZod(e, selfName) + case *bindings.Null: + return "z.null()" + case *bindings.TypeLiteralNode: + return objectLiteralToZod(e, selfName) + case *bindings.TypeIntersection: + return intersectionToZod(e, selfName) + case *bindings.TupleType: + return tupleToZod(e, selfName) + case *bindings.OperatorNodeType: + return exprToZod(e.Type, selfName) + default: + return "z.unknown()" + } +} + +func keywordToZod(kw *bindings.LiteralKeyword) string { + switch *kw { + case bindings.KeywordString: + return "z.string()" + case bindings.KeywordNumber: + return "z.number()" + case bindings.KeywordBoolean: + return "z.boolean()" + case bindings.KeywordAny, bindings.KeywordUnknown: + return "z.unknown()" + case bindings.KeywordVoid, bindings.KeywordUndefined: + return "z.undefined()" + case bindings.KeywordNever: + return "z.never()" + default: + return "z.unknown()" + } +} + +func literalToZod(lit *bindings.LiteralType) string { + switch v := lit.Value.(type) { + case string: + return fmt.Sprintf("z.literal(%q)", v) + case bool: + return fmt.Sprintf("z.literal(%t)", v) + case int64: + return fmt.Sprintf("z.literal(%d)", v) + case float64: + return fmt.Sprintf("z.literal(%g)", v) + default: + return fmt.Sprintf("z.literal(%v)", v) + } +} + +func referenceToZod(ref *bindings.ReferenceType, selfName string) string { + name := ref.Name.Ref() + schema := schemaName(name) + + if name == "Record" && len(ref.Arguments) == 2 { + return fmt.Sprintf("z.record(%s, %s)", + exprToZod(ref.Arguments[0], selfName), + exprToZod(ref.Arguments[1], selfName), + ) + } + + switch name { + case "Omit", "Pick", "Partial", "Required": + return "z.unknown()" + } + + // Self-referential types need z.lazy() to avoid + // reference-before-declaration errors. + if name == selfName { + return fmt.Sprintf("z.lazy((): z.ZodType => %s)", schema) + } + + return schema +} + +func unionToZod(u *bindings.UnionType, selfName string) string { + nonNull := make([]bindings.ExpressionType, 0, len(u.Types)) + hasNull := false + for _, t := range u.Types { + if _, ok := t.(*bindings.Null); ok { + hasNull = true + } else { + nonNull = append(nonNull, t) + } + } + + if hasNull && len(nonNull) == 1 { + return exprToZod(nonNull[0], selfName) + ".nullable()" + } + + if !hasNull && len(nonNull) == 1 { + return exprToZod(nonNull[0], selfName) + } + + parts := make([]string, 0, len(u.Types)) + for _, t := range u.Types { + parts = append(parts, exprToZod(t, selfName)) + } + return fmt.Sprintf("z.union([%s])", strings.Join(parts, ", ")) +} + +func objectLiteralToZod(tl *bindings.TypeLiteralNode, selfName string) string { + var b strings.Builder + b.WriteString("z.object({\n") + for _, f := range tl.Members { + zodType := exprToZod(f.Type, selfName) + if f.QuestionToken { + zodType += ".optional()" + } + b.WriteString(fmt.Sprintf(" %s: %s,\n", f.Name, zodType)) + } + b.WriteString(" })") + return b.String() +} + +func intersectionToZod(inter *bindings.TypeIntersection, selfName string) string { + if len(inter.Types) == 0 { + return "z.unknown()" + } + if len(inter.Types) == 1 { + return exprToZod(inter.Types[0], selfName) + } + parts := make([]string, 0, len(inter.Types)) + for _, t := range inter.Types { + parts = append(parts, exprToZod(t, selfName)) + } + result := parts[0] + for _, p := range parts[1:] { + result = fmt.Sprintf("z.intersection(%s, %s)", result, p) + } + return result +} + +func tupleToZod(t *bindings.TupleType, selfName string) string { + inner := exprToZod(t.Node, selfName) + return fmt.Sprintf("z.array(%s)", inner) +} + +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 +} + +func schemaName(typeName string) string { + return typeName + "Schema" +} diff --git a/zod/zod_e2e_test.go b/zod/zod_e2e_test.go new file mode 100644 index 0000000..d2e8ad2 --- /dev/null +++ b/zod/zod_e2e_test.go @@ -0,0 +1,55 @@ +//go:build !windows + +package zod_test + +import ( + "flag" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "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 golden files") + +// TestSerializeEndToEnd runs the full guts pipeline on a real Go +// package and compares the Zod output against a golden file. +// Run with -update to regenerate the golden file. +func TestSerializeEndToEnd(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, + ) + + output := zod.Serialize(ts) + + 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 golden file") + assert.Equal(t, string(expected), output) +} diff --git a/zod/zod_test.go b/zod/zod_test.go new file mode 100644 index 0000000..40e739b --- /dev/null +++ b/zod/zod_test.go @@ -0,0 +1,280 @@ +package zod_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/guts" + "github.com/coder/guts/bindings" + "github.com/coder/guts/zod" +) + +// TestSerializeInterface verifies z.object() generation from an +// Interface node with various field types and optionality. +func TestSerializeInterface(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + ts.SetNode(t, "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 := zod.Serialize(ts.TS) + + assert.Contains(t, out, `export const UserSchema = z.object({`) + assert.Contains(t, out, `id: z.string(),`) + assert.Contains(t, out, `name: z.string().optional(),`) + assert.Contains(t, out, `age: z.number(),`) + assert.Contains(t, out, `active: z.boolean(),`) + assert.Contains(t, out, `export type User = z.infer;`) +} + +// TestSerializeStringEnum verifies z.enum([...]) generation from a +// union-of-literals alias. +func TestSerializeStringEnum(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + ts.SetNode(t, "Status", &bindings.Alias{ + Name: ident("Status"), + Type: bindings.Union( + &bindings.LiteralType{Value: "active"}, + &bindings.LiteralType{Value: "inactive"}, + &bindings.LiteralType{Value: "banned"}, + ), + }) + + out := zod.Serialize(ts.TS) + + assert.Contains(t, out, `export const StatusSchema = z.enum([`) + assert.Contains(t, out, `"active"`) + assert.Contains(t, out, `"inactive"`) + assert.Contains(t, out, `"banned"`) +} + +// TestSerializeNullable verifies T | null maps to .nullable(). +func TestSerializeNullable(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + ts.SetNode(t, "Thing", &bindings.Interface{ + Name: ident("Thing"), + Fields: []*bindings.PropertySignature{ + { + Name: "error", + Type: bindings.Union(kw(bindings.KeywordString), &bindings.Null{}), + }, + }, + }) + + out := zod.Serialize(ts.TS) + + assert.Contains(t, out, `error: z.string().nullable(),`) +} + +// TestSerializeOptionalNullable verifies *T with omitempty maps to +// .nullable().optional() when QuestionToken is set. +func TestSerializeOptionalNullable(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + ts.SetNode(t, "Thing", &bindings.Interface{ + Name: ident("Thing"), + Fields: []*bindings.PropertySignature{ + { + Name: "parent_id", + Type: bindings.Union(kw(bindings.KeywordString), &bindings.Null{}), + QuestionToken: true, + }, + }, + }) + + out := zod.Serialize(ts.TS) + + assert.Contains(t, out, `parent_id: z.string().nullable().optional(),`) +} + +// TestSerializeArray verifies z.array() generation. +func TestSerializeArray(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + ts.SetNode(t, "Thing", &bindings.Interface{ + Name: ident("Thing"), + Fields: []*bindings.PropertySignature{ + {Name: "tags", Type: bindings.Array(kw(bindings.KeywordString))}, + }, + }) + + out := zod.Serialize(ts.TS) + + assert.Contains(t, out, `tags: z.array(z.string()),`) +} + +// TestSerializeReference verifies schema references between types. +func TestSerializeReference(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + ts.SetNode(t, "ErrorInfo", &bindings.Interface{ + Name: ident("ErrorInfo"), + Fields: []*bindings.PropertySignature{ + {Name: "message", Type: kw(bindings.KeywordString)}, + }, + }) + ts.SetNode(t, "Chat", &bindings.Interface{ + Name: ident("Chat"), + Fields: []*bindings.PropertySignature{ + {Name: "last_error", Type: bindings.Reference(ident("ErrorInfo"))}, + }, + }) + + out := zod.Serialize(ts.TS) + + assert.Contains(t, out, `last_error: ErrorInfoSchema,`) +} + +// TestSerializeRecord verifies Record maps to z.record(). +func TestSerializeRecord(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + ts.SetNode(t, "Thing", &bindings.Interface{ + Name: ident("Thing"), + Fields: []*bindings.PropertySignature{ + { + Name: "labels", + Type: bindings.Reference(ident("Record"), + kw(bindings.KeywordString), + kw(bindings.KeywordString), + ), + }, + }, + }) + + out := zod.Serialize(ts.TS) + + assert.Contains(t, out, `labels: z.record(z.string(), z.string()),`) +} + +// TestSerializeFilter verifies that SerializeFilter only includes +// nodes matching the filter predicate. +func TestSerializeFilter(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + ts.SetNode(t, "Keep", &bindings.Interface{ + Name: ident("Keep"), + Fields: []*bindings.PropertySignature{{Name: "id", Type: kw(bindings.KeywordString)}}, + }) + ts.SetNode(t, "Drop", &bindings.Interface{ + Name: ident("Drop"), + Fields: []*bindings.PropertySignature{{Name: "id", Type: kw(bindings.KeywordString)}}, + }) + + out := zod.SerializeFilter(ts.TS, func(name string) bool { + return name == "Keep" + }) + + assert.Contains(t, out, "KeepSchema") + assert.NotContains(t, out, "DropSchema") +} + +// TestSerializeSingleMemberUnion verifies that a union with one +// non-null member simplifies to the member directly. +func TestSerializeSingleMemberUnion(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + ts.SetNode(t, "Thing", &bindings.Interface{ + Name: ident("Thing"), + Fields: []*bindings.PropertySignature{ + { + Name: "workspace_id", + Type: bindings.Union(kw(bindings.KeywordString)), + QuestionToken: true, + }, + }, + }) + + out := zod.Serialize(ts.TS) + + // Should be z.string().optional(), not z.union([z.string()]).optional(). + assert.Contains(t, out, `workspace_id: z.string().optional(),`) + assert.NotContains(t, out, "z.union") +} + +// TestSerializeObjectLiteral verifies inline object types. +func TestSerializeObjectLiteral(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + ts.SetNode(t, "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 := zod.Serialize(ts.TS) + + assert.Contains(t, out, `nested: z.object({`) + assert.Contains(t, out, `x: z.number(),`) + assert.Contains(t, out, `y: z.number(),`) +} + +// TestSerializeHeader verifies the generated header. +func TestSerializeHeader(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + out := zod.Serialize(ts.TS) + + assert.True(t, strings.HasPrefix(out, "// Code generated by 'guts'. DO NOT EDIT.\n")) + assert.Contains(t, out, `import { z } from "zod";`) +} + +// testTS wraps guts.Typescript for testing convenience. +type testTS struct { + TS *guts.Typescript +} + +func newTestTS(t *testing.T) *testTS { + t.Helper() + gen, err := guts.NewGolangParser() + require.NoError(t, err) + ts, err := gen.ToTypescript() + require.NoError(t, err) + return &testTS{TS: ts} +} + +func (tt *testTS) SetNode(t *testing.T, name string, node bindings.Node) { + t.Helper() + err := tt.TS.SetNode(name, node) + require.NoError(t, err) +} + +func ident(name string) bindings.Identifier { + return bindings.Identifier{Name: name} +} + +func kw(k bindings.LiteralKeyword) *bindings.LiteralKeyword { + return &k +}