use proc_macro2::TokenStream as TokenStream2;
use quote::{format_ident, quote};
use syn::spanned::Spanned;
use syn::{
parse2, FnArg, GenericArgument, Ident, ItemFn, LitStr, Pat, PathArguments, Type, TypePath,
TypeTuple,
};
pub fn expand(attr: TokenStream2, item: TokenStream2) -> TokenStream2 {
let tag_name: LitStr = match parse2(attr.clone()) {
Ok(s) => s,
Err(_) => {
return syn::Error::new(
attr.span(),
"#[whisker::module_component(\"<tag-name>\")] requires a \
string-literal tag name (e.g. `\"Hello\"`, `\"Input\"`)",
)
.to_compile_error();
}
};
let tag_name_str = tag_name.value();
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 fn_name = &sig.ident;
if !sig.generics.params.is_empty() {
return syn::Error::new(
sig.generics.span(),
"#[whisker::module_component] does not support generic parameters \
— platform components are tag-name driven, not type-parameterised. \
Each tag is a distinct registered Lynx UI class.",
)
.to_compile_error();
}
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(),
"#[whisker::module_component] does not support method receivers",
)
.to_compile_error();
}
};
let ident = match &*pat_type.pat {
Pat::Ident(pi) => pi.ident.clone(),
other => {
return syn::Error::new(
other.span(),
"#[whisker::module_component] parameters must be plain identifiers",
)
.to_compile_error();
}
};
let ty = (*pat_type.ty).clone();
let kind = match classify(&ident, &ty) {
Ok(k) => k,
Err(e) => return e.to_compile_error(),
};
props.push(Prop { ident, ty, kind });
}
let props_name = format_ident!("{}", to_pascal_case(&fn_name.to_string()) + "Props");
let builder_name = format_ident!("{}Builder", props_name);
let internal_mod = format_ident!("__{}_props_internal", fn_name);
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(|p| {
let i = &p.ident;
quote! { #i: ::std::option::Option::None }
})
.collect();
let setters: Vec<TokenStream2> = props.iter().map(prop_setter).collect();
let build_assignments: Vec<TokenStream2> = props
.iter()
.map(|p| prop_build_assignment(p, &tag_name_str))
.collect();
let apply_calls: Vec<TokenStream2> = props.iter().map(prop_apply_call).collect();
let drop_unused = if props.is_empty() {
quote! { let _ = props; }
} else {
quote! {}
};
let inner_mod = format_ident!("__{}_inner", fn_name);
let pascal_alias_ident = format_ident!("{}", to_pascal_case(&fn_name.to_string()));
let fn_name_str = fn_name.to_string();
let alias_emission = if pascal_alias_ident == fn_name_str.as_str() {
quote! {
#[doc(hidden)]
#vis use #inner_mod::#fn_name;
#[doc(hidden)]
#[allow(non_camel_case_types)]
#vis type #fn_name = #props_name;
}
} else {
quote! {
#[allow(non_snake_case)]
#vis use #inner_mod::#fn_name as #pascal_alias_ident;
#[doc(hidden)]
#vis type #pascal_alias_ident = #props_name;
}
};
quote! {
#[doc(hidden)]
mod #internal_mod {
use super::*;
pub struct #props_name {
#(#props_fields,)*
pub __ref: ::std::option::Option<::whisker::ElementRef>,
}
#[doc(hidden)]
pub struct #builder_name {
#(#builder_fields,)*
pub __ref: ::std::option::Option<::whisker::ElementRef>,
}
impl #props_name {
pub fn builder() -> #builder_name {
#builder_name {
#(#builder_init,)*
__ref: ::std::option::Option::None,
}
}
}
impl #builder_name {
#(#setters)*
pub fn with_ref(
mut self,
r: ::whisker::ElementRef,
) -> Self {
self.__ref = ::std::option::Option::Some(r);
self
}
pub fn build(self) -> #props_name {
#props_name {
#(#build_assignments,)*
__ref: self.__ref,
}
}
}
}
#[doc(hidden)]
#vis use #internal_mod::#props_name;
#[doc(hidden)]
mod #inner_mod {
use super::*;
#[doc(hidden)]
#(#attrs)*
pub fn #fn_name(props: #props_name) -> ::whisker::runtime::view::Element {
#drop_unused
let __handle = ::whisker::runtime::view::create_element_by_name(
concat!(env!("CARGO_PKG_NAME"), ":", #tag_name)
);
#(#apply_calls)*
if let ::std::option::Option::Some(__r) = props.__ref {
__r.__bind(__handle);
::whisker::on_cleanup(move || __r.__unbind());
}
__handle
}
}
#alias_emission
}
}
struct Prop {
ident: Ident,
ty: Type,
kind: PropKind,
}
enum PropKind {
Style { inner: Type },
Attr { inner: Type },
Children,
EventNoPayload { event: String },
EventTyped { event: String, payload: Type },
}
fn classify(ident: &Ident, ty: &Type) -> syn::Result<PropKind> {
let name = ident.to_string();
if name == "children" {
return Ok(PropKind::Children);
}
if let Some(event) = name.strip_prefix("on_") {
if event.is_empty() {
return Err(syn::Error::new(
ident.span(),
"#[whisker::module_component]: event prop name `on_` is empty; \
use e.g. `on_tap: ()` or `on_input: TouchEvent`",
));
}
let event = event.to_string();
if is_unit_type(ty) {
return Ok(PropKind::EventNoPayload { event });
}
return Ok(PropKind::EventTyped {
event,
payload: ty.clone(),
});
}
if name == "style" {
return Ok(PropKind::Style {
inner: signal_inner(ty).unwrap_or_else(|| ty.clone()),
});
}
Ok(PropKind::Attr {
inner: signal_inner(ty).unwrap_or_else(|| ty.clone()),
})
}
fn signal_inner(ty: &Type) -> Option<Type> {
let Type::Path(TypePath { path, qself: None }) = ty else {
return None;
};
let seg = path.segments.last()?;
if seg.ident != "Signal" {
return None;
}
let PathArguments::AngleBracketed(args) = &seg.arguments else {
return None;
};
args.args.iter().find_map(|a| match a {
GenericArgument::Type(t) => Some(t.clone()),
_ => None,
})
}
fn is_unit_type(ty: &Type) -> bool {
matches!(ty, Type::Tuple(TypeTuple { elems, .. }) if elems.is_empty())
}
fn prop_struct_field(p: &Prop) -> TokenStream2 {
let i = &p.ident;
match &p.kind {
PropKind::Style { .. } | PropKind::Attr { .. } => {
let t = &p.ty;
quote! { pub #i: #t }
}
PropKind::Children => {
quote! { pub #i: ::whisker::runtime::view::Children }
}
PropKind::EventNoPayload { .. } => {
quote! { pub #i: ::std::boxed::Box<dyn ::std::ops::Fn() + 'static> }
}
PropKind::EventTyped { payload, .. } => {
quote! { pub #i: ::std::boxed::Box<dyn ::std::ops::Fn(#payload) + 'static> }
}
}
}
fn prop_builder_field(p: &Prop) -> TokenStream2 {
let i = &p.ident;
match &p.kind {
PropKind::Style { .. } | PropKind::Attr { .. } => {
let t = &p.ty;
quote! { #i: ::std::option::Option<#t> }
}
PropKind::Children => {
quote! { #i: ::std::option::Option<::whisker::runtime::view::Children> }
}
PropKind::EventNoPayload { .. } => {
quote! { #i: ::std::option::Option<::std::boxed::Box<dyn ::std::ops::Fn() + 'static>> }
}
PropKind::EventTyped { payload, .. } => {
quote! { #i: ::std::option::Option<::std::boxed::Box<dyn ::std::ops::Fn(#payload) + 'static>> }
}
}
}
fn prop_setter(p: &Prop) -> TokenStream2 {
let i = &p.ident;
match &p.kind {
PropKind::Style { .. } | PropKind::Attr { .. } => {
let t = &p.ty;
quote! {
#[allow(unused_mut)]
pub fn #i(mut self, value: impl ::std::convert::Into<#t>) -> Self {
self.#i = ::std::option::Option::Some(value.into());
self
}
}
}
PropKind::Children => {
quote! {
#[allow(unused_mut)]
pub fn #i(mut self, value: ::whisker::runtime::view::Children) -> Self {
self.#i = ::std::option::Option::Some(value);
self
}
}
}
PropKind::EventNoPayload { .. } => {
quote! {
#[allow(unused_mut)]
pub fn #i<F: ::std::ops::Fn() + 'static>(mut self, f: F) -> Self {
self.#i = ::std::option::Option::Some(::std::boxed::Box::new(f));
self
}
}
}
PropKind::EventTyped { payload, .. } => {
quote! {
#[allow(unused_mut)]
pub fn #i<F: ::std::ops::Fn(#payload) + 'static>(mut self, f: F) -> Self {
self.#i = ::std::option::Option::Some(::std::boxed::Box::new(f));
self
}
}
}
}
}
fn prop_build_assignment(p: &Prop, tag_name: &str) -> TokenStream2 {
let i = &p.ident;
let name = i.to_string();
let err = format!("required prop `{name}` was not set on `{tag_name}`");
match &p.kind {
PropKind::Children => {
quote! {
#i: self.#i.unwrap_or_else(|| {
::std::rc::Rc::new(|| ::whisker::runtime::view::View::Empty)
})
}
}
PropKind::Style { .. } | PropKind::Attr { .. } => {
quote! { #i: self.#i.unwrap_or_default() }
}
PropKind::EventNoPayload { .. } | PropKind::EventTyped { .. } => {
quote! { #i: self.#i.expect(#err) }
}
}
}
fn prop_apply_call(p: &Prop) -> TokenStream2 {
let i = &p.ident;
let name = i.to_string();
match &p.kind {
PropKind::Style { inner } => {
quote! {
::whisker::runtime::view::apply_styles::<_, #inner>(__handle, props.#i);
}
}
PropKind::Attr { inner } => {
let attr_name = name.replace('_', "-");
quote! {
::whisker::runtime::view::apply_attr::<_, #inner>(__handle, #attr_name, props.#i);
}
}
PropKind::EventNoPayload { event } => {
quote! {
::whisker::runtime::event::bind_unit(
__handle,
#event,
::whisker::runtime::event::BindType::Bind,
props.#i,
);
}
}
PropKind::EventTyped { event, payload } => {
quote! {
::whisker::runtime::event::bind_typed::<#payload, _>(
__handle,
#event,
::whisker::runtime::event::BindType::Bind,
props.#i,
);
}
}
PropKind::Children => {
quote! {
let __children_view: ::whisker::runtime::view::View = (props.#i)();
::whisker::runtime::view::IntoView::into_view(__children_view)
.attach_to(__handle);
}
}
}
}
fn to_pascal_case(snake: &str) -> String {
let mut out = String::with_capacity(snake.len());
let mut capitalize_next = true;
for ch in snake.chars() {
if ch == '_' {
capitalize_next = true;
continue;
}
if capitalize_next {
for upper in ch.to_uppercase() {
out.push(upper);
}
capitalize_next = false;
} else {
out.push(ch);
}
}
out
}