Skip to main content

fusor_macros/
lib.rs

1//! Derives re-exported by `fusor` with its `dom` feature.
2use proc_macro::TokenStream;
3use proc_macro2::Span;
4use quote::{format_ident, quote, quote_spanned};
5use syn::{
6    Data, DeriveInput, Error, Expr, Fields, Meta, Path, Result, fold::Fold, parse_macro_input,
7    parse_quote, spanned::Spanned,
8};
9
10/// Generate a component's named input struct and `FromInputs` implementation.
11/// Each field must be `#[input]` or `#[local(init = expression)]`.
12/// See `fusor::FromInputs` for the application-facing documentation.
13#[proc_macro_derive(FromInputs, attributes(input, local, from_inputs))]
14pub fn derive_from_inputs(input: TokenStream) -> TokenStream {
15    expand(parse_macro_input!(input as DeriveInput))
16        .unwrap_or_else(Error::into_compile_error)
17        .into()
18}
19
20// Self in an input's type means the component, not the generated Inputs struct.
21struct ComponentSelf(syn::Ident);
22impl Fold for ComponentSelf {
23    fn fold_path(&mut self, mut path: Path) -> Path {
24        if path.leading_colon.is_none()
25            && path
26                .segments
27                .first()
28                .is_some_and(|part| part.ident == "Self")
29        {
30            path.segments.first_mut().unwrap().ident = self.0.clone();
31        }
32        syn::fold::fold_path(self, path)
33    }
34}
35
36fn expand(input: DeriveInput) -> Result<proc_macro2::TokenStream> {
37    let mut runtime: Path = parse_quote!(::fusor);
38    let mut custom_crate = false;
39    for attr in &input.attrs {
40        if attr.path().is_ident("input") || attr.path().is_ident("local") {
41            return Err(Error::new_spanned(
42                attr,
43                "place #[input] or #[local(init = ...)] on a field",
44            ));
45        }
46        if attr.path().is_ident("from_inputs") {
47            if custom_crate {
48                return Err(Error::new_spanned(
49                    attr,
50                    "duplicate #[from_inputs] attribute",
51                ));
52            }
53            attr.parse_nested_meta(|meta| {
54                if !meta.path.is_ident("crate") {
55                    return Err(meta.error("expected `crate = path`"));
56                }
57                if custom_crate {
58                    return Err(meta.error("duplicate crate path"));
59                }
60                runtime = meta.value()?.parse()?;
61                custom_crate = true;
62                Ok(())
63            })?;
64            if !custom_crate {
65                return Err(Error::new_spanned(
66                    attr,
67                    "expected #[from_inputs(crate = path)]",
68                ));
69            }
70        }
71    }
72    if !input.generics.params.is_empty() || input.generics.where_clause.is_some() {
73        return Err(Error::new_spanned(
74            &input.generics,
75            "FromInputs derive currently supports concrete structs; implement FromInputs manually for generic components",
76        ));
77    }
78    let Data::Struct(data) = input.data else {
79        return Err(Error::new_spanned(
80            &input.ident,
81            "FromInputs can only be derived for a struct",
82        ));
83    };
84    if matches!(data.fields, Fields::Unnamed(_)) {
85        return Err(Error::new_spanned(
86            data.fields,
87            "FromInputs requires named fields or a unit struct",
88        ));
89    }
90    let name = &input.ident;
91    let visibility = &input.vis;
92    let inputs_name = format_ident!(
93        "{}Inputs",
94        name.to_string().trim_start_matches("r#"),
95        span = name.span()
96    );
97    let inputs_var = syn::Ident::new("__fusor_inputs", Span::mixed_site());
98    let owner_var = syn::Ident::new("__fusor_owner", Span::mixed_site());
99    let mut inputs_fields = Vec::new();
100    let mut values = Vec::new();
101    let mut errors: Option<Error> = None;
102    for field in &data.fields {
103        let field_name = field.ident.as_ref().unwrap();
104        let parsed = (|| {
105            let mut kind: Option<Option<Expr>> = None;
106            for attr in &field.attrs {
107                if attr.path().is_ident("from_inputs") {
108                    return Err(Error::new_spanned(
109                        attr,
110                        "#[from_inputs(crate = path)] belongs on the struct",
111                    ));
112                }
113                if !attr.path().is_ident("input") && !attr.path().is_ident("local") {
114                    continue;
115                }
116                if kind.is_some() {
117                    return Err(Error::new_spanned(
118                        attr,
119                        "choose exactly one #[input] or #[local(init = ...)] per field",
120                    ));
121                }
122                if attr.path().is_ident("input") {
123                    if !matches!(attr.meta, Meta::Path(_)) {
124                        return Err(Error::new_spanned(
125                            attr,
126                            "#[input] takes no arguments; inputs are required and keep their exact Rust type",
127                        ));
128                    }
129                    kind = Some(None);
130                } else {
131                    let mut init = None;
132                    attr.parse_nested_meta(|meta| {
133                        if !meta.path.is_ident("init") {
134                            return Err(meta.error("expected `init = expression`"));
135                        }
136                        if init.is_some() {
137                            return Err(meta.error("duplicate local initializer"));
138                        }
139                        init = Some(meta.value()?.parse::<Expr>()?);
140                        Ok(())
141                    })?;
142                    kind = Some(Some(init.ok_or_else(|| {
143                        Error::new_spanned(attr, "local state requires #[local(init = expression)]")
144                    })?));
145                }
146            }
147            kind.ok_or_else(|| {
148                Error::new_spanned(
149                    field_name,
150                    "field needs #[input] or #[local(init = expression)]; no value is inferred",
151                )
152            })
153        })();
154        match parsed {
155            Ok(None) => {
156                let ty = ComponentSelf(name.clone()).fold_type(field.ty.clone());
157                let docs = field
158                    .attrs
159                    .iter()
160                    .filter(|attr| attr.path().is_ident("doc"));
161                inputs_fields.push(quote_spanned!(field.span()=> #(#docs)* pub #field_name: #ty));
162                values.push(quote_spanned!(field.span()=> #field_name: #inputs_var.#field_name));
163            }
164            Ok(Some(init)) => values.push(quote_spanned!(field.span()=> #field_name: { #init })),
165            Err(error) => match &mut errors {
166                Some(errors) => errors.combine(error),
167                None => errors = Some(error),
168            },
169        }
170    }
171    if let Some(errors) = errors {
172        return Err(errors);
173    }
174    let doc = format!("Parent-supplied inputs generated by `FromInputs` for `{name}`.");
175    let declaration = if inputs_fields.is_empty() {
176        quote!(#[doc = #doc] #visibility struct #inputs_name;)
177    } else {
178        quote!(#[doc = #doc] #visibility struct #inputs_name { #(#inputs_fields,)* })
179    };
180    let construct = if matches!(data.fields, Fields::Unit) {
181        quote!(Self)
182    } else {
183        quote!(Self { #(#values,)* })
184    };
185    Ok(quote! {
186        #declaration
187        impl #runtime::FromInputs for #name {
188            type Inputs = #inputs_name;
189            type Error = ::core::convert::Infallible;
190            fn from_inputs(
191                #inputs_var: Self::Inputs,
192                #owner_var: #runtime::OwnerHandle,
193            ) -> ::core::result::Result<Self, Self::Error> {
194                ::core::result::Result::Ok(#construct)
195            }
196        }
197    })
198}
199
200#[cfg(test)]
201mod tests;
202
203/// Expose only marked `Signal<T>` fields to this component's JavaScript module.
204#[proc_macro_derive(JsInputs, attributes(js, js_inputs))]
205pub fn derive_js_inputs(input: TokenStream) -> TokenStream {
206    expand_js_inputs(parse_macro_input!(input as DeriveInput))
207        .unwrap_or_else(Error::into_compile_error)
208        .into()
209}
210
211fn supported_js_value(ty: &syn::Type) -> bool {
212    let syn::Type::Path(path) = ty else {
213        return false;
214    };
215    let Some(segment) = path.path.segments.last() else {
216        return false;
217    };
218    match segment.ident.to_string().as_str() {
219        "bool" | "String" | "f64" | "i32" | "u32" | "JsValue" => {
220            matches!(segment.arguments, syn::PathArguments::None)
221        }
222        "Option" | "Vec" => {
223            let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
224                return false;
225            };
226            args.args.len() == 1
227                && matches!(args.args.first(), Some(syn::GenericArgument::Type(inner)) if supported_js_value(inner))
228        }
229        _ => false,
230    }
231}
232
233fn expand_js_inputs(input: DeriveInput) -> Result<proc_macro2::TokenStream> {
234    let mut runtime: Path = parse_quote!(::fusor);
235    for attr in &input.attrs {
236        if attr.path().is_ident("js_inputs") {
237            attr.parse_nested_meta(|meta| {
238                if !meta.path.is_ident("crate") {
239                    return Err(meta.error("expected `crate = path`"));
240                }
241                runtime = meta.value()?.parse()?;
242                Ok(())
243            })?;
244        }
245        if attr.path().is_ident("js") {
246            return Err(Error::new_spanned(
247                attr,
248                "place #[js] on an exposed Signal field",
249            ));
250        }
251    }
252    if !input.generics.params.is_empty() || input.generics.where_clause.is_some() {
253        return Err(Error::new_spanned(
254            &input.generics,
255            "JsInputs derive supports concrete structs",
256        ));
257    }
258    let Data::Struct(data) = input.data else {
259        return Err(Error::new_spanned(
260            input.ident,
261            "JsInputs requires a struct",
262        ));
263    };
264    if matches!(data.fields, Fields::Unnamed(_)) {
265        return Err(Error::new_spanned(
266            data.fields,
267            "JsInputs requires named fields or a unit struct",
268        ));
269    }
270    let name = input.ident;
271    let mut fields = Vec::new();
272    for field in &data.fields {
273        let attributes: Vec<_> = field
274            .attrs
275            .iter()
276            .filter(|attr| attr.path().is_ident("js"))
277            .collect();
278        if attributes.is_empty() {
279            continue;
280        }
281        if attributes.len() != 1 || !matches!(attributes[0].meta, Meta::Path(_)) {
282            return Err(Error::new_spanned(
283                attributes[0],
284                "use a single #[js] marker without arguments",
285            ));
286        }
287        let supported = if let syn::Type::Path(path) = &field.ty {
288            path.path.segments.last().is_some_and(|segment| {
289                if segment.ident != "Signal" { return false; }
290                let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { return false; };
291                args.args.len() == 1 && matches!(args.args.first(), Some(syn::GenericArgument::Type(inner)) if supported_js_value(inner))
292            })
293        } else {
294            false
295        };
296        if !supported {
297            return Err(Error::new_spanned(
298                &field.ty,
299                "#[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",
300            ));
301        }
302        let field_name = field.ident.as_ref().unwrap();
303        let exposed = field_name.to_string().trim_start_matches("r#").to_owned();
304        fields
305            .push(quote_spanned! {field.span()=> inputs.add(#exposed, self.#field_name.clone()); });
306    }
307    Ok(quote! {
308        impl #runtime::js::JsInputs for #name {
309            fn js_inputs(&self) -> #runtime::js::Inputs {
310                let mut inputs = #runtime::js::Inputs::default();
311                #(#fields)*
312                inputs
313            }
314        }
315    })
316}