Skip to main content

rorpc_parse/
types.rs

1//! AST-based type analysis utilities.
2//!
3//! All wrapper extraction operates on `syn::Type` AST nodes by checking the
4//! **final path segment ident** — never on a string representation of the type.
5//! This means `Json<T>`, `axum::Json<T>`, and `axum::extract::Json<T>` are
6//! all matched identically.
7
8use syn::{GenericArgument, PathArguments, Token, Type, TypePath, punctuated::Punctuated};
9
10use crate::errors::{Error, Result};
11
12// ---------------------------------------------------------------------------
13// Well-known wrapper names
14// ---------------------------------------------------------------------------
15
16pub const JSON: &str = "Json";
17pub const QUERY: &str = "Query";
18pub const PATH: &str = "Path";
19pub const RESULT: &str = "Result";
20pub const OPTION: &str = "Option";
21pub const VEC: &str = "Vec";
22pub const HASHMAP: &str = "HashMap";
23pub const STATE: &str = "State";
24pub const SSE: &str = "Sse";
25pub const STATUSCODE: &str = "StatusCode";
26
27// ---------------------------------------------------------------------------
28// WrapperMatch — result of a successful wrapper extraction
29// ---------------------------------------------------------------------------
30
31/// The generic argument list of a matched wrapper type.
32///
33/// Returned by [`try_extract_wrapper`] and [`extract_wrapper`].
34// WrapperMatch holds references into the syn AST — Debug is derived for test ergonomics.
35pub struct WrapperMatch<'a> {
36    pub generic_args: &'a Punctuated<GenericArgument, Token![,]>,
37}
38
39impl std::fmt::Debug for WrapperMatch<'_> {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.debug_struct("WrapperMatch")
42            .field("generic_args_len", &self.generic_args.len())
43            .finish()
44    }
45}
46
47impl<'a> WrapperMatch<'a> {
48    /// The first type argument — `T` in `Wrapper<T, ...>`.
49    pub fn first_type(&self) -> Option<&'a Type> {
50        self.generic_args.iter().find_map(as_type_arg)
51    }
52
53    /// The second type argument — `E` in `Result<T, E>`.
54    pub fn second_type(&self) -> Option<&'a Type> {
55        self.generic_args.iter().filter_map(as_type_arg).nth(1)
56    }
57
58    /// The nth type argument (0-indexed).
59    pub fn nth_type(&self, n: usize) -> Option<&'a Type> {
60        self.generic_args.iter().filter_map(as_type_arg).nth(n)
61    }
62
63    /// All type arguments as a Vec.
64    pub fn all_types(&self) -> Vec<&'a Type> {
65        self.generic_args.iter().filter_map(as_type_arg).collect()
66    }
67}
68
69fn as_type_arg(arg: &GenericArgument) -> Option<&Type> {
70    if let GenericArgument::Type(ty) = arg {
71        Some(ty)
72    } else {
73        None
74    }
75}
76
77// ---------------------------------------------------------------------------
78// Extraction — try (Option) and strict (Result)
79// ---------------------------------------------------------------------------
80
81/// Extract the generic arguments of `wrapper_name<...>` from `ty`.
82///
83/// Checks the **final path segment ident** so qualified paths like
84/// `std::result::Result` and `axum::extract::Json` are handled identically
85/// to bare `Result` and `Json`.
86///
87/// Returns `None` when `ty` is not the named wrapper — use this when a
88/// wrapper is optional (e.g. a parameter may or may not be `Json<T>`).
89pub fn try_extract_wrapper<'a>(ty: &'a Type, wrapper_name: &str) -> Option<WrapperMatch<'a>> {
90    let last = last_segment(ty)?;
91    if last.ident != wrapper_name {
92        return None;
93    }
94    match &last.arguments {
95        PathArguments::AngleBracketed(args) if !args.args.is_empty() => Some(WrapperMatch {
96            generic_args: &args.args,
97        }),
98        _ => None,
99    }
100}
101
102/// Extract the generic arguments of `wrapper_name<...>` from `ty`, or return
103/// a structured error if the wrapper is not present or has no type arguments.
104///
105/// Use this when the wrapper is required (e.g. a handler return type must be
106/// `Json<T>` or `Result<Json<T>, E>`).
107pub fn extract_wrapper<'a>(ty: &'a Type, wrapper_name: &'static str) -> Result<WrapperMatch<'a>> {
108    let span = span_of(ty);
109
110    let last = last_segment(ty)
111        .ok_or_else(|| Error::unsupported_type(span, ty, "empty type path", "use a named type"))?;
112
113    if last.ident != wrapper_name {
114        return Err(Error::missing_wrapper(span, wrapper_name, ty));
115    }
116
117    match &last.arguments {
118        PathArguments::AngleBracketed(args) if !args.args.is_empty() => Ok(WrapperMatch {
119            generic_args: &args.args,
120        }),
121        PathArguments::AngleBracketed(_) | PathArguments::None => {
122            Err(Error::empty_generic_args(span, wrapper_name))
123        }
124        PathArguments::Parenthesized(_) => Err(Error::unsupported_type(
125            span,
126            ty,
127            "parenthesised arguments are not supported",
128            "use angle-bracketed generics: `Wrapper<T>`",
129        )),
130    }
131}
132
133// ---------------------------------------------------------------------------
134// Primitive check
135// ---------------------------------------------------------------------------
136
137/// Returns `true` for Rust primitive types and the unit type `()`.
138///
139/// Uses AST ident comparison — never string matching.
140pub fn is_primitive(ty: &Type) -> bool {
141    match ty {
142        // Unit type ()
143        Type::Tuple(t) if t.elems.is_empty() => true,
144        Type::Path(_) => matches!(
145            last_ident_str(ty).as_deref(),
146            Some(
147                "String"
148                    | "str"
149                    | "bool"
150                    | "i8"
151                    | "i16"
152                    | "i32"
153                    | "i64"
154                    | "i128"
155                    | "isize"
156                    | "u8"
157                    | "u16"
158                    | "u32"
159                    | "u64"
160                    | "u128"
161                    | "usize"
162                    | "f32"
163                    | "f64"
164                    // Well-known non-schema types that map to inline Zod expressions
165                    | "Uuid"
166                    | "DateTime"
167                    | "Value" // serde_json::Value
168            )
169        ),
170        _ => false,
171    }
172}
173
174/// Check if a type name string represents a Rust primitive or standard type.
175///
176/// This is for runtime use when you have a type name from metadata as a string,
177/// not a `syn::Type` AST. For compile-time AST checking, use [`is_primitive`] instead.
178///
179/// # Examples
180///
181/// ```
182/// use rorpc_parse::types::is_primitive_type_name;
183///
184/// assert!(is_primitive_type_name("String"));
185/// assert!(is_primitive_type_name("i32"));
186/// assert!(is_primitive_type_name("()"));
187/// assert!(!is_primitive_type_name("Planet"));
188/// ```
189pub fn is_primitive_type_name(type_name: &str) -> bool {
190    matches!(
191        type_name,
192        "()" | "String"
193            | "str"
194            | "i8"
195            | "i16"
196            | "i32"
197            | "i64"
198            | "i128"
199            | "u8"
200            | "u16"
201            | "u32"
202            | "u64"
203            | "u128"
204            | "f32"
205            | "f64"
206            | "bool"
207            | "usize"
208            | "isize"
209            | "serde_json::Value"
210            | "Json<serde_json::Value>"
211    )
212}
213
214// ---------------------------------------------------------------------------
215// Innermost custom type
216// ---------------------------------------------------------------------------
217
218/// Recursively unwrap well-known wrappers and return the innermost type.
219///
220/// `Result<Json<Vec<Planet>>, E>` → `Planet` (a `Type::Path` for `Planet`)
221///
222/// Returns `None` when the innermost resolved type is a primitive (no schema
223/// registration needed) or when the type cannot be unwrapped further.
224pub fn innermost_custom_type(ty: &Type) -> Option<&Type> {
225    for wrapper in &[RESULT, JSON, QUERY, OPTION, VEC, HASHMAP, STATE, SSE] {
226        if let Some(m) = try_extract_wrapper(ty, wrapper)
227            && let Some(inner) = m.first_type()
228        {
229            return innermost_custom_type(inner);
230        }
231    }
232    // Base case: no wrapper matched — this is the innermost type
233    if is_primitive(ty) { None } else { Some(ty) }
234}
235
236// ---------------------------------------------------------------------------
237// Internal helpers
238// ---------------------------------------------------------------------------
239
240fn last_segment(ty: &Type) -> Option<&syn::PathSegment> {
241    if let Type::Path(TypePath { path, .. }) = ty {
242        path.segments.last()
243    } else {
244        None
245    }
246}
247
248fn last_ident_str(ty: &Type) -> Option<String> {
249    last_segment(ty).map(|seg| seg.ident.to_string())
250}
251
252/// Check if the last path segment ident matches the given name.
253///
254/// Used for types that don't have generic arguments (like StatusCode).
255pub fn last_ident_matches(ty: &Type, name: &str) -> bool {
256    last_ident_str(ty).as_deref() == Some(name)
257}
258
259fn span_of(ty: &Type) -> proc_macro2::Span {
260    use syn::spanned::Spanned;
261    ty.span()
262}
263
264/// Extract the first generic argument from a type string.
265///
266/// String-based parsing for runtime use. Returns the first type argument
267/// from generic type syntax, handling nested generics correctly.
268///
269/// # Examples
270///
271/// ```
272/// use rorpc_parse::types::extract_first_generic_arg_string;
273///
274/// assert_eq!(extract_first_generic_arg_string("Result<T, E>"), Some("T".to_string()));
275/// assert_eq!(extract_first_generic_arg_string("Vec<Planet>"), Some("Planet".to_string()));
276/// assert_eq!(extract_first_generic_arg_string("Result<Json<Planet>, E>"), Some("Json<Planet>".to_string()));
277/// assert_eq!(extract_first_generic_arg_string("NoGenerics"), None);
278/// ```
279pub fn extract_first_generic_arg_string(type_str: &str) -> Option<String> {
280    let start = type_str.find('<')? + 1;
281    let mut depth = 0;
282    let mut end = start;
283
284    for (i, ch) in type_str[start..].char_indices() {
285        match ch {
286            '<' => depth += 1,
287            '>' if depth == 0 => {
288                end = start + i;
289                break;
290            }
291            '>' => depth -= 1,
292            ',' if depth == 0 => {
293                end = start + i;
294                break;
295            }
296            _ => {}
297        }
298    }
299
300    if end > start {
301        Some(type_str[start..end].trim().to_string())
302    } else {
303        None
304    }
305}
306
307// ---------------------------------------------------------------------------
308// Tests
309// ---------------------------------------------------------------------------
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use crate::errors::type_display;
315
316    fn parse_type(s: &str) -> Type {
317        syn::parse_str(s).unwrap()
318    }
319
320    // --- try_extract_wrapper ---
321
322    #[test]
323    fn bare_json() {
324        let ty = parse_type("Json<Planet>");
325        let m = try_extract_wrapper(&ty, JSON).unwrap();
326        assert!(m.first_type().is_some());
327    }
328
329    #[test]
330    fn qualified_json() {
331        let ty = parse_type("axum::extract::Json<Planet>");
332        let m = try_extract_wrapper(&ty, JSON).unwrap();
333        assert!(m.first_type().is_some());
334    }
335
336    #[test]
337    fn qualified_result() {
338        let ty = parse_type("std::result::Result<Json<Planet>, AppError>");
339        let m = try_extract_wrapper(&ty, RESULT).unwrap();
340        assert!(m.first_type().is_some());
341        assert!(m.second_type().is_some());
342    }
343
344    #[test]
345    fn wrong_wrapper_returns_none() {
346        let ty = parse_type("Option<String>");
347        assert!(try_extract_wrapper(&ty, JSON).is_none());
348    }
349
350    #[test]
351    fn wrapper_without_args_returns_none() {
352        // `Json` with no generics — syn parses this as a bare path, not angle-bracketed
353        let ty = parse_type("Json");
354        assert!(try_extract_wrapper(&ty, JSON).is_none());
355    }
356
357    // --- extract_wrapper (strict) ---
358
359    #[test]
360    fn extract_wrong_wrapper_gives_error() {
361        let ty = parse_type("Vec<Planet>");
362        let err = extract_wrapper(&ty, JSON).unwrap_err();
363        let msg = err.to_string();
364        assert!(msg.contains("expected `Json` wrapper"));
365        assert!(msg.contains("Vec<Planet>"));
366    }
367
368    // --- is_primitive ---
369
370    #[test]
371    fn primitives_identified() {
372        for s in &["String", "i32", "u64", "f64", "bool", "usize"] {
373            assert!(is_primitive(&parse_type(s)), "{} should be primitive", s);
374        }
375    }
376
377    #[test]
378    fn unit_type_is_primitive() {
379        let ty: Type = syn::parse_str("()").unwrap();
380        assert!(is_primitive(&ty));
381    }
382
383    #[test]
384    fn custom_type_not_primitive() {
385        assert!(!is_primitive(&parse_type("Planet")));
386        assert!(!is_primitive(&parse_type("AppError")));
387    }
388
389    // --- innermost_custom_type ---
390
391    #[test]
392    fn unwraps_result_json_vec() {
393        let ty = parse_type("Result<Json<Vec<Planet>>, AppError>");
394        let inner = innermost_custom_type(&ty).unwrap();
395        assert_eq!(type_display(inner), "Planet");
396    }
397
398    #[test]
399    fn primitive_innermost_returns_none() {
400        let ty = parse_type("Json<String>");
401        assert!(innermost_custom_type(&ty).is_none());
402    }
403
404    #[test]
405    fn custom_type_at_root_returned_as_is() {
406        let ty = parse_type("Planet");
407        let inner = innermost_custom_type(&ty).unwrap();
408        assert_eq!(type_display(inner), "Planet");
409    }
410
411    // --- WrapperMatch ---
412
413    #[test]
414    fn second_type_on_result() {
415        let ty = parse_type("Result<Json<Planet>, AppError>");
416        let m = try_extract_wrapper(&ty, RESULT).unwrap();
417        let second = m.second_type().unwrap();
418        assert_eq!(type_display(second), "AppError");
419    }
420
421    #[test]
422    fn nth_type_indexing() {
423        let ty = parse_type("Result<Json<Planet>, AppError>");
424        let m = try_extract_wrapper(&ty, RESULT).unwrap();
425        assert!(m.nth_type(0).is_some());
426        assert!(m.nth_type(1).is_some());
427        assert!(m.nth_type(2).is_none());
428    }
429}