use crate::Emitter;
use crate::ir;
use super::case::to_pascal_case;
#[derive(Debug, Default, Clone, Copy)]
pub struct ZodEmitter;
impl ZodEmitter {
pub fn new() -> Self {
Self
}
}
impl Emitter for ZodEmitter {
fn emit(&self, schema: &ir::Schema) -> String {
let mut code = String::new();
code.push_str(HEADER);
code.push('\n');
for ty in &schema.types {
if let ir::TypeDef::Enum {
name,
description,
variants,
} = ty
{
code.push_str(&render_enum(name, description.as_deref(), variants));
code.push_str("\n\n");
}
}
for ty in &schema.types {
if let ir::TypeDef::Struct {
name,
description,
fields,
} = ty
{
code.push_str(&render_object(name, description.as_deref(), fields));
code.push_str("\n\n");
}
}
for record in &schema.records {
code.push_str(&render_object(
&record.name,
record.description.as_deref(),
&record_members(record),
));
code.push_str("\n\n");
}
for relation in &schema.relations {
code.push_str(&render_object(
&relation.name,
relation.description.as_deref(),
&relation_members(relation),
));
code.push_str("\n\n");
}
if let Some(protocol) = &schema.protocol {
for channel in &protocol.channels {
for req in &channel.requests {
code.push_str(&render_object(&req.name, None, &req.fields));
code.push_str("\n\n");
if let Some(returns) = &req.returns {
code.push_str(&render_object(&returns.name, None, &returns.fields));
code.push_str("\n\n");
}
}
for evt in &channel.events {
code.push_str(&render_object(&evt.name, None, &evt.fields));
code.push_str("\n\n");
}
if let Some(tag) = &channel.envelope
&& !channel.requests.is_empty()
{
code.push_str(&render_envelope(channel, tag));
code.push_str("\n\n");
}
}
}
code
}
}
const HEADER: &str = "\
// Auto-generated Zod schemas
// DO NOT EDIT MANUALLY
import { z } from \"zod\";
";
fn js_string(text: &str) -> String {
let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
fn describe_suffix(description: Option<&str>) -> String {
match description {
Some(text) => format!(".describe({})", js_string(text)),
None => String::new(),
}
}
fn render_enum(name: &str, description: Option<&str>, variants: &[String]) -> String {
let vs: Vec<String> = variants.iter().map(|v| format!("\"{v}\"")).collect();
format!(
"export const {} = z.enum([{}]){};",
to_pascal_case(name),
vs.join(", "),
describe_suffix(description),
)
}
fn render_object(name: &str, description: Option<&str>, fields: &[ir::Field]) -> String {
let pascal = to_pascal_case(name);
let describe = describe_suffix(description);
if fields.is_empty() {
return format!("export const {pascal} = z.object({{}}){describe};");
}
let body: Vec<String> = fields.iter().map(render_field).collect();
format!(
"export const {pascal} = z.object({{\n{}\n}}){describe};",
body.join("\n")
)
}
fn render_envelope(channel: &ir::Channel, tag: &str) -> String {
let name = format!("{}Envelope", to_pascal_case(&channel.name));
let mut out = format!(
"export const {name} = z.discriminatedUnion({}, [\n",
js_string(tag)
);
for req in &channel.requests {
out.push_str(&format!(
" {}.extend({{ {tag}: z.literal({}) }}),\n",
to_pascal_case(&req.name),
js_string(&req.name),
));
}
out.push_str("]);");
out
}
fn constraint_suffix(c: &ir::Constraints) -> String {
let mut out = String::new();
if let Some(min) = c.min.or(c.min_length.map(|n| n as i64)) {
out.push_str(&format!(".min({min})"));
}
if let Some(max) = c.max.or(c.max_length.map(|n| n as i64)) {
out.push_str(&format!(".max({max})"));
}
if let Some(pattern) = &c.pattern {
out.push_str(&format!(".regex(/{pattern}/)"));
}
out
}
fn render_field(field: &ir::Field) -> String {
let mut schema = ty_to_zod(&field.ty);
schema.push_str(&constraint_suffix(&field.constraints));
if let Some(desc) = &field.description {
schema.push_str(&format!(".describe({})", js_string(desc)));
}
if !field.required {
schema.push_str(".optional()");
}
format!(" {}: {},", field.name, schema)
}
fn id_member() -> ir::Field {
ir::Field {
name: "id".to_string(),
ty: ir::Ty::Primitive(ir::Prim::String),
required: true,
flexible: false,
default: None,
description: None,
constraints: ir::Constraints::default(),
}
}
fn record_members(record: &ir::Record) -> Vec<ir::Field> {
let mut members = Vec::with_capacity(record.fields.len() + 1);
members.push(id_member());
members.extend(record.fields.iter().cloned());
members
}
fn relation_members(relation: &ir::Relation) -> Vec<ir::Field> {
let endpoint = |name: &str| ir::Field {
name: name.to_string(),
ty: ir::Ty::Primitive(ir::Prim::String),
required: true,
flexible: false,
default: None,
description: None,
constraints: ir::Constraints::default(),
};
let mut members = Vec::with_capacity(relation.fields.len() + 3);
members.push(id_member());
members.push(endpoint("in"));
members.push(endpoint("out"));
members.extend(relation.fields.iter().cloned());
members
}
fn ty_to_zod(ty: &ir::Ty) -> String {
match ty {
ir::Ty::Primitive(p) => prim_to_zod(*p).to_string(),
ir::Ty::Array(inner) => format!("z.array({})", ty_to_zod(inner)),
ir::Ty::Named(name) => to_pascal_case(name),
ir::Ty::Link(_) => "z.string()".to_string(),
ir::Ty::Literal(value) => format!("z.literal(\"{value}\")"),
ir::Ty::Union(members) => {
if let Some(values) = literal_union_values(members) {
let vs: Vec<String> = values.iter().map(|v| format!("\"{v}\"")).collect();
format!("z.enum([{}])", vs.join(", "))
} else {
let mut parts: Vec<String> = Vec::new();
for m in members {
let p = ty_to_zod(m);
if !parts.contains(&p) {
parts.push(p);
}
}
if parts.len() == 1 {
parts.into_iter().next().unwrap()
} else {
format!("z.union([{}])", parts.join(", "))
}
}
}
}
}
fn literal_union_values(members: &[ir::Ty]) -> Option<Vec<String>> {
members
.iter()
.map(|m| match m {
ir::Ty::Literal(v) => Some(v.clone()),
_ => None,
})
.collect()
}
fn prim_to_zod(p: ir::Prim) -> &'static str {
match p {
ir::Prim::String => "z.string()",
ir::Prim::Int => "z.number().int()",
ir::Prim::Float => "z.number()",
ir::Prim::Bool => "z.boolean()",
ir::Prim::Datetime => "z.string().datetime()",
ir::Prim::Json => "z.unknown()",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn field(name: &str, ty: ir::Ty, required: bool) -> ir::Field {
ir::Field {
name: name.to_string(),
ty,
required,
flexible: false,
default: None,
description: None,
constraints: ir::Constraints::default(),
}
}
#[test]
fn emits_header() {
let out = ZodEmitter::new().emit(&ir::Schema::default());
assert!(out.contains("import { z } from \"zod\";"));
assert!(out.contains("// DO NOT EDIT MANUALLY"));
}
#[test]
fn emits_enum_as_z_enum() {
let schema = ir::Schema {
types: vec![ir::TypeDef::Enum {
name: "Role".to_string(),
description: None,
variants: vec!["admin".to_string(), "member".to_string()],
}],
protocol: None,
..Default::default()
};
let out = ZodEmitter::new().emit(&schema);
assert!(out.contains("export const Role = z.enum([\"admin\", \"member\"]);"));
}
#[test]
fn emits_object_with_optional_field() {
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "User".to_string(),
description: None,
fields: vec![
field("name", ir::Ty::Primitive(ir::Prim::String), true),
field("nick", ir::Ty::Primitive(ir::Prim::String), false),
],
}],
protocol: None,
..Default::default()
};
let out = ZodEmitter::new().emit(&schema);
assert!(out.contains("export const User = z.object({"));
assert!(out.contains(" name: z.string(),"));
assert!(out.contains(" nick: z.string().optional(),"));
}
#[test]
fn maps_primitive_and_compound_types() {
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "T".to_string(),
description: None,
fields: vec![
field("n", ir::Ty::Primitive(ir::Prim::Int), true),
field("f", ir::Ty::Primitive(ir::Prim::Float), true),
field("b", ir::Ty::Primitive(ir::Prim::Bool), true),
field("at", ir::Ty::Primitive(ir::Prim::Datetime), true),
field("blob", ir::Ty::Primitive(ir::Prim::Json), true),
field(
"tags",
ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String))),
true,
),
],
}],
protocol: None,
..Default::default()
};
let out = ZodEmitter::new().emit(&schema);
assert!(out.contains(" n: z.number().int(),"));
assert!(out.contains(" f: z.number(),"));
assert!(out.contains(" b: z.boolean(),"));
assert!(out.contains(" at: z.string().datetime(),"));
assert!(out.contains(" blob: z.unknown(),"));
assert!(out.contains(" tags: z.array(z.string()),"));
}
#[test]
fn enum_is_emitted_before_referencing_struct() {
let schema = ir::Schema {
types: vec![
ir::TypeDef::Struct {
name: "User".to_string(),
description: None,
fields: vec![field("role", ir::Ty::Named("Role".to_string()), true)],
},
ir::TypeDef::Enum {
name: "Role".to_string(),
description: None,
variants: vec!["admin".to_string()],
},
],
protocol: None,
..Default::default()
};
let out = ZodEmitter::new().emit(&schema);
let enum_pos = out.find("export const Role").expect("enum emitted");
let struct_pos = out.find("export const User").expect("struct emitted");
assert!(
enum_pos < struct_pos,
"enum must precede the struct using it"
);
assert!(out.contains(" role: Role,"));
}
#[test]
fn emits_protocol_payload_schemas() {
let schema = ir::Schema {
types: vec![],
records: vec![],
relations: vec![],
protocol: Some(ir::Protocol {
name: "chat".to_string(),
version: "1.0.0".to_string(),
namespace: None,
description: None,
channels: vec![ir::Channel {
name: "messaging".to_string(),
from: ir::ChannelFrom::Client,
lifetime: ir::ChannelLifetime::Persistent,
backend: ir::ChannelBackend::Stream,
channel_id: None,
envelope: None,
requests: vec![ir::Request {
name: "Send".to_string(),
fields: vec![field("body", ir::Ty::Primitive(ir::Prim::String), true)],
returns: Some(ir::Message {
name: "Ack".to_string(),
fields: vec![field("id", ir::Ty::Primitive(ir::Prim::String), true)],
}),
}],
events: vec![],
}],
}),
};
let out = ZodEmitter::new().emit(&schema);
assert!(out.contains("export const Send = z.object({"));
assert!(out.contains("export const Ack = z.object({"));
}
fn sidebar_schema(envelope: Option<&str>) -> ir::Schema {
ir::Schema {
protocol: Some(ir::Protocol {
name: "sidebar".to_string(),
version: "1.0.0".to_string(),
namespace: None,
description: None,
channels: vec![ir::Channel {
name: "ipc".to_string(),
from: ir::ChannelFrom::Client,
lifetime: ir::ChannelLifetime::Transient,
backend: ir::ChannelBackend::Stream,
channel_id: None,
envelope: envelope.map(str::to_string),
requests: vec![
ir::Request {
name: "process:toggle".to_string(),
fields: vec![field("path", ir::Ty::Primitive(ir::Prim::String), true)],
returns: None,
},
ir::Request {
name: "process:add".to_string(),
fields: vec![],
returns: None,
},
],
events: vec![],
}],
}),
..Default::default()
}
}
#[test]
fn channel_request_schema_names_are_sanitized() {
let out = ZodEmitter::new().emit(&sidebar_schema(None));
assert!(out.contains("export const ProcessToggle = z.object({"));
assert!(out.contains("export const ProcessAdd = z.object({})"));
}
#[test]
fn channel_without_envelope_emits_no_discriminated_union() {
let out = ZodEmitter::new().emit(&sidebar_schema(None));
assert!(
!out.contains("z.discriminatedUnion"),
"no envelope ⇒ no union"
);
}
#[test]
fn envelope_channel_emits_discriminated_union() {
let out = ZodEmitter::new().emit(&sidebar_schema(Some("t")));
assert!(out.contains("export const IpcEnvelope = z.discriminatedUnion(\"t\", ["));
assert!(out.contains(" ProcessToggle.extend({ t: z.literal(\"process:toggle\") }),"));
assert!(out.contains(" ProcessAdd.extend({ t: z.literal(\"process:add\") }),"));
let obj = out.find("export const ProcessToggle").unwrap();
let union = out.find("export const IpcEnvelope").unwrap();
assert!(
obj < union,
"request objects precede the discriminated union"
);
}
#[test]
fn record_becomes_object_with_id() {
let schema = ir::Schema {
records: vec![ir::Record {
name: "Atlas".to_string(),
description: None,
id_strategy: ir::IdStrategy::Uuidv7,
fields: vec![field("name", ir::Ty::Primitive(ir::Prim::String), true)],
}],
..Default::default()
};
let out = ZodEmitter::new().emit(&schema);
assert!(out.contains("export const Atlas = z.object({"));
assert!(out.contains(" id: z.string(),"));
assert!(out.contains(" name: z.string(),"));
}
#[test]
fn relation_object_is_pascal_cased_with_in_out() {
let schema = ir::Schema {
relations: vec![ir::Relation {
name: "derivedFrom".to_string(),
description: None,
from: "Memory".to_string(),
to: "Memory".to_string(),
unique: false,
fields: vec![field("reason", ir::Ty::Primitive(ir::Prim::String), false)],
}],
..Default::default()
};
let out = ZodEmitter::new().emit(&schema);
assert!(out.contains("export const DerivedFrom = z.object({"));
assert!(out.contains(" id: z.string(),"));
assert!(out.contains(" in: z.string(),"));
assert!(out.contains(" out: z.string(),"));
assert!(out.contains(" reason: z.string().optional(),"));
}
#[test]
fn link_becomes_z_string() {
let schema = ir::Schema {
records: vec![ir::Record {
name: "Atlas".to_string(),
description: None,
id_strategy: ir::IdStrategy::Uuidv7,
fields: vec![field("parent", ir::Ty::Link("Atlas".to_string()), true)],
}],
..Default::default()
};
let out = ZodEmitter::new().emit(&schema);
assert!(out.contains(" parent: z.string(),"));
}
#[test]
fn literal_union_collapses_to_z_enum() {
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "T".to_string(),
description: None,
fields: vec![field(
"visibility",
ir::Ty::Union(vec![
ir::Ty::Literal("public".to_string()),
ir::Ty::Literal("private".to_string()),
]),
true,
)],
}],
..Default::default()
};
let out = ZodEmitter::new().emit(&schema);
assert!(out.contains(" visibility: z.enum([\"public\", \"private\"]),"));
}
#[test]
fn mixed_union_becomes_z_union() {
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "T".to_string(),
description: None,
fields: vec![field(
"v",
ir::Ty::Union(vec![
ir::Ty::Primitive(ir::Prim::String),
ir::Ty::Primitive(ir::Prim::Int),
]),
true,
)],
}],
..Default::default()
};
let out = ZodEmitter::new().emit(&schema);
assert!(out.contains(" v: z.union([z.string(), z.number().int()]),"));
}
#[test]
fn object_and_field_descriptions_become_describe_calls() {
let mut content = field("content", ir::Ty::Primitive(ir::Prim::String), true);
content.description = Some("Memory content text".to_string());
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "Memory".to_string(),
description: Some("User memory".to_string()),
fields: vec![content],
}],
..Default::default()
};
let out = ZodEmitter::new().emit(&schema);
assert!(
out.contains("}).describe(\"User memory\");"),
"object .describe()"
);
assert!(
out.contains("z.string().describe(\"Memory content text\")"),
"field .describe()"
);
}
#[test]
fn enum_description_becomes_describe_call() {
let schema = ir::Schema {
types: vec![ir::TypeDef::Enum {
name: "Role".to_string(),
description: Some("An access role".to_string()),
variants: vec!["admin".to_string()],
}],
..Default::default()
};
let out = ZodEmitter::new().emit(&schema);
assert!(out.contains("z.enum([\"admin\"]).describe(\"An access role\");"));
}
#[test]
fn numeric_constraints_become_min_max() {
let mut f = field("confidence", ir::Ty::Primitive(ir::Prim::Float), true);
f.constraints = ir::Constraints {
min: Some(0),
max: Some(1),
..Default::default()
};
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "T".to_string(),
description: None,
fields: vec![f],
}],
..Default::default()
};
let out = ZodEmitter::new().emit(&schema);
assert!(out.contains(" confidence: z.number().min(0).max(1),"));
}
#[test]
fn string_length_and_pattern_constraints_are_emitted() {
let mut f = field("name", ir::Ty::Primitive(ir::Prim::String), true);
f.constraints = ir::Constraints {
min_length: Some(1),
max_length: Some(32),
pattern: Some("^[a-z]+$".to_string()),
..Default::default()
};
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "T".to_string(),
description: None,
fields: vec![f],
}],
..Default::default()
};
let out = ZodEmitter::new().emit(&schema);
assert!(
out.contains(" name: z.string().min(1).max(32).regex(/^[a-z]+$/),"),
"got: {out}"
);
}
#[test]
fn constraint_and_describe_precede_optional_wrapper() {
let mut f = field("nick", ir::Ty::Primitive(ir::Prim::String), false);
f.constraints = ir::Constraints {
max_length: Some(8),
..Default::default()
};
f.description = Some("nickname".to_string());
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "T".to_string(),
description: None,
fields: vec![f],
}],
..Default::default()
};
let out = ZodEmitter::new().emit(&schema);
assert!(
out.contains("z.string().max(8).describe(\"nickname\").optional()"),
".optional() must come last; got: {out}"
);
}
}