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