Skip to main content

rorpc_parse/
attributes.rs

1//! Attribute parsing for `#[serde(...)]` and `#[zod(...)]` annotations.
2//!
3//! Both parsers use `syn`'s `parse_nested_meta` API so they work correctly
4//! with the full attribute syntax including `serde(rename(serialize = "...",
5//! deserialize = "..."))` and can skip unknown keys without panicking.
6
7use syn::{Attribute, Meta, spanned::Spanned};
8
9use crate::errors::{Error, Result};
10
11// ---------------------------------------------------------------------------
12// SerdeAttrs
13// ---------------------------------------------------------------------------
14
15/// Parsed `#[serde(...)]` attributes relevant to orpc schema generation.
16#[derive(Debug, Default, PartialEq)]
17pub struct SerdeAttrs {
18    /// `#[serde(rename = "name")]` or `#[serde(rename(deserialize = "name"))]`
19    pub rename: Option<String>,
20    /// `#[serde(rename_all = "camelCase")]` on containers
21    pub rename_all: Option<String>,
22    /// `#[serde(skip)]` or `#[serde(skip_serializing)]`
23    pub skip: bool,
24    /// `#[serde(default)]`
25    pub default: bool,
26    /// `#[serde(tag = "type")]` on enum containers
27    pub tag: Option<String>,
28    /// `#[serde(content = "data")]` on enum containers
29    pub content: Option<String>,
30    /// `#[serde(untagged)]` on enum containers
31    pub untagged: bool,
32    /// `#[serde(skip_serializing_if = "...")]` — stores the predicate string
33    pub skip_serializing_if: Option<String>,
34}
35
36/// Parse all `#[serde(...)]` attributes from a slice, merging results.
37///
38/// Unknown serde keys are silently skipped — orpc only cares about rename/skip.
39pub fn parse_serde_attrs(attrs: &[Attribute]) -> Result<SerdeAttrs> {
40    let mut out = SerdeAttrs::default();
41
42    for attr in attrs {
43        if !attr.path().is_ident("serde") {
44            continue;
45        }
46        attr.parse_nested_meta(|meta| {
47            if meta.path.is_ident("rename") {
48                out.rename = Some(parse_string_or_nested(&meta, "deserialize")?);
49            } else if meta.path.is_ident("rename_all") {
50                out.rename_all = Some(parse_lit_str_value(&meta)?);
51            } else if meta.path.is_ident("skip") || meta.path.is_ident("skip_serializing") {
52                out.skip = true;
53                // skip has no value
54            } else if meta.path.is_ident("default") {
55                out.default = true;
56                // default may have an optional path value — consume and ignore
57                if meta.input.peek(syn::Token![=]) {
58                    let _: syn::Expr = meta.value()?.parse()?;
59                }
60            } else if meta.path.is_ident("tag") {
61                out.tag = Some(parse_lit_str_value(&meta)?);
62            } else if meta.path.is_ident("content") {
63                out.content = Some(parse_lit_str_value(&meta)?);
64            } else if meta.path.is_ident("untagged") {
65                out.untagged = true;
66            } else if meta.path.is_ident("skip_serializing_if") {
67                out.skip_serializing_if = Some(parse_lit_str_value(&meta)?);
68            } else {
69                // Unknown serde key — consume any value so the parser doesn't stall
70                skip_meta_value(&meta)?;
71            }
72            Ok(())
73        })
74        .map_err(Error::from)?;
75    }
76
77    Ok(out)
78}
79
80// ---------------------------------------------------------------------------
81// ZodAttrs
82// ---------------------------------------------------------------------------
83
84/// Parsed `#[zod(...)]` attributes for a single struct field.
85#[derive(Debug, Default, PartialEq)]
86pub struct ZodAttrs {
87    // Numbers
88    pub min: Option<f64>,
89    pub max: Option<f64>,
90    pub int: bool,
91    pub positive: bool,
92    pub negative: bool,
93    pub nonnegative: bool,
94    pub nonpositive: bool,
95    pub finite: bool,
96    // Strings / arrays
97    pub length: Option<usize>,
98    pub min_length: Option<usize>,
99    pub max_length: Option<usize>,
100    pub starts_with: Option<String>,
101    pub ends_with: Option<String>,
102    pub includes: Option<String>,
103    pub email: bool,
104    pub url: bool,
105    pub regex: Option<String>,
106}
107
108/// Parse all `#[zod(...)]` attributes from a slice, merging results.
109///
110/// Returns an error for unrecognised keys so users get an actionable message
111/// rather than silently ignored constraints.
112pub fn parse_zod_attrs(attrs: &[Attribute]) -> Result<ZodAttrs> {
113    let mut out = ZodAttrs::default();
114
115    for attr in attrs {
116        if !attr.path().is_ident("zod") {
117            continue;
118        }
119
120        let Meta::List(ref list) = attr.meta else {
121            continue;
122        };
123
124        attr.parse_nested_meta(|meta| {
125            if meta.path.is_ident("min") {
126                out.min = Some(parse_f64_value(&meta)?);
127            } else if meta.path.is_ident("max") {
128                out.max = Some(parse_f64_value(&meta)?);
129            } else if meta.path.is_ident("length") {
130                out.length = Some(parse_usize_value(&meta)?);
131            } else if meta.path.is_ident("min_length") {
132                out.min_length = Some(parse_usize_value(&meta)?);
133            } else if meta.path.is_ident("max_length") {
134                out.max_length = Some(parse_usize_value(&meta)?);
135            } else if meta.path.is_ident("starts_with") {
136                out.starts_with = Some(parse_lit_str_value(&meta)?);
137            } else if meta.path.is_ident("ends_with") {
138                out.ends_with = Some(parse_lit_str_value(&meta)?);
139            } else if meta.path.is_ident("includes") {
140                out.includes = Some(parse_lit_str_value(&meta)?);
141            } else if meta.path.is_ident("regex") {
142                out.regex = Some(parse_lit_str_value(&meta)?);
143            } else if meta.path.is_ident("email") {
144                out.email = true;
145            } else if meta.path.is_ident("url") {
146                out.url = true;
147            } else if meta.path.is_ident("int") {
148                out.int = true;
149            } else if meta.path.is_ident("positive") {
150                out.positive = true;
151            } else if meta.path.is_ident("negative") {
152                out.negative = true;
153            } else if meta.path.is_ident("nonnegative") {
154                out.nonnegative = true;
155            } else if meta.path.is_ident("nonpositive") {
156                out.nonpositive = true;
157            } else if meta.path.is_ident("finite") {
158                out.finite = true;
159            } else {
160                let key = meta
161                    .path
162                    .get_ident()
163                    .map(|i| i.to_string())
164                    .unwrap_or_default();
165                return Err(syn::Error::new(
166                    meta.path.span(),
167                    Error::unknown_key(
168                        meta.path.span(),
169                        &key,
170                        &[
171                            "min",
172                            "max",
173                            "length",
174                            "min_length",
175                            "max_length",
176                            "starts_with",
177                            "ends_with",
178                            "includes",
179                            "regex",
180                            "email",
181                            "url",
182                            "int",
183                            "positive",
184                            "negative",
185                            "nonnegative",
186                            "nonpositive",
187                            "finite",
188                        ],
189                    )
190                    .to_string(),
191                ));
192            }
193            let _ = list; // suppress unused warning — list is accessed above for Meta::List check
194            Ok(())
195        })
196        .map_err(Error::from)?;
197    }
198
199    Ok(out)
200}
201
202// ---------------------------------------------------------------------------
203// serde rename_all rule application
204// ---------------------------------------------------------------------------
205
206/// Apply a `#[serde(rename_all = "...")]` rule to a variant or field name.
207pub fn apply_rename_rule(rule: &str, name: &str) -> String {
208    match rule {
209        "lowercase" => name.to_ascii_lowercase(),
210        "UPPERCASE" => name.to_ascii_uppercase(),
211        "camelCase" => {
212            let mut chars = name.chars();
213            match chars.next() {
214                Some(first) => first.to_ascii_lowercase().to_string() + chars.as_str(),
215                None => String::new(),
216            }
217        }
218        "snake_case" => to_snake_case(name),
219        "SCREAMING_SNAKE_CASE" => to_snake_case(name).to_ascii_uppercase(),
220        "kebab-case" => to_snake_case(name).replace('_', "-"),
221        "SCREAMING-KEBAB-CASE" => to_snake_case(name).to_ascii_uppercase().replace('_', "-"),
222        // Unknown rules pass through unchanged — serde would reject them at compile time
223        _ => name.to_string(),
224    }
225}
226
227fn to_snake_case(name: &str) -> String {
228    let mut out = String::new();
229    for (i, ch) in name.char_indices() {
230        if i > 0 && ch.is_uppercase() {
231            out.push('_');
232        }
233        out.push(ch.to_ascii_lowercase());
234    }
235    out
236}
237
238// ---------------------------------------------------------------------------
239// Internal parsing helpers
240// ---------------------------------------------------------------------------
241
242/// Parse `= "string"` or `("string")` value from a meta item.
243fn parse_lit_str_value(meta: &syn::meta::ParseNestedMeta) -> syn::Result<String> {
244    if meta.input.peek(syn::Token![=]) {
245        let lit: syn::LitStr = meta.value()?.parse()?;
246        return Ok(lit.value());
247    }
248    // Parenthesised form: key("value")
249    if meta.input.peek(syn::token::Paren) {
250        let mut result = None;
251        meta.parse_nested_meta(|inner| {
252            // The inner content is a bare string literal, not a key=value pair.
253            // We parse it by reading the literal directly from the token stream.
254            let lit: syn::LitStr = inner.input.parse()?;
255            result = Some(lit.value());
256            Ok(())
257        })?;
258        if let Some(v) = result {
259            return Ok(v);
260        }
261    }
262    Err(syn::Error::new(
263        meta.input.span(),
264        "expected `= \"value\"` or `(\"value\")`",
265    ))
266}
267
268/// Parse `= 3.14` / `= 42` or `(3.14)` / `(42)` as f64.
269fn parse_f64_value(meta: &syn::meta::ParseNestedMeta) -> syn::Result<f64> {
270    let expr: syn::Expr = if meta.input.peek(syn::Token![=]) {
271        meta.value()?.parse()?
272    } else if meta.input.peek(syn::token::Paren) {
273        // Parenthesised form: key(42) or key(3.14)
274        let content;
275        syn::parenthesized!(content in meta.input);
276        content.parse()?
277    } else {
278        return Err(syn::Error::new(
279            meta.input.span(),
280            "expected `= <number>` or `(<number>)`",
281        ));
282    };
283
284    match &expr {
285        syn::Expr::Lit(syn::ExprLit {
286            lit: syn::Lit::Float(f),
287            ..
288        }) => f
289            .base10_parse::<f64>()
290            .map_err(|e| syn::Error::new(f.span(), e)),
291        syn::Expr::Lit(syn::ExprLit {
292            lit: syn::Lit::Int(i),
293            ..
294        }) => i
295            .base10_parse::<f64>()
296            .map_err(|e| syn::Error::new(i.span(), e)),
297        _ => Err(syn::Error::new(expr.span(), "expected a numeric literal")),
298    }
299}
300
301/// Parse `= 42` or `(42)` integer value as usize.
302fn parse_usize_value(meta: &syn::meta::ParseNestedMeta) -> syn::Result<usize> {
303    let lit: syn::LitInt = if meta.input.peek(syn::Token![=]) {
304        meta.value()?.parse()?
305    } else if meta.input.peek(syn::token::Paren) {
306        // Parenthesised form: key(42)
307        let content;
308        syn::parenthesized!(content in meta.input);
309        content.parse()?
310    } else {
311        return Err(syn::Error::new(
312            meta.input.span(),
313            "expected `= <integer>` or `(<integer>)`",
314        ));
315    };
316    lit.base10_parse::<usize>()
317        .map_err(|e| syn::Error::new(lit.span(), e))
318}
319
320/// Parse either `= "value"` or `(serialize = "...", deserialize = "...")`,
321/// preferring the `prefer_key` variant when both are present.
322fn parse_string_or_nested(
323    meta: &syn::meta::ParseNestedMeta,
324    prefer_key: &str,
325) -> syn::Result<String> {
326    if meta.input.peek(syn::Token![=]) {
327        return parse_lit_str_value(meta);
328    }
329    // Nested form: rename(serialize = "a", deserialize = "b")
330    let mut serialize = None;
331    let mut deserialize = None;
332    meta.parse_nested_meta(|inner| {
333        let lit: syn::LitStr = inner.value()?.parse()?;
334        if inner.path.is_ident("serialize") {
335            serialize = Some(lit.value());
336        } else if inner.path.is_ident("deserialize") {
337            deserialize = Some(lit.value());
338        }
339        Ok(())
340    })?;
341    // Prefer the requested key (deserialize drives the wire format we care about)
342    if prefer_key == "deserialize" {
343        Ok(deserialize.or(serialize).unwrap_or_default())
344    } else {
345        Ok(serialize.or(deserialize).unwrap_or_default())
346    }
347}
348
349/// Consume any value attached to a meta key without recording it.
350fn skip_meta_value(meta: &syn::meta::ParseNestedMeta) -> syn::Result<()> {
351    if meta.input.peek(syn::Token![=]) {
352        let _: syn::Expr = meta.value()?.parse()?;
353    } else if meta.input.peek(syn::token::Paren) {
354        meta.parse_nested_meta(|inner| skip_meta_value(&inner))?;
355    }
356    Ok(())
357}
358
359// ---------------------------------------------------------------------------
360// Tests
361// ---------------------------------------------------------------------------
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use syn::{DeriveInput, parse_quote};
367
368    fn attrs_of(input: DeriveInput) -> Vec<Attribute> {
369        input.attrs
370    }
371
372    fn field_attrs(input: &syn::ItemStruct, field_name: &str) -> Vec<Attribute> {
373        if let syn::Fields::Named(fields) = &input.fields {
374            for f in &fields.named {
375                if f.ident.as_ref().map(|i| i == field_name).unwrap_or(false) {
376                    return f.attrs.clone();
377                }
378            }
379        }
380        vec![]
381    }
382
383    // --- SerdeAttrs ---
384
385    #[test]
386    fn serde_rename_simple() {
387        let input: DeriveInput = parse_quote! {
388            #[serde(rename = "planet_name")]
389            struct S;
390        };
391        let attrs = parse_serde_attrs(&attrs_of(input)).unwrap();
392        assert_eq!(attrs.rename, Some("planet_name".to_string()));
393    }
394
395    #[test]
396    fn serde_rename_all() {
397        let input: DeriveInput = parse_quote! {
398            #[serde(rename_all = "camelCase")]
399            struct S;
400        };
401        let attrs = parse_serde_attrs(&attrs_of(input)).unwrap();
402        assert_eq!(attrs.rename_all, Some("camelCase".to_string()));
403    }
404
405    #[test]
406    fn serde_skip() {
407        let input: DeriveInput = parse_quote! {
408            #[serde(skip)]
409            struct S;
410        };
411        let attrs = parse_serde_attrs(&attrs_of(input)).unwrap();
412        assert!(attrs.skip);
413    }
414
415    #[test]
416    fn serde_default() {
417        let input: DeriveInput = parse_quote! {
418            #[serde(default)]
419            struct S;
420        };
421        let attrs = parse_serde_attrs(&attrs_of(input)).unwrap();
422        assert!(attrs.default);
423    }
424
425    #[test]
426    fn serde_unknown_key_ignored() {
427        // Unknown keys are silently skipped
428        let input: DeriveInput = parse_quote! {
429            #[serde(some_future_key = "value")]
430            struct S;
431        };
432        assert!(parse_serde_attrs(&attrs_of(input)).is_ok());
433    }
434
435    #[test]
436    fn serde_tag_only() {
437        let input: DeriveInput = parse_quote! {
438            #[serde(tag = "type")]
439            enum E {}
440        };
441        let attrs = parse_serde_attrs(&attrs_of(input)).unwrap();
442        assert_eq!(attrs.tag, Some("type".to_string()));
443        assert_eq!(attrs.content, None);
444        assert!(!attrs.untagged);
445    }
446
447    #[test]
448    fn serde_tag_and_content() {
449        let input: DeriveInput = parse_quote! {
450            #[serde(tag = "type", content = "data")]
451            enum E {}
452        };
453        let attrs = parse_serde_attrs(&attrs_of(input)).unwrap();
454        assert_eq!(attrs.tag, Some("type".to_string()));
455        assert_eq!(attrs.content, Some("data".to_string()));
456        assert!(!attrs.untagged);
457    }
458
459    #[test]
460    fn serde_untagged() {
461        let input: DeriveInput = parse_quote! {
462            #[serde(untagged)]
463            enum E {}
464        };
465        let attrs = parse_serde_attrs(&attrs_of(input)).unwrap();
466        assert!(attrs.untagged);
467        assert_eq!(attrs.tag, None);
468        assert_eq!(attrs.content, None);
469    }
470
471    #[test]
472    fn serde_skip_serializing_if_option_is_none() {
473        let s: syn::ItemStruct = parse_quote! {
474            struct S {
475                #[serde(skip_serializing_if = "Option::is_none")]
476                field: Option<String>,
477            }
478        };
479        let attrs = parse_serde_attrs(&field_attrs(&s, "field")).unwrap();
480        assert_eq!(attrs.skip_serializing_if, Some("Option::is_none".to_string()));
481    }
482
483    #[test]
484    fn serde_skip_serializing_if_custom_predicate() {
485        let s: syn::ItemStruct = parse_quote! {
486            struct S {
487                #[serde(skip_serializing_if = "String::is_empty")]
488                field: String,
489            }
490        };
491        let attrs = parse_serde_attrs(&field_attrs(&s, "field")).unwrap();
492        assert_eq!(attrs.skip_serializing_if, Some("String::is_empty".to_string()));
493    }
494
495    #[test]
496    fn non_serde_attr_ignored() {
497        let input: DeriveInput = parse_quote! {
498            #[derive(Debug)]
499            struct S;
500        };
501        let attrs = parse_serde_attrs(&attrs_of(input)).unwrap();
502        assert_eq!(attrs, SerdeAttrs::default());
503    }
504
505    // --- ZodAttrs ---
506
507    #[test]
508    fn zod_string_constraints() {
509        let s: syn::ItemStruct = parse_quote! {
510            struct S {
511                #[zod(min_length(3), max_length(100), email)]
512                name: String,
513            }
514        };
515        let attrs = parse_zod_attrs(&field_attrs(&s, "name")).unwrap();
516        assert_eq!(attrs.min_length, Some(3));
517        assert_eq!(attrs.max_length, Some(100));
518        assert!(attrs.email);
519    }
520
521    #[test]
522    fn zod_number_constraints() {
523        let s: syn::ItemStruct = parse_quote! {
524            struct S {
525                #[zod(min(0), max(100), int, positive)]
526                score: f64,
527            }
528        };
529        let attrs = parse_zod_attrs(&field_attrs(&s, "score")).unwrap();
530        assert_eq!(attrs.min, Some(0.0));
531        assert_eq!(attrs.max, Some(100.0));
532        assert!(attrs.int);
533        assert!(attrs.positive);
534    }
535
536    #[test]
537    fn zod_unknown_key_returns_error() {
538        let s: syn::ItemStruct = parse_quote! {
539            struct S {
540                #[zod(unknown_key)]
541                name: String,
542            }
543        };
544        let err = parse_zod_attrs(&field_attrs(&s, "name")).unwrap_err();
545        assert!(err.to_string().contains("unknown key"));
546    }
547
548    // --- apply_rename_rule ---
549
550    #[test]
551    fn rename_rules() {
552        assert_eq!(apply_rename_rule("camelCase", "PlanetName"), "planetName");
553        assert_eq!(apply_rename_rule("snake_case", "PlanetName"), "planet_name");
554        assert_eq!(
555            apply_rename_rule("SCREAMING_SNAKE_CASE", "PlanetName"),
556            "PLANET_NAME"
557        );
558        assert_eq!(apply_rename_rule("kebab-case", "PlanetName"), "planet-name");
559        assert_eq!(apply_rename_rule("lowercase", "PlanetName"), "planetname");
560        assert_eq!(apply_rename_rule("UPPERCASE", "PlanetName"), "PLANETNAME");
561    }
562
563    #[test]
564    fn unknown_rule_passthrough() {
565        assert_eq!(
566            apply_rename_rule("PascalCase", "planet_name"),
567            "planet_name"
568        );
569    }
570}