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::{HASHMAP, 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(container_expr) = try_generate_container_with_custom_types(base_ty) {
83 let zod_expr = if is_opt {
86 format!("{}.optional()", container_expr)
87 } else {
88 container_expr
89 };
90 quote! {
91 ::rorpc::FieldDef {
92 ts_name: #ts_key,
93 zod_expr: #zod_expr,
94 type_ref: "",
95 optional: #is_opt,
96 }
97 }
98 } else if let Some(ref type_ref) = custom {
99 let bare_ref = type_ref.rsplit("::").next().unwrap_or(type_ref.as_str());
101 quote! {
102 ::rorpc::FieldDef {
103 ts_name: #ts_key,
104 zod_expr: "",
105 type_ref: #bare_ref,
106 optional: #is_opt,
107 }
108 }
109 } else {
110 let zod_expr = rust_type_to_zod(base_ty, &zod);
112 let zod_expr = if is_opt {
113 format!("{}.optional()", zod_expr)
114 } else {
115 zod_expr
116 };
117 quote! {
118 ::rorpc::FieldDef {
119 ts_name: #ts_key,
120 zod_expr: #zod_expr,
121 type_ref: "",
122 optional: #is_opt,
123 }
124 }
125 };
126
127 field_tokens.push(field_tok);
128 }
129
130 Ok(emit_registration(
131 name,
132 name_str,
133 field_tokens,
134 &dep_type_names,
135 ))
136}
137
138fn expand_enum(
143 name: &syn::Ident,
144 name_str: &str,
145 data: &syn::DataEnum,
146 input: &DeriveInput,
147) -> Result<TokenStream> {
148 let serde_container = parse_serde_attrs(&input.attrs)?;
149 let rename_all = serde_container.rename_all.as_deref();
150
151 let mut variant_tokens: Vec<TokenStream> = Vec::new();
152
153 for variant in &data.variants {
154 let serde_variant = parse_serde_attrs(&variant.attrs)?;
155 if serde_variant.skip {
156 continue;
157 }
158
159 let raw_name = variant.ident.to_string();
160 let variant_name = serde_variant
161 .rename
162 .as_deref()
163 .map(str::to_string)
164 .unwrap_or_else(|| {
165 rename_all
166 .map(|rule| apply_rename_rule(rule, &raw_name))
167 .unwrap_or(raw_name)
168 });
169
170 let kind_tok = generate_variant_def(&variant.fields)?;
171 variant_tokens.push(quote! {
172 ::rorpc::VariantDef {
173 serialized_name: #variant_name,
174 kind: #kind_tok,
175 }
176 });
177 }
178
179 Ok(emit_enum_registration(name, name_str, variant_tokens))
180}
181
182fn generate_variant_def(fields: &Fields) -> Result<TokenStream> {
187 match fields {
188 Fields::Unit => Ok(quote! { ::rorpc::VariantKind::Unit }),
189
190 Fields::Unnamed(fields_unnamed) => {
191 let count = fields_unnamed.unnamed.len();
192 if count == 1 {
193 let field = fields_unnamed.unnamed.first().unwrap();
194 let zod = parse_zod_attrs(&field.attrs)?;
195 if let Some(custom) = innermost_custom_name(&field.ty) {
196 let bare_ref = custom.rsplit("::").next().unwrap_or(custom.as_str());
197 Ok(quote! { ::rorpc::VariantKind::NewtypeRef { type_ref: #bare_ref } })
198 } else {
199 let schema = rust_type_to_zod(&field.ty, &zod);
200 Ok(quote! { ::rorpc::VariantKind::NewtypeZod { zod_expr: #schema } })
201 }
202 } else {
203 Ok(quote! { ::rorpc::VariantKind::Unit })
205 }
206 }
207
208 Fields::Named(fields_named) => {
209 let mut field_tokens: Vec<TokenStream> = Vec::new();
210 for field in &fields_named.named {
211 let field_name = field.ident.as_ref().unwrap().to_string();
212 let serde = parse_serde_attrs(&field.attrs)?;
213 if serde.skip {
214 continue;
215 }
216 let ts_key = serde.rename.as_deref().unwrap_or(&field_name);
217 let zod_attrs = parse_zod_attrs(&field.attrs)?;
218 let is_opt = is_option_type(&field.ty);
219 let base_ty = if is_opt {
220 option_inner(&field.ty).unwrap_or(&field.ty)
221 } else {
222 &field.ty
223 };
224
225 let field_tok = if let Some(custom) = innermost_custom_name(base_ty) {
226 let bare_ref = custom.rsplit("::").next().unwrap_or(custom.as_str());
227 quote! {
228 ::rorpc::FieldDef {
229 ts_name: #ts_key,
230 zod_expr: "",
231 type_ref: #bare_ref,
232 optional: #is_opt,
233 }
234 }
235 } else {
236 let zod_expr = rust_type_to_zod(base_ty, &zod_attrs);
237 let zod_expr = if is_opt {
238 format!("{}.optional()", zod_expr)
239 } else {
240 zod_expr
241 };
242 quote! {
243 ::rorpc::FieldDef {
244 ts_name: #ts_key,
245 zod_expr: #zod_expr,
246 type_ref: "",
247 optional: #is_opt,
248 }
249 }
250 };
251 field_tokens.push(field_tok);
252 }
253 Ok(quote! {
254 ::rorpc::VariantKind::Struct {
255 fields: &[ #(#field_tokens),* ]
256 }
257 })
258 }
259 }
260}
261
262fn emit_registration(
271 name: &syn::Ident,
272 name_str: &str,
273 items: Vec<TokenStream>,
274 dep_type_names: &[String],
275) -> TokenStream {
276 let dep_strs: Vec<&str> = dep_type_names.iter().map(String::as_str).collect();
277
278 quote! {
279 impl #name {
280 pub fn dependent_types() -> Vec<&'static str> {
281 vec![#(#dep_strs),*]
282 }
283 }
284
285 const _: () = {
286 static __FIELDS: &[::rorpc::FieldDef] = &[ #(#items),* ];
287 ::rorpc::inventory::submit! {
288 ::rorpc::SchemaRegistration {
289 type_name: #name_str,
290 module_path: concat!(module_path!(), "::", #name_str),
291 schema_def: ::rorpc::SchemaDef::Object { fields: __FIELDS },
292 dependent_types: #name::dependent_types,
293 }
294 }
295 };
296 }
297}
298
299fn emit_enum_registration(
301 name: &syn::Ident,
302 name_str: &str,
303 items: Vec<TokenStream>,
304) -> TokenStream {
305 quote! {
306 impl #name {
307 pub fn dependent_types() -> Vec<&'static str> {
308 vec![]
309 }
310 }
311
312 const _: () = {
313 static __VARIANTS: &[::rorpc::VariantDef] = &[ #(#items),* ];
314 ::rorpc::inventory::submit! {
315 ::rorpc::SchemaRegistration {
316 type_name: #name_str,
317 module_path: concat!(module_path!(), "::", #name_str),
318 schema_def: ::rorpc::SchemaDef::Enum { variants: __VARIANTS },
319 dependent_types: #name::dependent_types,
320 }
321 }
322 };
323 }
324}
325
326pub fn rust_type_to_zod(ty: &syn::Type, attrs: &ZodAttrs) -> String {
335 if is_option_type(ty)
337 && let Some(inner) = option_inner(ty)
338 {
339 let inner_schema = rust_type_to_zod(inner, &ZodAttrs::default());
340 return format!("{}.optional()", inner_schema);
341 }
342
343 if let Some(m) = try_extract_wrapper(ty, VEC)
345 && let Some(inner) = m.first_type()
346 {
347 let inner_schema = rust_type_to_zod(inner, &ZodAttrs::default());
348 let mut chain = format!("z.array({})", inner_schema);
349 if let Some(n) = attrs.length {
350 chain.push_str(&format!(".length({})", n));
351 }
352 if let Some(n) = attrs.min_length {
353 chain.push_str(&format!(".min({})", n));
354 }
355 if let Some(n) = attrs.max_length {
356 chain.push_str(&format!(".max({})", n));
357 }
358 return chain;
359 }
360
361 if let Some(m) = try_extract_wrapper(ty, HASHMAP) {
363 let types = m.all_types();
364 if types.len() == 2 {
365 let key_schema = rust_type_to_zod(&types[0], &ZodAttrs::default());
366 let value_schema = rust_type_to_zod(&types[1], &ZodAttrs::default());
367 return format!("z.record({}, {})", key_schema, value_schema);
368 } else {
369 return "z.record(z.string(), z.unknown())".to_string();
371 }
372 }
373
374 if let syn::Type::Path(type_path) = ty
376 && let Some(seg) = type_path.path.segments.last()
377 {
378 let name = seg.ident.to_string();
379 return match name.as_str() {
380 "String" | "str" => build_string_schema(attrs),
381 "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64"
382 | "u128" | "usize" => build_integer_schema(attrs),
383 "f32" | "f64" => build_float_schema(attrs),
384 "bool" => "z.boolean()".to_string(),
385 "Uuid" => "z.uuid()".to_string(),
387 "DateTime" => "z.iso.datetime({ offset: true })".to_string(),
389 "Value" => "z.record(z.string(), z.unknown())".to_string(),
391 other => format!("{}Schema", other),
393 };
394 }
395
396 if let syn::Type::Tuple(t) = ty
398 && t.elems.is_empty()
399 {
400 return "z.void()".to_string();
401 }
402
403 "z.unknown()".to_string()
404}
405
406fn build_string_schema(attrs: &ZodAttrs) -> String {
411 let mut chain = String::from("z.string()");
412 if let Some(n) = attrs.length {
413 chain.push_str(&format!(".length({})", n));
414 }
415 if let Some(n) = attrs.min_length {
416 chain.push_str(&format!(".min({})", n));
417 }
418 if let Some(n) = attrs.max_length {
419 chain.push_str(&format!(".max({})", n));
420 }
421 if attrs.email {
422 chain.push_str(".email()");
423 }
424 if attrs.url {
425 chain.push_str(".url()");
426 }
427 if let Some(ref p) = attrs.regex {
428 chain.push_str(&format!(".regex(/{}/)", p));
429 }
430 if let Some(ref p) = attrs.starts_with {
431 chain.push_str(&format!(".startsWith(\"{}\")", p));
432 }
433 if let Some(ref p) = attrs.ends_with {
434 chain.push_str(&format!(".endsWith(\"{}\")", p));
435 }
436 if let Some(ref p) = attrs.includes {
437 chain.push_str(&format!(".includes(\"{}\")", p));
438 }
439 chain
440}
441
442fn build_integer_schema(attrs: &ZodAttrs) -> String {
443 let mut chain = String::from("z.number().int()");
444 append_number_validators(&mut chain, attrs);
445 chain
446}
447
448fn build_float_schema(attrs: &ZodAttrs) -> String {
449 let mut chain = String::from("z.number()");
450 if attrs.int {
451 chain.push_str(".int()");
452 }
453 append_number_validators(&mut chain, attrs);
454 chain
455}
456
457fn append_number_validators(chain: &mut String, attrs: &ZodAttrs) {
458 if let Some(n) = attrs.min {
459 chain.push_str(&format!(".min({})", n));
460 }
461 if let Some(n) = attrs.max {
462 chain.push_str(&format!(".max({})", n));
463 }
464 if attrs.positive {
465 chain.push_str(".positive()");
466 }
467 if attrs.negative {
468 chain.push_str(".negative()");
469 }
470 if attrs.nonnegative {
471 chain.push_str(".nonnegative()");
472 }
473 if attrs.nonpositive {
474 chain.push_str(".nonpositive()");
475 }
476 if attrs.finite {
477 chain.push_str(".finite()");
478 }
479}
480
481fn is_option_type(ty: &syn::Type) -> bool {
486 try_extract_wrapper(ty, OPTION).is_some()
487}
488
489fn option_inner(ty: &syn::Type) -> Option<&syn::Type> {
490 try_extract_wrapper(ty, OPTION)?.first_type()
491}
492
493fn try_generate_container_with_custom_types(ty: &syn::Type) -> Option<String> {
500 if let Some(m) = try_extract_wrapper(ty, VEC) {
502 if let Some(inner) = m.first_type() {
503 if let Some(custom_name) = innermost_custom_name(inner) {
504 let bare = custom_name.rsplit("::").next().unwrap_or(&custom_name);
505 return Some(format!("z.array({}Schema)", bare));
506 }
507 }
508 }
509
510 if let Some(m) = try_extract_wrapper(ty, HASHMAP) {
512 let types = m.all_types();
513 if types.len() == 2 {
514 let key_has_custom = innermost_custom_name(&types[0]).is_some();
515 let val_has_custom = innermost_custom_name(&types[1]).is_some();
516
517 if key_has_custom || val_has_custom {
518 let key_expr = type_to_zod_or_schema_ref(&types[0]);
519 let val_expr = type_to_zod_or_schema_ref(&types[1]);
520 return Some(format!("z.record({}, {})", key_expr, val_expr));
521 }
522 }
523 }
524
525 None
526}
527
528fn type_to_zod_or_schema_ref(ty: &syn::Type) -> String {
532 if let Some(custom) = innermost_custom_name(ty) {
533 let bare = custom.rsplit("::").next().unwrap_or(&custom);
534 format!("{}Schema", bare)
535 } else {
536 rust_type_to_zod(ty, &ZodAttrs::default())
537 }
538}
539
540fn innermost_custom_name(ty: &syn::Type) -> Option<String> {
543 if let Some(m) = try_extract_wrapper(ty, VEC) {
545 return m.first_type().and_then(innermost_custom_name);
546 }
547 if let Some(m) = try_extract_wrapper(ty, HASHMAP) {
549 if let Some(key) = m.first_type() {
552 if let Some(name) = innermost_custom_name(key) {
553 return Some(name);
554 }
555 }
556 if let Some(value) = m.nth_type(1) {
557 return innermost_custom_name(value);
558 }
559 return None;
560 }
561 if is_primitive(ty) {
562 return None;
563 }
564 if let syn::Type::Path(tp) = ty {
565 let segments: Vec<String> = tp
567 .path
568 .segments
569 .iter()
570 .map(|seg| seg.ident.to_string())
571 .collect();
572
573 if segments.is_empty() {
574 return None;
575 }
576
577 let last = segments.last().unwrap();
578 if last == "Value" {
580 return None;
581 }
582
583 return Some(segments.join("::"));
585 }
586 None
587}
588
589pub fn rust_type_to_ts_schema(raw: &str) -> String {
622 let raw = raw.replace(' ', "");
623
624 if raw.starts_with("Sse<") {
625 return "asyncIteratorObject(z.unknown() /* TODO: add #[derive(ZodTs)] to your stream event type */)".to_string();
626 }
627
628 let inner = if raw.starts_with("Result<") {
630 extract_first_generic_arg_string(&raw).unwrap_or(raw.clone())
631 } else {
632 raw.clone()
633 };
634
635 let inner = if inner.starts_with("Json<") && inner.ends_with('>') {
637 inner[5..inner.len() - 1].to_string()
638 } else {
639 inner
640 };
641
642 type_name_to_zod_ref(&inner)
643}
644
645fn type_name_to_zod_ref(type_name: &str) -> String {
647 match type_name {
648 "()" => "z.void()".to_string(),
649 "" => String::new(),
650 "String" | "str" => "z.string()".to_string(),
651 "bool" => "z.boolean()".to_string(),
652 "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128"
653 | "usize" => "z.number().int()".to_string(),
654 "f32" | "f64" => "z.number()".to_string(),
655 "Uuid" => "z.uuid()".to_string(),
656 "DateTime" => "z.iso.datetime({ offset: true })".to_string(),
657 "serde_json::Value" | "Value" => "z.record(z.string(), z.unknown())".to_string(),
658 _ if type_name.starts_with("Vec<") && type_name.ends_with('>') => {
659 let inner = &type_name[4..type_name.len() - 1];
660 format!("z.array({})", type_name_to_zod_ref(inner))
661 }
662 _ if type_name.starts_with("HashMap<") && type_name.ends_with('>') => {
663 let inner = &type_name[8..type_name.len() - 1];
664 let parts: Vec<&str> = inner.splitn(2, ',').collect();
666 if parts.len() == 2 {
667 let key_schema = type_name_to_zod_ref(parts[0].trim());
668 let value_schema = type_name_to_zod_ref(parts[1].trim());
669 format!("z.record({}, {})", key_schema, value_schema)
670 } else {
671 "z.record(z.string(), z.unknown())".to_string()
673 }
674 }
675 _ if type_name.starts_with("Option<") && type_name.ends_with('>') => {
676 let inner = &type_name[7..type_name.len() - 1];
677 format!("{}.optional()", type_name_to_zod_ref(inner))
678 }
679 _ => {
680 let base = type_name.rsplit("::").next().unwrap_or(type_name);
681 format!("{}Schema", base)
682 }
683 }
684}
685
686fn extract_first_generic_arg_string(type_str: &str) -> Option<String> {
690 let start = type_str.find('<')? + 1;
691 let mut depth = 0;
692 let mut end = start;
693
694 for (i, ch) in type_str[start..].char_indices() {
695 match ch {
696 '<' => depth += 1,
697 '>' if depth == 0 => {
698 end = start + i;
699 break;
700 }
701 '>' => depth -= 1,
702 ',' if depth == 0 => {
703 end = start + i;
704 break;
705 }
706 _ => {}
707 }
708 }
709
710 if end > start {
711 Some(type_str[start..end].to_string())
712 } else {
713 None
714 }
715}
716
717pub fn to_schema_name(rust_type: &str) -> String {
719 format!("{}Schema", base_type_name(rust_type))
720}
721
722pub fn base_type_name(rust_type: &str) -> String {
726 let mut base = rust_type.trim();
727
728 if base.starts_with("Result<")
729 && let Some(inner) = extract_first_generic_arg_string(base)
730 {
731 base = Box::leak(inner.into_boxed_str());
732 }
733 if base.starts_with("Json<") && base.ends_with('>') {
734 base = &base[5..base.len() - 1];
735 }
736 if base.starts_with("Vec<") && base.ends_with('>') {
737 base = &base[4..base.len() - 1];
738 }
739 if base.starts_with("Option<") && base.ends_with('>') {
740 base = &base[7..base.len() - 1];
741 }
742
743 base.rsplit("::").next().unwrap_or(base).to_string()
744}
745
746#[cfg(test)]
747mod runtime_conversion_tests {
748 use super::*;
749
750 #[test]
751 fn json_planet() {
752 assert_eq!(rust_type_to_ts_schema("Json<Planet>"), "PlanetSchema");
753 }
754
755 #[test]
756 fn json_vec_planet() {
757 assert_eq!(
758 rust_type_to_ts_schema("Json<Vec<Planet>>"),
759 "z.array(PlanetSchema)"
760 );
761 }
762
763 #[test]
764 fn result_json_planet() {
765 assert_eq!(
766 rust_type_to_ts_schema("Result<Json<Planet>, StatusCode>"),
767 "PlanetSchema"
768 );
769 }
770
771 #[test]
772 fn json_string() {
773 assert_eq!(rust_type_to_ts_schema("Json<String>"), "z.string()");
774 }
775
776 #[test]
777 fn unit_type() {
778 assert_eq!(rust_type_to_ts_schema("()"), "z.void()");
779 }
780
781 #[test]
782 fn serde_json_value() {
783 assert_eq!(
784 rust_type_to_ts_schema("Json<serde_json::Value>"),
785 "z.record(z.string(), z.unknown())"
786 );
787 }
788
789 #[test]
790 fn schema_name_simple() {
791 assert_eq!(to_schema_name("Planet"), "PlanetSchema");
792 }
793
794 #[test]
795 fn schema_name_vec() {
796 assert_eq!(to_schema_name("Vec<Planet>"), "PlanetSchema");
797 }
798
799 #[test]
800 fn base_type_unwraps_wrappers() {
801 assert_eq!(base_type_name("Result<Json<Vec<Planet>>, E>"), "Planet");
802 assert_eq!(base_type_name("Json<Planet>"), "Planet");
803 assert_eq!(base_type_name("Vec<Planet>"), "Planet");
804 assert_eq!(base_type_name("Option<Planet>"), "Planet");
805 }
806
807 #[test]
808 fn base_type_strips_module_path() {
809 assert_eq!(base_type_name("models::Planet"), "Planet");
810 assert_eq!(base_type_name("crate::domain::Planet"), "Planet");
811 }
812
813 #[test]
814 fn hashmap_string_string() {
815 assert_eq!(
816 rust_type_to_ts_schema("HashMap<String, String>"),
817 "z.record(z.string(), z.string())"
818 );
819 }
820
821 #[test]
822 fn hashmap_with_custom_value() {
823 assert_eq!(
824 rust_type_to_ts_schema("HashMap<String, Planet>"),
825 "z.record(z.string(), PlanetSchema)"
826 );
827 }
828
829 #[test]
830 fn json_hashmap() {
831 assert_eq!(
832 rust_type_to_ts_schema("Json<HashMap<String, String>>"),
833 "z.record(z.string(), z.string())"
834 );
835 }
836
837 #[test]
838 fn vec_of_custom_type() {
839 let ty: syn::Type = syn::parse_str("Vec<Planet>").unwrap();
840 let expr = try_generate_container_with_custom_types(&ty);
841 assert_eq!(expr, Some("z.array(PlanetSchema)".to_string()));
842 }
843
844 #[test]
845 fn vec_of_primitive_returns_none() {
846 let ty: syn::Type = syn::parse_str("Vec<String>").unwrap();
847 let expr = try_generate_container_with_custom_types(&ty);
848 assert_eq!(expr, None);
849 }
850
851 #[test]
852 fn hashmap_with_custom_key() {
853 let ty: syn::Type = syn::parse_str("HashMap<Planet, String>").unwrap();
854 let expr = try_generate_container_with_custom_types(&ty);
855 assert_eq!(expr, Some("z.record(PlanetSchema, z.string())".to_string()));
856 }
857
858 #[test]
859 fn hashmap_fully_primitive_returns_none() {
860 let ty: syn::Type = syn::parse_str("HashMap<String, i32>").unwrap();
861 let expr = try_generate_container_with_custom_types(&ty);
862 assert_eq!(expr, None);
863 }
864}