Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changepacks/changepack_log_EpxhrM524BIpZTRmhX9z1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"changes":{"crates/vespera_core/Cargo.toml":"Patch","crates/vespera/Cargo.toml":"Patch","crates/vespera_macro/Cargo.toml":"Patch"},"note":"Support nested generic","date":"2026-01-26T07:06:46.307922700Z"}
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

234 changes: 209 additions & 25 deletions crates/vespera_macro/src/parser/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -699,35 +699,118 @@ pub fn parse_struct_to_schema(
}

fn substitute_type(ty: &Type, generic_params: &[String], concrete_types: &[&Type]) -> Type {
// Check if this is a generic parameter
if let Type::Path(type_path) = ty
&& let Some(segment) = type_path.path.segments.last()
{
let ident_str = segment.ident.to_string();
if generic_params.contains(&ident_str) && segment.arguments.is_none() {
// Find the index and substitute
if let Some(index) = generic_params.iter().position(|p| p == &ident_str)
&& let Some(concrete_ty) = concrete_types.get(index)
{
return (*concrete_ty).clone();
match ty {
Type::Path(type_path) => {
let path = &type_path.path;
if path.segments.is_empty() {
return ty.clone();
}
}
}

// For complex types, use quote! to regenerate with substitutions
let tokens = quote::quote! { #ty };
let mut new_tokens = tokens.to_string();
// Check if this is a direct generic parameter (e.g., just "T" with no arguments)
if path.segments.len() == 1 {
let segment = &path.segments[0];
let ident_str = segment.ident.to_string();

if let syn::PathArguments::None = &segment.arguments {
// Direct generic parameter substitution
if let Some(index) = generic_params.iter().position(|p| p == &ident_str)
&& let Some(concrete_ty) = concrete_types.get(index) {
return (*concrete_ty).clone();
}
}
}

// Replace generic parameter names with concrete types
for (param, concrete_ty) in generic_params.iter().zip(concrete_types.iter()) {
// Replace standalone generic parameter (not part of another identifier)
let pattern = format!(r"\b{}\b", param);
let replacement = quote::quote! { #concrete_ty }.to_string();
new_tokens = new_tokens.replace(&pattern, &replacement);
}
// For types with generic arguments (e.g., Vec<T>, Option<T>, HashMap<K, V>),
// recursively substitute the type arguments
let mut new_segments = syn::punctuated::Punctuated::new();
for segment in &path.segments {
let new_arguments = match &segment.arguments {
syn::PathArguments::AngleBracketed(args) => {
let mut new_args = syn::punctuated::Punctuated::new();
for arg in &args.args {
let new_arg = match arg {
syn::GenericArgument::Type(inner_ty) => syn::GenericArgument::Type(
substitute_type(inner_ty, generic_params, concrete_types),
),
other => other.clone(),
};
new_args.push(new_arg);
}
syn::PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments {
colon2_token: args.colon2_token,
lt_token: args.lt_token,
args: new_args,
gt_token: args.gt_token,
})
}
other => other.clone(),
};

new_segments.push(syn::PathSegment {
ident: segment.ident.clone(),
arguments: new_arguments,
});
}

// Parse the substituted type
syn::parse_str::<Type>(&new_tokens).unwrap_or_else(|_| ty.clone())
Type::Path(syn::TypePath {
qself: type_path.qself.clone(),
path: syn::Path {
leading_colon: path.leading_colon,
segments: new_segments,
},
})
}
Type::Reference(type_ref) => {
// Handle &T, &mut T
Type::Reference(syn::TypeReference {
and_token: type_ref.and_token,
lifetime: type_ref.lifetime.clone(),
mutability: type_ref.mutability,
elem: Box::new(substitute_type(
&type_ref.elem,
generic_params,
concrete_types,
)),
})
}
Type::Slice(type_slice) => {
// Handle [T]
Type::Slice(syn::TypeSlice {
bracket_token: type_slice.bracket_token,
elem: Box::new(substitute_type(
&type_slice.elem,
generic_params,
concrete_types,
)),
})
}
Type::Array(type_array) => {
// Handle [T; N]
Type::Array(syn::TypeArray {
bracket_token: type_array.bracket_token,
elem: Box::new(substitute_type(
&type_array.elem,
generic_params,
concrete_types,
)),
semi_token: type_array.semi_token,
len: type_array.len.clone(),
})
}
Type::Tuple(type_tuple) => {
// Handle (T1, T2, ...)
let new_elems = type_tuple
.elems
.iter()
.map(|elem| substitute_type(elem, generic_params, concrete_types))
.collect();
Type::Tuple(syn::TypeTuple {
paren_token: type_tuple.paren_token,
elems: new_elems,
})
}
_ => ty.clone(),
}
}

pub(super) fn is_primitive_type(ty: &Type) -> bool {
Expand Down Expand Up @@ -1561,6 +1644,107 @@ mod tests {
assert_eq!(substituted, ty);
}

#[rstest]
// Direct generic param substitution
#[case("T", &["T"], &["String"], "String")]
// Vec<T> substitution
#[case("Vec<T>", &["T"], &["String"], "Vec < String >")]
// Option<T> substitution
#[case("Option<T>", &["T"], &["i32"], "Option < i32 >")]
// Nested: Vec<Option<T>>
#[case("Vec<Option<T>>", &["T"], &["String"], "Vec < Option < String > >")]
// Deeply nested: Option<Vec<Option<T>>>
#[case("Option<Vec<Option<T>>>", &["T"], &["bool"], "Option < Vec < Option < bool > > >")]
// Multiple generic params
#[case("HashMap<K, V>", &["K", "V"], &["String", "i32"], "HashMap < String , i32 >")]
// Generic param not in list (unchanged)
#[case("Vec<U>", &["T"], &["String"], "Vec < U >")]
// Non-generic type (unchanged)
#[case("String", &["T"], &["i32"], "String")]
// Reference type: &T
#[case("&T", &["T"], &["String"], "& String")]
// Mutable reference: &mut T
#[case("&mut T", &["T"], &["i32"], "& mut i32")]
// Slice type: [T]
#[case("[T]", &["T"], &["String"], "[String]")]
// Array type: [T; 5]
#[case("[T; 5]", &["T"], &["u8"], "[u8 ; 5]")]
// Tuple type: (T, U)
#[case("(T, U)", &["T", "U"], &["String", "i32"], "(String , i32)")]
// Complex nested tuple
#[case("(Vec<T>, Option<U>)", &["T", "U"], &["String", "bool"], "(Vec < String > , Option < bool >)")]
// Reference to Vec<T>
#[case("&Vec<T>", &["T"], &["String"], "& Vec < String >")]
// Multi-segment path (no substitution for crate::Type)
#[case("std::vec::Vec<T>", &["T"], &["String"], "std :: vec :: Vec < String >")]
fn test_substitute_type_comprehensive(
#[case] input: &str,
#[case] params: &[&str],
#[case] concrete: &[&str],
#[case] expected: &str,
) {
let ty: Type = syn::parse_str(input).unwrap();
let generic_params: Vec<String> = params.iter().map(|s| s.to_string()).collect();
let concrete_types: Vec<Type> = concrete.iter().map(|s| syn::parse_str(s).unwrap()).collect();
let concrete_refs: Vec<&Type> = concrete_types.iter().collect();

let result = substitute_type(&ty, &generic_params, &concrete_refs);
let result_str = quote::quote!(#result).to_string();

assert_eq!(result_str, expected, "Input: {}", input);
}

#[test]
fn test_substitute_type_empty_path_segments() {
// Create a TypePath with empty segments
let ty = Type::Path(syn::TypePath {
qself: None,
path: syn::Path {
leading_colon: None,
segments: syn::punctuated::Punctuated::new(),
},
});
let concrete: Type = syn::parse_str("String").unwrap();
let result = substitute_type(&ty, &[String::from("T")], &[&concrete]);
// Should return the original type unchanged
assert_eq!(result, ty);
}

#[test]
fn test_substitute_type_with_lifetime_generic_argument() {
// Test type with lifetime: Cow<'static, T>
// The lifetime argument should be preserved while T is substituted
let ty: Type = syn::parse_str("std::borrow::Cow<'static, T>").unwrap();
let concrete: Type = syn::parse_str("String").unwrap();
let result = substitute_type(&ty, &[String::from("T")], &[&concrete]);
let result_str = quote::quote!(#result).to_string();
// Lifetime 'static should be preserved, T should be substituted
assert_eq!(result_str, "std :: borrow :: Cow < 'static , String >");
}

#[test]
fn test_substitute_type_parenthesized_args() {
// Fn(T) -> U style (parenthesized arguments)
// This tests the `other => other.clone()` branch for PathArguments
let ty: Type = syn::parse_str("fn(T) -> U").unwrap();
let concrete_t: Type = syn::parse_str("String").unwrap();
let concrete_u: Type = syn::parse_str("i32").unwrap();
let result = substitute_type(&ty, &[String::from("T"), String::from("U")], &[&concrete_t, &concrete_u]);
// Type::BareFn doesn't go through the Path branch, falls to _ => ty.clone()
assert_eq!(result, ty);
}

#[test]
fn test_substitute_type_path_without_angle_brackets() {
// Test path with parenthesized arguments: Fn(T) -> U as a trait
let ty: Type = syn::parse_str("dyn Fn(T) -> U").unwrap();
let concrete_t: Type = syn::parse_str("String").unwrap();
let concrete_u: Type = syn::parse_str("i32").unwrap();
let result = substitute_type(&ty, &[String::from("T"), String::from("U")], &[&concrete_t, &concrete_u]);
// Type::TraitObject falls to _ => ty.clone()
assert_eq!(result, ty);
}

#[rstest]
#[case("&i32")]
#[case("std::string::String")]
Expand Down
Loading