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