1use proc_macro2::TokenStream;
8use quote::quote;
9use syn::{Data, DeriveInput, Fields};
10
11use crate::{
12 attributes::{ZodAttrs, apply_rename_rule, parse_serde_attrs, parse_zod_attrs},
13 errors::Result,
14 types::{OPTION, VEC, is_primitive, try_extract_wrapper},
15};
16
17pub fn derive_zod_ts(input: DeriveInput) -> Result<TokenStream> {
23 let name = &input.ident;
24 let name_str = name.to_string();
25
26 match &input.data {
27 Data::Struct(data) => match &data.fields {
28 Fields::Named(fields) => expand_named_struct(name, &name_str, fields, &input),
29 Fields::Unnamed(_) | Fields::Unit => Err(syn::Error::new_spanned(
30 name,
31 "ZodTs: only structs with named fields are supported",
32 )
33 .into()),
34 },
35 Data::Enum(data) => expand_enum(name, &name_str, data, &input),
36 Data::Union(_) => {
37 Err(syn::Error::new_spanned(name, "ZodTs cannot be derived for unions").into())
38 }
39 }
40}
41
42fn expand_named_struct(
47 name: &syn::Ident,
48 name_str: &str,
49 fields: &syn::FieldsNamed,
50 _input: &DeriveInput,
51) -> Result<TokenStream> {
52 let mut field_tokens: Vec<TokenStream> = Vec::new();
53 let mut dep_type_names: Vec<String> = Vec::new();
54
55 for field in &fields.named {
56 let field_name = field.ident.as_ref().unwrap().to_string();
57 let serde = parse_serde_attrs(&field.attrs)?;
58
59 if serde.skip {
60 continue;
61 }
62
63 let ts_key = serde.rename.as_deref().unwrap_or(&field_name);
64 let zod = parse_zod_attrs(&field.attrs)?;
65 let is_opt = is_option_type(&field.ty);
66
67 let base_ty = if is_opt {
68 option_inner(&field.ty).unwrap_or(&field.ty)
69 } else {
70 &field.ty
71 };
72
73 let custom = innermost_custom_name(base_ty);
75 if let Some(ref c) = custom {
76 let bare = c.rsplit("::").next().unwrap_or(c.as_str());
78 dep_type_names.push(bare.to_string());
79 }
80
81 let field_tok = if let Some(ref type_ref) = custom {
83 let bare_ref = type_ref.rsplit("::").next().unwrap_or(type_ref.as_str());
86 quote! {
87 ::rorpc::FieldDef {
88 ts_name: #ts_key,
89 zod_expr: "",
90 type_ref: #bare_ref,
91 optional: #is_opt,
92 }
93 }
94 } else {
95 let zod_expr = rust_type_to_zod(base_ty, &zod);
97 let zod_expr = if is_opt {
100 format!("{}.optional()", zod_expr)
101 } else {
102 zod_expr
103 };
104 quote! {
105 ::rorpc::FieldDef {
106 ts_name: #ts_key,
107 zod_expr: #zod_expr,
108 type_ref: "",
109 optional: #is_opt,
110 }
111 }
112 };
113
114 field_tokens.push(field_tok);
115 }
116
117 Ok(emit_registration(
118 name,
119 name_str,
120 field_tokens,
121 &dep_type_names,
122 ))
123}
124
125fn expand_enum(
130 name: &syn::Ident,
131 name_str: &str,
132 data: &syn::DataEnum,
133 input: &DeriveInput,
134) -> Result<TokenStream> {
135 let serde_container = parse_serde_attrs(&input.attrs)?;
136 let rename_all = serde_container.rename_all.as_deref();
137
138 let mut variant_tokens: Vec<TokenStream> = Vec::new();
139
140 for variant in &data.variants {
141 let serde_variant = parse_serde_attrs(&variant.attrs)?;
142 if serde_variant.skip {
143 continue;
144 }
145
146 let raw_name = variant.ident.to_string();
147 let variant_name = serde_variant
148 .rename
149 .as_deref()
150 .map(str::to_string)
151 .unwrap_or_else(|| {
152 rename_all
153 .map(|rule| apply_rename_rule(rule, &raw_name))
154 .unwrap_or(raw_name)
155 });
156
157 let kind_tok = generate_variant_def(&variant.fields)?;
158 variant_tokens.push(quote! {
159 ::rorpc::VariantDef {
160 serialized_name: #variant_name,
161 kind: #kind_tok,
162 }
163 });
164 }
165
166 Ok(emit_enum_registration(name, name_str, variant_tokens))
167}
168
169fn generate_variant_def(fields: &Fields) -> Result<TokenStream> {
174 match fields {
175 Fields::Unit => Ok(quote! { ::rorpc::VariantKind::Unit }),
176
177 Fields::Unnamed(fields_unnamed) => {
178 let count = fields_unnamed.unnamed.len();
179 if count == 1 {
180 let field = fields_unnamed.unnamed.first().unwrap();
181 let zod = parse_zod_attrs(&field.attrs)?;
182 if let Some(custom) = innermost_custom_name(&field.ty) {
183 let bare_ref = custom.rsplit("::").next().unwrap_or(custom.as_str());
184 Ok(quote! { ::rorpc::VariantKind::NewtypeRef { type_ref: #bare_ref } })
185 } else {
186 let schema = rust_type_to_zod(&field.ty, &zod);
187 Ok(quote! { ::rorpc::VariantKind::NewtypeZod { zod_expr: #schema } })
188 }
189 } else {
190 Ok(quote! { ::rorpc::VariantKind::Unit })
192 }
193 }
194
195 Fields::Named(fields_named) => {
196 let mut field_tokens: Vec<TokenStream> = Vec::new();
197 for field in &fields_named.named {
198 let field_name = field.ident.as_ref().unwrap().to_string();
199 let serde = parse_serde_attrs(&field.attrs)?;
200 if serde.skip {
201 continue;
202 }
203 let ts_key = serde.rename.as_deref().unwrap_or(&field_name);
204 let zod_attrs = parse_zod_attrs(&field.attrs)?;
205 let is_opt = is_option_type(&field.ty);
206 let base_ty = if is_opt {
207 option_inner(&field.ty).unwrap_or(&field.ty)
208 } else {
209 &field.ty
210 };
211
212 let field_tok = if let Some(custom) = innermost_custom_name(base_ty) {
213 let bare_ref = custom.rsplit("::").next().unwrap_or(custom.as_str());
214 quote! {
215 ::rorpc::FieldDef {
216 ts_name: #ts_key,
217 zod_expr: "",
218 type_ref: #bare_ref,
219 optional: #is_opt,
220 }
221 }
222 } else {
223 let zod_expr = rust_type_to_zod(base_ty, &zod_attrs);
224 let zod_expr = if is_opt {
225 format!("{}.optional()", zod_expr)
226 } else {
227 zod_expr
228 };
229 quote! {
230 ::rorpc::FieldDef {
231 ts_name: #ts_key,
232 zod_expr: #zod_expr,
233 type_ref: "",
234 optional: #is_opt,
235 }
236 }
237 };
238 field_tokens.push(field_tok);
239 }
240 Ok(quote! {
241 ::rorpc::VariantKind::Struct {
242 fields: &[ #(#field_tokens),* ]
243 }
244 })
245 }
246 }
247}
248
249fn emit_registration(
258 name: &syn::Ident,
259 name_str: &str,
260 items: Vec<TokenStream>,
261 dep_type_names: &[String],
262) -> TokenStream {
263 let dep_strs: Vec<&str> = dep_type_names.iter().map(String::as_str).collect();
264
265 quote! {
266 impl #name {
267 pub fn dependent_types() -> Vec<&'static str> {
268 vec![#(#dep_strs),*]
269 }
270 }
271
272 const _: () = {
273 static __FIELDS: &[::rorpc::FieldDef] = &[ #(#items),* ];
274 ::rorpc::inventory::submit! {
275 ::rorpc::SchemaRegistration {
276 type_name: #name_str,
277 module_path: concat!(module_path!(), "::", #name_str),
278 schema_def: ::rorpc::SchemaDef::Object { fields: __FIELDS },
279 dependent_types: #name::dependent_types,
280 }
281 }
282 };
283 }
284}
285
286fn emit_enum_registration(
288 name: &syn::Ident,
289 name_str: &str,
290 items: Vec<TokenStream>,
291) -> TokenStream {
292 quote! {
293 impl #name {
294 pub fn dependent_types() -> Vec<&'static str> {
295 vec![]
296 }
297 }
298
299 const _: () = {
300 static __VARIANTS: &[::rorpc::VariantDef] = &[ #(#items),* ];
301 ::rorpc::inventory::submit! {
302 ::rorpc::SchemaRegistration {
303 type_name: #name_str,
304 module_path: concat!(module_path!(), "::", #name_str),
305 schema_def: ::rorpc::SchemaDef::Enum { variants: __VARIANTS },
306 dependent_types: #name::dependent_types,
307 }
308 }
309 };
310 }
311}
312
313pub fn rust_type_to_zod(ty: &syn::Type, attrs: &ZodAttrs) -> String {
322 if is_option_type(ty)
324 && let Some(inner) = option_inner(ty)
325 {
326 let inner_schema = rust_type_to_zod(inner, &ZodAttrs::default());
327 return format!("{}.optional()", inner_schema);
328 }
329
330 if let Some(m) = try_extract_wrapper(ty, VEC)
332 && let Some(inner) = m.first_type()
333 {
334 let inner_schema = rust_type_to_zod(inner, &ZodAttrs::default());
335 let mut chain = format!("z.array({})", inner_schema);
336 if let Some(n) = attrs.length {
337 chain.push_str(&format!(".length({})", n));
338 }
339 if let Some(n) = attrs.min_length {
340 chain.push_str(&format!(".min({})", n));
341 }
342 if let Some(n) = attrs.max_length {
343 chain.push_str(&format!(".max({})", n));
344 }
345 return chain;
346 }
347
348 if let syn::Type::Path(type_path) = ty
350 && let Some(seg) = type_path.path.segments.last()
351 {
352 let name = seg.ident.to_string();
353 return match name.as_str() {
354 "String" | "str" => build_string_schema(attrs),
355 "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64"
356 | "u128" | "usize" => build_integer_schema(attrs),
357 "f32" | "f64" => build_float_schema(attrs),
358 "bool" => "z.boolean()".to_string(),
359 "Uuid" => "z.uuid()".to_string(),
361 "DateTime" => "z.iso.datetime({ offset: true })".to_string(),
363 "Value" => "z.record(z.string(), z.unknown())".to_string(),
365 other => format!("{}Schema", other),
367 };
368 }
369
370 if let syn::Type::Tuple(t) = ty
372 && t.elems.is_empty()
373 {
374 return "z.void()".to_string();
375 }
376
377 "z.unknown()".to_string()
378}
379
380fn build_string_schema(attrs: &ZodAttrs) -> String {
385 let mut chain = String::from("z.string()");
386 if let Some(n) = attrs.length {
387 chain.push_str(&format!(".length({})", n));
388 }
389 if let Some(n) = attrs.min_length {
390 chain.push_str(&format!(".min({})", n));
391 }
392 if let Some(n) = attrs.max_length {
393 chain.push_str(&format!(".max({})", n));
394 }
395 if attrs.email {
396 chain.push_str(".email()");
397 }
398 if attrs.url {
399 chain.push_str(".url()");
400 }
401 if let Some(ref p) = attrs.regex {
402 chain.push_str(&format!(".regex(/{}/)", p));
403 }
404 if let Some(ref p) = attrs.starts_with {
405 chain.push_str(&format!(".startsWith(\"{}\")", p));
406 }
407 if let Some(ref p) = attrs.ends_with {
408 chain.push_str(&format!(".endsWith(\"{}\")", p));
409 }
410 if let Some(ref p) = attrs.includes {
411 chain.push_str(&format!(".includes(\"{}\")", p));
412 }
413 chain
414}
415
416fn build_integer_schema(attrs: &ZodAttrs) -> String {
417 let mut chain = String::from("z.number().int()");
418 append_number_validators(&mut chain, attrs);
419 chain
420}
421
422fn build_float_schema(attrs: &ZodAttrs) -> String {
423 let mut chain = String::from("z.number()");
424 if attrs.int {
425 chain.push_str(".int()");
426 }
427 append_number_validators(&mut chain, attrs);
428 chain
429}
430
431fn append_number_validators(chain: &mut String, attrs: &ZodAttrs) {
432 if let Some(n) = attrs.min {
433 chain.push_str(&format!(".min({})", n));
434 }
435 if let Some(n) = attrs.max {
436 chain.push_str(&format!(".max({})", n));
437 }
438 if attrs.positive {
439 chain.push_str(".positive()");
440 }
441 if attrs.negative {
442 chain.push_str(".negative()");
443 }
444 if attrs.nonnegative {
445 chain.push_str(".nonnegative()");
446 }
447 if attrs.nonpositive {
448 chain.push_str(".nonpositive()");
449 }
450 if attrs.finite {
451 chain.push_str(".finite()");
452 }
453}
454
455fn is_option_type(ty: &syn::Type) -> bool {
460 try_extract_wrapper(ty, OPTION).is_some()
461}
462
463fn option_inner(ty: &syn::Type) -> Option<&syn::Type> {
464 try_extract_wrapper(ty, OPTION)?.first_type()
465}
466
467fn innermost_custom_name(ty: &syn::Type) -> Option<String> {
470 if let Some(m) = try_extract_wrapper(ty, VEC) {
472 return m.first_type().and_then(innermost_custom_name);
473 }
474 if is_primitive(ty) {
475 return None;
476 }
477 if let syn::Type::Path(tp) = ty {
478 let segments: Vec<String> = tp
480 .path
481 .segments
482 .iter()
483 .map(|seg| seg.ident.to_string())
484 .collect();
485
486 if segments.is_empty() {
487 return None;
488 }
489
490 let last = segments.last().unwrap();
491 if last == "Value" {
493 return None;
494 }
495
496 return Some(segments.join("::"));
498 }
499 None
500}
501
502pub fn rust_type_to_ts_schema(raw: &str) -> String {
535 let raw = raw.replace(' ', "");
536
537 if raw.starts_with("Sse<") {
538 return "asyncIteratorObject(z.unknown() /* TODO: add #[derive(ZodTs)] to your stream event type */)".to_string();
539 }
540
541 let inner = if raw.starts_with("Result<") {
543 extract_first_generic_arg_string(&raw).unwrap_or(raw.clone())
544 } else {
545 raw.clone()
546 };
547
548 let inner = if inner.starts_with("Json<") && inner.ends_with('>') {
550 inner[5..inner.len() - 1].to_string()
551 } else {
552 inner
553 };
554
555 type_name_to_zod_ref(&inner)
556}
557
558fn type_name_to_zod_ref(type_name: &str) -> String {
560 match type_name {
561 "()" => "z.void()".to_string(),
562 "" => String::new(),
563 "String" | "str" => "z.string()".to_string(),
564 "bool" => "z.boolean()".to_string(),
565 "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128"
566 | "usize" => "z.number().int()".to_string(),
567 "f32" | "f64" => "z.number()".to_string(),
568 "Uuid" => "z.uuid()".to_string(),
569 "DateTime" => "z.iso.datetime({ offset: true })".to_string(),
570 "serde_json::Value" | "Value" => "z.record(z.string(), z.unknown())".to_string(),
571 _ if type_name.starts_with("Vec<") && type_name.ends_with('>') => {
572 let inner = &type_name[4..type_name.len() - 1];
573 format!("z.array({})", type_name_to_zod_ref(inner))
574 }
575 _ if type_name.starts_with("Option<") && type_name.ends_with('>') => {
576 let inner = &type_name[7..type_name.len() - 1];
577 format!("{}.optional()", type_name_to_zod_ref(inner))
578 }
579 _ => {
580 let base = type_name.rsplit("::").next().unwrap_or(type_name);
581 format!("{}Schema", base)
582 }
583 }
584}
585
586fn extract_first_generic_arg_string(type_str: &str) -> Option<String> {
590 let start = type_str.find('<')? + 1;
591 let mut depth = 0;
592 let mut end = start;
593
594 for (i, ch) in type_str[start..].char_indices() {
595 match ch {
596 '<' => depth += 1,
597 '>' if depth == 0 => {
598 end = start + i;
599 break;
600 }
601 '>' => depth -= 1,
602 ',' if depth == 0 => {
603 end = start + i;
604 break;
605 }
606 _ => {}
607 }
608 }
609
610 if end > start {
611 Some(type_str[start..end].to_string())
612 } else {
613 None
614 }
615}
616
617pub fn to_schema_name(rust_type: &str) -> String {
619 format!("{}Schema", base_type_name(rust_type))
620}
621
622pub fn base_type_name(rust_type: &str) -> String {
626 let mut base = rust_type.trim();
627
628 if base.starts_with("Result<")
629 && let Some(inner) = extract_first_generic_arg_string(base)
630 {
631 base = Box::leak(inner.into_boxed_str());
632 }
633 if base.starts_with("Json<") && base.ends_with('>') {
634 base = &base[5..base.len() - 1];
635 }
636 if base.starts_with("Vec<") && base.ends_with('>') {
637 base = &base[4..base.len() - 1];
638 }
639 if base.starts_with("Option<") && base.ends_with('>') {
640 base = &base[7..base.len() - 1];
641 }
642
643 base.rsplit("::").next().unwrap_or(base).to_string()
644}
645
646#[cfg(test)]
647mod runtime_conversion_tests {
648 use super::*;
649
650 #[test]
651 fn json_planet() {
652 assert_eq!(rust_type_to_ts_schema("Json<Planet>"), "PlanetSchema");
653 }
654
655 #[test]
656 fn json_vec_planet() {
657 assert_eq!(
658 rust_type_to_ts_schema("Json<Vec<Planet>>"),
659 "z.array(PlanetSchema)"
660 );
661 }
662
663 #[test]
664 fn result_json_planet() {
665 assert_eq!(
666 rust_type_to_ts_schema("Result<Json<Planet>, StatusCode>"),
667 "PlanetSchema"
668 );
669 }
670
671 #[test]
672 fn json_string() {
673 assert_eq!(rust_type_to_ts_schema("Json<String>"), "z.string()");
674 }
675
676 #[test]
677 fn unit_type() {
678 assert_eq!(rust_type_to_ts_schema("()"), "z.void()");
679 }
680
681 #[test]
682 fn serde_json_value() {
683 assert_eq!(
684 rust_type_to_ts_schema("Json<serde_json::Value>"),
685 "z.record(z.string(), z.unknown())"
686 );
687 }
688
689 #[test]
690 fn schema_name_simple() {
691 assert_eq!(to_schema_name("Planet"), "PlanetSchema");
692 }
693
694 #[test]
695 fn schema_name_vec() {
696 assert_eq!(to_schema_name("Vec<Planet>"), "PlanetSchema");
697 }
698
699 #[test]
700 fn base_type_unwraps_wrappers() {
701 assert_eq!(base_type_name("Result<Json<Vec<Planet>>, E>"), "Planet");
702 assert_eq!(base_type_name("Json<Planet>"), "Planet");
703 assert_eq!(base_type_name("Vec<Planet>"), "Planet");
704 assert_eq!(base_type_name("Option<Planet>"), "Planet");
705 }
706
707 #[test]
708 fn base_type_strips_module_path() {
709 assert_eq!(base_type_name("models::Planet"), "Planet");
710 assert_eq!(base_type_name("crate::domain::Planet"), "Planet");
711 }
712}