fusor-macros 0.1.1

FromInputs and JsInputs derives for fusor components
Documentation
//! Derives re-exported by `fusor` with its `dom` feature.
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,
};

/// Generate a component's named input struct and `FromInputs` implementation.
/// Each field must be `#[input]` or `#[local(init = expression)]`.
/// See `fusor::FromInputs` for the application-facing documentation.
#[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()
}

// Self in an input's type means the component, not the generated Inputs struct.
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;

/// Expose only marked `Signal<T>` fields to this component's JavaScript module.
#[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
            }
        }
    })
}