use proc_macro::TokenStream;
use proc_macro2::Span;
use quote::{format_ident, quote, quote_spanned};
use syn::{
Data, DeriveInput, Error, Expr, Fields, Meta, Path, Result, fold::Fold, parse_macro_input,
parse_quote, spanned::Spanned,
};
#[proc_macro_derive(FromInputs, attributes(input, local, from_inputs))]
pub fn derive_from_inputs(input: TokenStream) -> TokenStream {
expand(parse_macro_input!(input as DeriveInput))
.unwrap_or_else(Error::into_compile_error)
.into()
}
struct ComponentSelf(syn::Ident);
impl Fold for ComponentSelf {
fn fold_path(&mut self, mut path: Path) -> Path {
if path.leading_colon.is_none()
&& path
.segments
.first()
.is_some_and(|part| part.ident == "Self")
{
path.segments.first_mut().unwrap().ident = self.0.clone();
}
syn::fold::fold_path(self, path)
}
}
fn expand(input: DeriveInput) -> Result<proc_macro2::TokenStream> {
let mut runtime: Path = parse_quote!(::fusor);
let mut custom_crate = false;
for attr in &input.attrs {
if attr.path().is_ident("input") || attr.path().is_ident("local") {
return Err(Error::new_spanned(
attr,
"place #[input] or #[local(init = ...)] on a field",
));
}
if attr.path().is_ident("from_inputs") {
if custom_crate {
return Err(Error::new_spanned(
attr,
"duplicate #[from_inputs] attribute",
));
}
attr.parse_nested_meta(|meta| {
if !meta.path.is_ident("crate") {
return Err(meta.error("expected `crate = path`"));
}
if custom_crate {
return Err(meta.error("duplicate crate path"));
}
runtime = meta.value()?.parse()?;
custom_crate = true;
Ok(())
})?;
if !custom_crate {
return Err(Error::new_spanned(
attr,
"expected #[from_inputs(crate = path)]",
));
}
}
}
if !input.generics.params.is_empty() || input.generics.where_clause.is_some() {
return Err(Error::new_spanned(
&input.generics,
"FromInputs derive currently supports concrete structs; implement FromInputs manually for generic components",
));
}
let Data::Struct(data) = input.data else {
return Err(Error::new_spanned(
&input.ident,
"FromInputs can only be derived for a struct",
));
};
if matches!(data.fields, Fields::Unnamed(_)) {
return Err(Error::new_spanned(
data.fields,
"FromInputs requires named fields or a unit struct",
));
}
let name = &input.ident;
let visibility = &input.vis;
let inputs_name = format_ident!(
"{}Inputs",
name.to_string().trim_start_matches("r#"),
span = name.span()
);
let inputs_var = syn::Ident::new("__fusor_inputs", Span::mixed_site());
let owner_var = syn::Ident::new("__fusor_owner", Span::mixed_site());
let mut inputs_fields = Vec::new();
let mut values = Vec::new();
let mut errors: Option<Error> = None;
for field in &data.fields {
let field_name = field.ident.as_ref().unwrap();
let parsed = (|| {
let mut kind: Option<Option<Expr>> = None;
for attr in &field.attrs {
if attr.path().is_ident("from_inputs") {
return Err(Error::new_spanned(
attr,
"#[from_inputs(crate = path)] belongs on the struct",
));
}
if !attr.path().is_ident("input") && !attr.path().is_ident("local") {
continue;
}
if kind.is_some() {
return Err(Error::new_spanned(
attr,
"choose exactly one #[input] or #[local(init = ...)] per field",
));
}
if attr.path().is_ident("input") {
if !matches!(attr.meta, Meta::Path(_)) {
return Err(Error::new_spanned(
attr,
"#[input] takes no arguments; inputs are required and keep their exact Rust type",
));
}
kind = Some(None);
} else {
let mut init = None;
attr.parse_nested_meta(|meta| {
if !meta.path.is_ident("init") {
return Err(meta.error("expected `init = expression`"));
}
if init.is_some() {
return Err(meta.error("duplicate local initializer"));
}
init = Some(meta.value()?.parse::<Expr>()?);
Ok(())
})?;
kind = Some(Some(init.ok_or_else(|| {
Error::new_spanned(attr, "local state requires #[local(init = expression)]")
})?));
}
}
kind.ok_or_else(|| {
Error::new_spanned(
field_name,
"field needs #[input] or #[local(init = expression)]; no value is inferred",
)
})
})();
match parsed {
Ok(None) => {
let ty = ComponentSelf(name.clone()).fold_type(field.ty.clone());
let docs = field
.attrs
.iter()
.filter(|attr| attr.path().is_ident("doc"));
inputs_fields.push(quote_spanned!(field.span()=> #(#docs)* pub #field_name: #ty));
values.push(quote_spanned!(field.span()=> #field_name: #inputs_var.#field_name));
}
Ok(Some(init)) => values.push(quote_spanned!(field.span()=> #field_name: { #init })),
Err(error) => match &mut errors {
Some(errors) => errors.combine(error),
None => errors = Some(error),
},
}
}
if let Some(errors) = errors {
return Err(errors);
}
let doc = format!("Parent-supplied inputs generated by `FromInputs` for `{name}`.");
let declaration = if inputs_fields.is_empty() {
quote!(#[doc = #doc] #visibility struct #inputs_name;)
} else {
quote!(#[doc = #doc] #visibility struct #inputs_name { #(#inputs_fields,)* })
};
let construct = if matches!(data.fields, Fields::Unit) {
quote!(Self)
} else {
quote!(Self { #(#values,)* })
};
Ok(quote! {
#declaration
impl #runtime::dom::FromInputs for #name {
type Inputs = #inputs_name;
fn from_inputs(
#inputs_var: Self::Inputs,
#owner_var: #runtime::OwnerHandle,
) -> ::core::result::Result<Self, #runtime::dom::JsValue> {
::core::result::Result::Ok(#construct)
}
}
})
}
#[cfg(test)]
mod tests;
#[proc_macro_derive(JsInputs, attributes(js, js_inputs))]
pub fn derive_js_inputs(input: TokenStream) -> TokenStream {
expand_js_inputs(parse_macro_input!(input as DeriveInput))
.unwrap_or_else(Error::into_compile_error)
.into()
}
fn supported_js_value(ty: &syn::Type) -> bool {
let syn::Type::Path(path) = ty else {
return false;
};
let Some(segment) = path.path.segments.last() else {
return false;
};
match segment.ident.to_string().as_str() {
"bool" | "String" | "f64" | "i32" | "u32" | "JsValue" => {
matches!(segment.arguments, syn::PathArguments::None)
}
"Option" | "Vec" => {
let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
return false;
};
args.args.len() == 1
&& matches!(args.args.first(), Some(syn::GenericArgument::Type(inner)) if supported_js_value(inner))
}
_ => false,
}
}
fn expand_js_inputs(input: DeriveInput) -> Result<proc_macro2::TokenStream> {
let mut runtime: Path = parse_quote!(::fusor);
for attr in &input.attrs {
if attr.path().is_ident("js_inputs") {
attr.parse_nested_meta(|meta| {
if !meta.path.is_ident("crate") {
return Err(meta.error("expected `crate = path`"));
}
runtime = meta.value()?.parse()?;
Ok(())
})?;
}
if attr.path().is_ident("js") {
return Err(Error::new_spanned(
attr,
"place #[js] on an exposed Signal field",
));
}
}
if !input.generics.params.is_empty() || input.generics.where_clause.is_some() {
return Err(Error::new_spanned(
&input.generics,
"JsInputs derive supports concrete structs",
));
}
let Data::Struct(data) = input.data else {
return Err(Error::new_spanned(
input.ident,
"JsInputs requires a struct",
));
};
if matches!(data.fields, Fields::Unnamed(_)) {
return Err(Error::new_spanned(
data.fields,
"JsInputs requires named fields or a unit struct",
));
}
let name = input.ident;
let mut fields = Vec::new();
for field in &data.fields {
let attributes: Vec<_> = field
.attrs
.iter()
.filter(|attr| attr.path().is_ident("js"))
.collect();
if attributes.is_empty() {
continue;
}
if attributes.len() != 1 || !matches!(attributes[0].meta, Meta::Path(_)) {
return Err(Error::new_spanned(
attributes[0],
"use a single #[js] marker without arguments",
));
}
let supported = if let syn::Type::Path(path) = &field.ty {
path.path.segments.last().is_some_and(|segment| {
if segment.ident != "Signal" { return false; }
let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { return false; };
args.args.len() == 1 && matches!(args.args.first(), Some(syn::GenericArgument::Type(inner)) if supported_js_value(inner))
})
} else {
false
};
if !supported {
return Err(Error::new_spanned(
&field.ty,
"#[js] requires Signal<T>, where T is bool, String, f64, i32, u32, JsValue, or Option/Vec of these; arbitrary structs and 64-bit integers are unsupported",
));
}
let field_name = field.ident.as_ref().unwrap();
let exposed = field_name.to_string().trim_start_matches("r#").to_owned();
fields
.push(quote_spanned! {field.span()=> inputs.add(#exposed, self.#field_name.clone()); });
}
Ok(quote! {
impl #runtime::js::JsInputs for #name {
fn js_inputs(&self) -> #runtime::js::Inputs {
let mut inputs = #runtime::js::Inputs::default();
#(#fields)*
inputs
}
}
})
}