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