use proc_macro2::TokenStream as TokenStream2;
use quote::{format_ident, quote};
use syn::spanned::Spanned;
use syn::{parse2, Expr, FnArg, GenericParam, Ident, ItemFn, Pat, Type};
pub fn expand(item: TokenStream2) -> TokenStream2 {
let input: ItemFn = match parse2(item) {
Ok(f) => f,
Err(e) => return e.to_compile_error(),
};
let attrs = &input.attrs;
let vis = &input.vis;
let sig = &input.sig;
let block = &input.block;
let fn_name = &sig.ident;
let output = &sig.output;
let generics = &sig.generics;
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
let ty_generics_for_turbofish = ty_generics_to_turbofish(generics);
let generic_type_params: Vec<Ident> = generics
.params
.iter()
.filter_map(|p| match p {
GenericParam::Type(t) => Some(t.ident.clone()),
_ => None,
})
.collect();
let mut props: Vec<Prop> = Vec::new();
for arg in &sig.inputs {
let pat_type = match arg {
FnArg::Typed(t) => t,
FnArg::Receiver(r) => {
return syn::Error::new(
r.span(),
"#[component] does not support method receivers (`self` / `&self`)",
)
.to_compile_error();
}
};
let ident = match &*pat_type.pat {
Pat::Ident(pi) => pi.ident.clone(),
other => {
return syn::Error::new(
other.span(),
"#[component] parameters must be plain identifiers \
(no destructuring patterns)",
)
.to_compile_error();
}
};
let prop_attr = match parse_prop_attr(&pat_type.attrs) {
Ok(p) => p,
Err(e) => return e.to_compile_error(),
};
let other_attrs: Vec<syn::Attribute> = pat_type
.attrs
.iter()
.filter(|a| !a.path().is_ident("prop"))
.cloned()
.collect();
let kind = classify_prop(&pat_type.ty, &prop_attr, &generic_type_params);
props.push(Prop {
ident,
ty: (*pat_type.ty).clone(),
kind,
forward_attrs: other_attrs,
});
}
let prop_idents: Vec<Ident> = props.iter().map(|p| p.ident.clone()).collect();
let props_fields: Vec<TokenStream2> = props.iter().map(prop_struct_field).collect();
let builder_fields: Vec<TokenStream2> = props.iter().map(prop_builder_field).collect();
let builder_init: Vec<TokenStream2> = props.iter().map(prop_builder_init).collect();
let setter_methods: Vec<TokenStream2> = props.iter().map(prop_setter_method).collect();
let build_assignments: Vec<TokenStream2> = props.iter().map(prop_build_assignment).collect();
let props_name = props_struct_name(fn_name);
let captures: Vec<TokenStream2> = prop_idents
.iter()
.map(|i| {
let cap = format_ident!("__whisker_prop_{}", i);
quote! { let #cap = #i; }
})
.collect();
let restores: Vec<TokenStream2> = prop_idents
.iter()
.map(|i| {
let cap = format_ident!("__whisker_prop_{}", i);
quote! { let #i = ::std::clone::Clone::clone(&#cap); }
})
.collect();
let fn_ptr_expr = if ty_generics_for_turbofish.is_empty() {
quote! { #fn_name as *const () }
} else {
quote! { #fn_name :: < #(#ty_generics_for_turbofish),* > as *const () }
};
let internal_mod = format_ident!("__{}_props_internal", fn_name);
let builder_name = format_ident!("{}Builder", props_name);
let props_struct = quote! {
#[doc(hidden)]
mod #internal_mod {
use super::*;
pub struct #props_name #impl_generics #where_clause {
#(#props_fields),*
}
#[doc(hidden)]
pub struct #builder_name #impl_generics #where_clause {
#(#builder_fields),*
}
impl #impl_generics #props_name #ty_generics #where_clause {
pub fn builder() -> #builder_name #ty_generics {
#builder_name {
#(#builder_init),*
}
}
}
impl #impl_generics #builder_name #ty_generics #where_clause {
#(#setter_methods)*
pub fn build(self) -> #props_name #ty_generics {
#props_name {
#(#build_assignments),*
}
}
}
}
#[doc(hidden)]
#vis use #internal_mod::#props_name;
};
let props_name_str = props_name.to_string();
let alias_str = props_name_str
.strip_suffix("Props")
.unwrap_or(&props_name_str);
let fn_name_str = fn_name.to_string();
let inner_mod = format_ident!("__{}_inner", fn_name);
let new_fn = quote! {
#[doc(hidden)]
mod #inner_mod {
use super::*;
#[doc(hidden)]
#(#attrs)*
pub fn #fn_name #impl_generics (
__props: #props_name #ty_generics
) #output #where_clause {
let #props_name { #(#prop_idents),* } = __props;
#(#captures)*
let __body: ::std::boxed::Box<
dyn ::std::ops::Fn() -> ::whisker::runtime::view::Element + 'static,
> = ::std::boxed::Box::new(move || {
#(#restores)*
::whisker::__hot::call(move || {
#block
})
});
::whisker::runtime::reactive::mount_component_remountable(
#fn_ptr_expr,
__body,
)
}
}
};
let pascal_alias = if alias_str == fn_name_str {
quote! {
#[doc(hidden)]
#vis use #inner_mod::#fn_name;
#[doc(hidden)]
#[allow(non_camel_case_types, type_alias_bounds)]
#vis type #fn_name #impl_generics = #props_name #ty_generics;
}
} else {
let alias_ident = format_ident!("{}", alias_str);
quote! {
#[allow(non_snake_case)]
#vis use #inner_mod::#fn_name as #alias_ident;
#[doc(hidden)]
#[allow(type_alias_bounds)]
#vis type #alias_ident #impl_generics = #props_name #ty_generics;
}
};
quote! {
#props_struct
#new_fn
#pascal_alias
}
}
#[derive(Default, Clone)]
struct PropAttr {
default: Option<Expr>,
optional: bool,
}
fn parse_prop_attr(attrs: &[syn::Attribute]) -> syn::Result<PropAttr> {
let mut out = PropAttr::default();
for attr in attrs {
if !attr.path().is_ident("prop") {
continue;
}
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("default") {
let value = meta.value()?;
let expr: Expr = value.parse()?;
out.default = Some(expr);
Ok(())
} else if meta.path.is_ident("optional") {
out.optional = true;
Ok(())
} else {
Err(meta.error(
"unknown `#[prop(...)]` setting; supported: \
`default = <expr>`, `optional`",
))
}
})?;
}
Ok(out)
}
struct Prop {
ident: Ident,
ty: Type,
kind: PropKind,
forward_attrs: Vec<syn::Attribute>,
}
enum PropKind {
Required,
RequiredGeneric,
Optional {
inner: Type,
inner_is_generic: bool,
},
Children,
Default {
default: Expr,
is_generic: bool,
},
}
fn classify_prop(ty: &Type, attr: &PropAttr, generic_type_params: &[Ident]) -> PropKind {
let is_generic = is_generic_type_param(ty, generic_type_params);
if let Some(default_expr) = attr.default.clone() {
return PropKind::Default {
default: default_expr,
is_generic,
};
}
if attr.optional {
if let Some(inner) = option_inner_type(ty).cloned() {
let inner_is_generic = is_generic_type_param(&inner, generic_type_params);
return PropKind::Optional {
inner,
inner_is_generic,
};
}
return PropKind::Optional {
inner: ty.clone(),
inner_is_generic: is_generic,
};
}
if is_children_type(ty) {
return PropKind::Children;
}
if let Some(inner) = option_inner_type(ty).cloned() {
let inner_is_generic = is_generic_type_param(&inner, generic_type_params);
return PropKind::Optional {
inner,
inner_is_generic,
};
}
if is_generic {
return PropKind::RequiredGeneric;
}
PropKind::Required
}
fn prop_struct_field(prop: &Prop) -> TokenStream2 {
let ident = &prop.ident;
let ty = &prop.ty;
let attrs = &prop.forward_attrs;
quote! {
#(#attrs)*
pub #ident: #ty
}
}
fn prop_builder_field(prop: &Prop) -> TokenStream2 {
let ident = &prop.ident;
let ty = &prop.ty;
match &prop.kind {
PropKind::Required | PropKind::RequiredGeneric | PropKind::Children => {
quote! { #ident: ::std::option::Option<#ty> }
}
PropKind::Optional { inner, .. } => {
quote! { #ident: ::std::option::Option<::std::option::Option<#inner>> }
}
PropKind::Default { .. } => {
quote! { #ident: ::std::option::Option<#ty> }
}
}
}
fn prop_builder_init(prop: &Prop) -> TokenStream2 {
let ident = &prop.ident;
quote! { #ident: ::std::option::Option::None }
}
fn prop_setter_method(prop: &Prop) -> TokenStream2 {
let ident = &prop.ident;
let ty = &prop.ty;
match &prop.kind {
PropKind::Required => quote! {
#[allow(unused_mut)]
pub fn #ident(mut self, value: impl ::std::convert::Into<#ty>) -> Self {
self.#ident = ::std::option::Option::Some(value.into());
self
}
},
PropKind::RequiredGeneric => quote! {
#[allow(unused_mut)]
pub fn #ident(mut self, value: #ty) -> Self {
self.#ident = ::std::option::Option::Some(value);
self
}
},
PropKind::Optional {
inner,
inner_is_generic,
} => {
if *inner_is_generic {
quote! {
#[allow(unused_mut)]
pub fn #ident(mut self, value: #inner) -> Self {
self.#ident = ::std::option::Option::Some(
::std::option::Option::Some(value)
);
self
}
}
} else {
quote! {
#[allow(unused_mut)]
pub fn #ident(mut self, value: impl ::std::convert::Into<#inner>) -> Self {
self.#ident = ::std::option::Option::Some(
::std::option::Option::Some(value.into())
);
self
}
}
}
}
PropKind::Children => quote! {
#[allow(unused_mut)]
pub fn #ident(mut self, value: #ty) -> Self {
self.#ident = ::std::option::Option::Some(value);
self
}
},
PropKind::Default { is_generic, .. } => {
if *is_generic {
quote! {
#[allow(unused_mut)]
pub fn #ident(mut self, value: #ty) -> Self {
self.#ident = ::std::option::Option::Some(value);
self
}
}
} else {
quote! {
#[allow(unused_mut)]
pub fn #ident(mut self, value: impl ::std::convert::Into<#ty>) -> Self {
self.#ident = ::std::option::Option::Some(value.into());
self
}
}
}
}
}
}
fn prop_build_assignment(prop: &Prop) -> TokenStream2 {
let ident = &prop.ident;
let missing_msg = format!("required field `{ident}` was not set");
match &prop.kind {
PropKind::Required | PropKind::RequiredGeneric => quote! {
#ident: self.#ident.expect(#missing_msg)
},
PropKind::Optional { .. } => quote! {
#ident: self.#ident.unwrap_or(::std::option::Option::None)
},
PropKind::Children => quote! {
#ident: self.#ident.unwrap_or_else(|| {
::std::rc::Rc::new(|| ::whisker::runtime::view::View::Empty)
})
},
PropKind::Default { default, .. } => {
quote! {
#ident: self.#ident.unwrap_or_else(|| #default)
}
}
}
}
fn option_inner_type(ty: &Type) -> Option<&Type> {
let Type::Path(tp) = ty else { return None };
let last = tp.path.segments.last()?;
if last.ident != "Option" {
return None;
}
let syn::PathArguments::AngleBracketed(args) = &last.arguments else {
return None;
};
for arg in &args.args {
if let syn::GenericArgument::Type(inner) = arg {
return Some(inner);
}
}
None
}
fn is_generic_type_param(ty: &Type, generic_type_params: &[Ident]) -> bool {
if let Type::Path(tp) = ty {
if tp.qself.is_none() && tp.path.segments.len() == 1 {
let seg = &tp.path.segments[0];
if seg.arguments.is_empty() {
return generic_type_params.contains(&seg.ident);
}
}
}
false
}
fn is_children_type(ty: &Type) -> bool {
last_path_ident(ty)
.map(|i| i == "Children")
.unwrap_or(false)
}
fn last_path_ident(ty: &Type) -> Option<Ident> {
if let Type::Path(tp) = ty {
tp.path.segments.last().map(|s| s.ident.clone())
} else {
None
}
}
fn props_struct_name(fn_name: &Ident) -> Ident {
let snake = fn_name.to_string();
let mut camel = String::with_capacity(snake.len() + 5);
let mut upper_next = true;
for c in snake.chars() {
if c == '_' {
upper_next = true;
continue;
}
if upper_next {
camel.extend(c.to_uppercase());
upper_next = false;
} else {
camel.push(c);
}
}
camel.push_str("Props");
Ident::new(&camel, fn_name.span())
}
fn ty_generics_to_turbofish(generics: &syn::Generics) -> Vec<TokenStream2> {
generics
.params
.iter()
.filter_map(|p| match p {
GenericParam::Type(t) => {
let name = &t.ident;
Some(quote! { #name })
}
GenericParam::Lifetime(_) | GenericParam::Const(_) => None,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use syn::parse_quote;
#[test]
fn props_struct_name_pascal_case_conversion() {
let id: Ident = parse_quote!(card);
assert_eq!(props_struct_name(&id).to_string(), "CardProps");
let id: Ident = parse_quote!(my_component);
assert_eq!(props_struct_name(&id).to_string(), "MyComponentProps");
let id: Ident = parse_quote!(tab_item);
assert_eq!(props_struct_name(&id).to_string(), "TabItemProps");
let id: Ident = parse_quote!(x);
assert_eq!(props_struct_name(&id).to_string(), "XProps");
}
#[test]
fn children_type_detected_across_path_shapes() {
let bare: Type = parse_quote!(Children);
assert!(is_children_type(&bare));
let qualified: Type = parse_quote!(whisker::Children);
assert!(is_children_type(&qualified));
let runtime_path: Type = parse_quote!(::whisker::runtime::view::Children);
assert!(is_children_type(&runtime_path));
let other: Type = parse_quote!(MyChildren);
assert!(!is_children_type(&other));
}
#[test]
fn option_inner_type_unwraps_across_path_shapes() {
let bare: Type = parse_quote!(Option<String>);
let inner = option_inner_type(&bare).unwrap();
assert!(matches!(inner, Type::Path(_)));
let std_path: Type = parse_quote!(std::option::Option<String>);
assert!(option_inner_type(&std_path).is_some());
let fq_path: Type = parse_quote!(::std::option::Option<i32>);
assert!(option_inner_type(&fq_path).is_some());
let not_option: Type = parse_quote!(String);
assert!(option_inner_type(¬_option).is_none());
let custom: Type = parse_quote!(MyOptional);
assert!(option_inner_type(&custom).is_none());
let bare_option: Type = parse_quote!(Option);
assert!(option_inner_type(&bare_option).is_none());
let tup: Type = parse_quote!((u8, u8));
assert!(option_inner_type(&tup).is_none());
}
#[test]
fn ty_generics_turbofish_extracts_only_type_params() {
let g: syn::Generics = parse_quote!(<'a, T: Clone, const N: usize>);
let turbofish = ty_generics_to_turbofish(&g);
assert_eq!(turbofish.len(), 1, "lifetime and const generic skipped");
assert_eq!(turbofish[0].to_string(), "T");
}
#[test]
fn is_generic_type_param_detects_bare_t() {
let t_param: Ident = parse_quote!(T);
let u_param: Ident = parse_quote!(U);
let generics = vec![t_param, u_param];
assert!(is_generic_type_param(&parse_quote!(T), &generics));
assert!(is_generic_type_param(&parse_quote!(U), &generics));
assert!(!is_generic_type_param(&parse_quote!(Option<T>), &generics));
assert!(!is_generic_type_param(&parse_quote!(crate::T), &generics));
assert!(!is_generic_type_param(&parse_quote!(String), &generics));
let t_with_args: Type = parse_quote!(T<i32>);
assert!(!is_generic_type_param(&t_with_args, &generics));
let reference: Type = parse_quote!(&'a str);
assert!(!is_generic_type_param(&reference, &generics));
}
#[test]
fn parse_prop_default_attribute() {
let attrs: Vec<syn::Attribute> = parse_quote! {
#[prop(default = 42)]
};
let parsed = parse_prop_attr(&attrs).unwrap();
assert!(parsed.default.is_some());
assert!(!parsed.optional);
}
#[test]
fn parse_prop_optional_attribute() {
let attrs: Vec<syn::Attribute> = parse_quote! {
#[prop(optional)]
};
let parsed = parse_prop_attr(&attrs).unwrap();
assert!(parsed.optional);
assert!(parsed.default.is_none());
}
#[test]
fn parse_prop_unknown_key_errors() {
let attrs: Vec<syn::Attribute> = parse_quote! {
#[prop(unknown_setting = 1)]
};
match parse_prop_attr(&attrs) {
Ok(_) => panic!("expected error for unknown prop setting"),
Err(e) => assert!(e.to_string().contains("unknown")),
}
}
#[test]
fn parse_prop_ignores_other_attrs() {
let attrs: Vec<syn::Attribute> = parse_quote! {
#[allow(dead_code)]
#[doc = "ignored"]
};
let parsed = parse_prop_attr(&attrs).unwrap();
assert!(parsed.default.is_none());
assert!(!parsed.optional);
}
fn classify(ty: Type, attr: PropAttr, generics: &[Ident]) -> PropKind {
classify_prop(&ty, &attr, generics)
}
#[test]
fn classify_required_for_plain_type() {
let k = classify(parse_quote!(String), PropAttr::default(), &[]);
assert!(matches!(k, PropKind::Required));
}
#[test]
fn classify_required_generic_for_bare_t() {
let generics = vec![parse_quote!(T)];
let k = classify(parse_quote!(T), PropAttr::default(), &generics);
assert!(matches!(k, PropKind::RequiredGeneric));
}
#[test]
fn classify_optional_for_option_of_concrete() {
let k = classify(parse_quote!(Option<String>), PropAttr::default(), &[]);
match k {
PropKind::Optional {
inner_is_generic, ..
} => assert!(!inner_is_generic, "concrete inner shouldn't be generic"),
other => panic!(
"expected Optional, got {other:?}",
other = kind_name(&other)
),
}
}
#[test]
fn classify_optional_for_option_of_generic() {
let generics = vec![parse_quote!(T)];
let k = classify(parse_quote!(Option<T>), PropAttr::default(), &generics);
match k {
PropKind::Optional {
inner_is_generic, ..
} => assert!(
inner_is_generic,
"Option<T> inner T must be flagged generic"
),
other => panic!(
"expected Optional, got {other:?}",
other = kind_name(&other)
),
}
}
#[test]
fn classify_children_for_children_type() {
let k = classify(parse_quote!(Children), PropAttr::default(), &[]);
assert!(matches!(k, PropKind::Children));
let k = classify(parse_quote!(whisker::Children), PropAttr::default(), &[]);
assert!(matches!(k, PropKind::Children));
}
#[test]
fn classify_default_wins_over_other_kinds() {
let attr = PropAttr {
default: Some(parse_quote!(42)),
..PropAttr::default()
};
let k = classify(parse_quote!(Option<i32>), attr.clone(), &[]);
assert!(matches!(
k,
PropKind::Default {
is_generic: false,
..
}
));
let k = classify(parse_quote!(Children), attr, &[]);
assert!(matches!(
k,
PropKind::Default {
is_generic: false,
..
}
));
}
#[test]
fn classify_default_with_generic_t() {
let generics = vec![parse_quote!(T)];
let attr = PropAttr {
default: Some(parse_quote!(Default::default())),
..PropAttr::default()
};
let k = classify(parse_quote!(T), attr, &generics);
assert!(matches!(
k,
PropKind::Default {
is_generic: true,
..
}
));
}
#[test]
fn classify_optional_attribute_wraps_non_option_type() {
let attr = PropAttr {
optional: true,
..PropAttr::default()
};
let k = classify(parse_quote!(String), attr, &[]);
match k {
PropKind::Optional {
inner_is_generic, ..
} => assert!(!inner_is_generic),
other => panic!(
"expected Optional, got {other:?}",
other = kind_name(&other)
),
}
}
#[test]
fn classify_optional_attribute_on_option_uses_inner() {
let attr = PropAttr {
optional: true,
..PropAttr::default()
};
let k = classify(parse_quote!(Option<String>), attr, &[]);
assert!(matches!(k, PropKind::Optional { .. }));
}
fn make_prop(ident: &str, ty: Type, kind: PropKind) -> Prop {
Prop {
ident: format_ident!("{}", ident),
ty,
kind,
forward_attrs: vec![],
}
}
#[test]
fn prop_struct_field_keeps_user_type() {
let p = make_prop("label", parse_quote!(String), PropKind::Required);
let out = prop_struct_field(&p).to_string();
assert!(
out.contains("pub label : String"),
"Props field uses the user's type verbatim; got: {out}"
);
}
#[test]
fn prop_struct_field_forwards_attrs() {
let attrs: Vec<syn::Attribute> = parse_quote! {
#[doc = "user doc"]
#[allow(dead_code)]
};
let p = Prop {
ident: format_ident!("label"),
ty: parse_quote!(String),
kind: PropKind::Required,
forward_attrs: attrs,
};
let out = prop_struct_field(&p).to_string();
assert!(out.contains("doc = \"user doc\""));
assert!(out.contains("allow (dead_code)"));
}
#[test]
fn prop_builder_field_wraps_required_in_option() {
let p = make_prop("a", parse_quote!(String), PropKind::Required);
let out = prop_builder_field(&p).to_string();
assert!(out.contains("a : :: std :: option :: Option < String >"));
}
#[test]
fn prop_builder_field_double_wraps_optional() {
let p = make_prop(
"b",
parse_quote!(Option<String>),
PropKind::Optional {
inner: parse_quote!(String),
inner_is_generic: false,
},
);
let out = prop_builder_field(&p).to_string();
let normalized = out.replace(" >>", " > >");
assert!(
normalized
.contains(":: std :: option :: Option < :: std :: option :: Option < String > >"),
"Option<T> prop should be Option<Option<T>> in builder; got: {out}"
);
}
#[test]
fn prop_builder_field_default_uses_outer_type() {
let p = make_prop(
"c",
parse_quote!(i32),
PropKind::Default {
default: parse_quote!(0),
is_generic: false,
},
);
let out = prop_builder_field(&p).to_string();
assert!(out.contains("c : :: std :: option :: Option < i32 >"));
}
#[test]
fn prop_setter_required_uses_impl_into() {
let p = make_prop("a", parse_quote!(String), PropKind::Required);
let out = prop_setter_method(&p).to_string();
assert!(out.contains("pub fn a"));
assert!(out.contains("impl :: std :: convert :: Into < String >"));
assert!(out.contains("self . a = :: std :: option :: Option :: Some (value . into ())"));
}
#[test]
fn prop_setter_required_generic_takes_t_directly() {
let p = make_prop("v", parse_quote!(T), PropKind::RequiredGeneric);
let out = prop_setter_method(&p).to_string();
assert!(out.contains("pub fn v (mut self , value : T)"));
assert!(!out.contains("Into < T >"));
}
#[test]
fn prop_setter_optional_strips_outer_option() {
let p = make_prop(
"alt",
parse_quote!(Option<String>),
PropKind::Optional {
inner: parse_quote!(String),
inner_is_generic: false,
},
);
let out = prop_setter_method(&p).to_string();
assert!(out.contains("impl :: std :: convert :: Into < String >"));
assert!(out.contains("Option :: Some (:: std :: option :: Option :: Some"));
}
#[test]
fn prop_setter_optional_with_generic_inner_skips_into() {
let p = make_prop(
"alt",
parse_quote!(Option<T>),
PropKind::Optional {
inner: parse_quote!(T),
inner_is_generic: true,
},
);
let out = prop_setter_method(&p).to_string();
assert!(out.contains("value : T"));
assert!(!out.contains("Into < T >"));
}
#[test]
fn prop_setter_children_takes_value_directly() {
let p = make_prop("children", parse_quote!(Children), PropKind::Children);
let out = prop_setter_method(&p).to_string();
assert!(out.contains("value : Children"));
assert!(!out.contains("Into <"));
}
#[test]
fn prop_setter_default_uses_impl_into_for_concrete() {
let p = make_prop(
"count",
parse_quote!(i32),
PropKind::Default {
default: parse_quote!(5),
is_generic: false,
},
);
let out = prop_setter_method(&p).to_string();
assert!(out.contains("impl :: std :: convert :: Into < i32 >"));
}
#[test]
fn prop_setter_default_with_generic_takes_t_directly() {
let p = make_prop(
"v",
parse_quote!(T),
PropKind::Default {
default: parse_quote!(Default::default()),
is_generic: true,
},
);
let out = prop_setter_method(&p).to_string();
assert!(out.contains("value : T"));
assert!(!out.contains("Into < T >"));
}
#[test]
fn prop_build_assignment_required_expects() {
let p = make_prop("a", parse_quote!(String), PropKind::Required);
let out = prop_build_assignment(&p).to_string();
assert!(out.contains(". expect ("));
assert!(out.contains("\"required field `a` was not set\""));
}
#[test]
fn prop_build_assignment_required_generic_expects() {
let p = make_prop("v", parse_quote!(T), PropKind::RequiredGeneric);
let out = prop_build_assignment(&p).to_string();
assert!(out.contains("\"required field `v` was not set\""));
}
#[test]
fn prop_build_assignment_optional_defaults_to_none() {
let p = make_prop(
"alt",
parse_quote!(Option<String>),
PropKind::Optional {
inner: parse_quote!(String),
inner_is_generic: false,
},
);
let out = prop_build_assignment(&p).to_string();
assert!(out.contains("unwrap_or"));
assert!(out.contains("Option :: None"));
}
#[test]
fn prop_build_assignment_children_defaults_to_empty_closure() {
let p = make_prop("children", parse_quote!(Children), PropKind::Children);
let out = prop_build_assignment(&p).to_string();
assert!(out.contains("unwrap_or_else"));
assert!(out.contains("Rc :: new"));
assert!(out.contains("View :: Empty"));
}
#[test]
fn prop_build_assignment_default_uses_user_expr() {
let p = make_prop(
"count",
parse_quote!(i32),
PropKind::Default {
default: parse_quote!(99),
is_generic: false,
},
);
let out = prop_build_assignment(&p).to_string();
assert!(out.contains("unwrap_or_else"));
assert!(out.contains("99"));
}
#[test]
fn expand_emits_props_struct_and_rewritten_fn() {
let input: TokenStream2 = quote! {
fn card(title: String) -> Element {
render! { view { text { {title.clone()} } } }
}
};
let output = expand(input).to_string();
assert!(output.contains("struct CardProps"));
assert!(output.contains("struct CardPropsBuilder"));
assert!(output.contains("fn card"));
assert!(output.contains("__props : CardProps"));
assert!(output.contains("CardProps { title }"));
assert!(output.contains("use __card_inner :: card as Card"));
}
#[test]
fn expand_no_param_component_emits_empty_destructure() {
let input: TokenStream2 = quote! {
fn header() -> Element {
render! { view { text { "Hi" } } }
}
};
let output = expand(input).to_string();
assert!(output.contains("struct HeaderProps"));
assert!(
output.contains("HeaderProps { }") || output.contains("HeaderProps {}"),
"no-param destructure should be empty braces; got: {output}"
);
assert!(output.contains("pub fn builder"));
assert!(output.contains("pub fn build"));
}
#[test]
fn expand_does_not_reference_typed_builder() {
let input: TokenStream2 = quote! {
fn card(title: String, count: i32) -> Element {
render! { view {} }
}
};
let output = expand(input).to_string();
assert!(!output.contains("typed_builder"));
assert!(!output.contains("TypedBuilder"));
assert!(!output.contains("__typed_builder"));
}
#[test]
fn expand_generic_component_uses_turbofish() {
let input: TokenStream2 = quote! {
fn typed<T: Clone + 'static>(value: T) -> Element {
render! { view {} }
}
};
let output = expand(input).to_string();
assert!(output.contains("struct TypedProps"));
assert!(
output.contains("typed :: < T >") || output.contains("typed::<T>"),
"generic fn should use turbofish for fn-ptr cast; got: {output}"
);
}
#[test]
fn expand_rejects_method_receiver() {
let input: TokenStream2 = quote! {
fn card(&self, title: String) -> Element {
render! { view {} }
}
};
let output = expand(input).to_string();
assert!(
output.contains("compile_error"),
"method receiver should produce a compile error; got: {output}"
);
}
#[test]
fn expand_rejects_destructuring_pattern() {
let input: TokenStream2 = quote! {
fn card((a, b): (i32, i32)) -> Element {
render! { view {} }
}
};
let output = expand(input).to_string();
assert!(output.contains("compile_error"));
}
#[test]
fn expand_props_alias_strips_props_suffix_once() {
let input: TokenStream2 = quote! {
fn two_props(title: String, count: i32) -> Element {
render! { view {} }
}
};
let output = expand(input).to_string();
assert!(
output.contains("as TwoProps"),
"alias should be `TwoProps`, not the over-trimmed `Two`; got: {output}"
);
}
#[test]
fn expand_forwards_attribute_on_param_to_props_field() {
let input: TokenStream2 = quote! {
fn card(#[allow(dead_code)] title: String) -> Element {
render! { view {} }
}
};
let output = expand(input).to_string();
assert!(
output.contains("allow (dead_code)"),
"user attr should appear on the Props field; got: {output}"
);
}
fn kind_name(k: &PropKind) -> &'static str {
match k {
PropKind::Required => "Required",
PropKind::RequiredGeneric => "RequiredGeneric",
PropKind::Optional { .. } => "Optional",
PropKind::Children => "Children",
PropKind::Default { .. } => "Default",
}
}
}