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