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::dom::FromInputs for #name {
188            type Inputs = #inputs_name;
189            fn from_inputs(
190                #inputs_var: Self::Inputs,
191                #owner_var: #runtime::OwnerHandle,
192            ) -> ::core::result::Result<Self, #runtime::dom::JsValue> {
193                ::core::result::Result::Ok(#construct)
194            }
195        }
196    })
197}
198
199#[cfg(test)]
200mod tests;
201
202/// Expose only marked `Signal<T>` fields to this component's JavaScript module.
203#[proc_macro_derive(JsInputs, attributes(js, js_inputs))]
204pub fn derive_js_inputs(input: TokenStream) -> TokenStream {
205    expand_js_inputs(parse_macro_input!(input as DeriveInput))
206        .unwrap_or_else(Error::into_compile_error)
207        .into()
208}
209
210fn supported_js_value(ty: &syn::Type) -> bool {
211    let syn::Type::Path(path) = ty else {
212        return false;
213    };
214    let Some(segment) = path.path.segments.last() else {
215        return false;
216    };
217    match segment.ident.to_string().as_str() {
218        "bool" | "String" | "f64" | "i32" | "u32" | "JsValue" => {
219            matches!(segment.arguments, syn::PathArguments::None)
220        }
221        "Option" | "Vec" => {
222            let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
223                return false;
224            };
225            args.args.len() == 1
226                && matches!(args.args.first(), Some(syn::GenericArgument::Type(inner)) if supported_js_value(inner))
227        }
228        _ => false,
229    }
230}
231
232fn expand_js_inputs(input: DeriveInput) -> Result<proc_macro2::TokenStream> {
233    let mut runtime: Path = parse_quote!(::fusor);
234    for attr in &input.attrs {
235        if attr.path().is_ident("js_inputs") {
236            attr.parse_nested_meta(|meta| {
237                if !meta.path.is_ident("crate") {
238                    return Err(meta.error("expected `crate = path`"));
239                }
240                runtime = meta.value()?.parse()?;
241                Ok(())
242            })?;
243        }
244        if attr.path().is_ident("js") {
245            return Err(Error::new_spanned(
246                attr,
247                "place #[js] on an exposed Signal field",
248            ));
249        }
250    }
251    if !input.generics.params.is_empty() || input.generics.where_clause.is_some() {
252        return Err(Error::new_spanned(
253            &input.generics,
254            "JsInputs derive supports concrete structs",
255        ));
256    }
257    let Data::Struct(data) = input.data else {
258        return Err(Error::new_spanned(
259            input.ident,
260            "JsInputs requires a struct",
261        ));
262    };
263    if matches!(data.fields, Fields::Unnamed(_)) {
264        return Err(Error::new_spanned(
265            data.fields,
266            "JsInputs requires named fields or a unit struct",
267        ));
268    }
269    let name = input.ident;
270    let mut fields = Vec::new();
271    for field in &data.fields {
272        let attributes: Vec<_> = field
273            .attrs
274            .iter()
275            .filter(|attr| attr.path().is_ident("js"))
276            .collect();
277        if attributes.is_empty() {
278            continue;
279        }
280        if attributes.len() != 1 || !matches!(attributes[0].meta, Meta::Path(_)) {
281            return Err(Error::new_spanned(
282                attributes[0],
283                "use a single #[js] marker without arguments",
284            ));
285        }
286        let supported = if let syn::Type::Path(path) = &field.ty {
287            path.path.segments.last().is_some_and(|segment| {
288                if segment.ident != "Signal" { return false; }
289                let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { return false; };
290                args.args.len() == 1 && matches!(args.args.first(), Some(syn::GenericArgument::Type(inner)) if supported_js_value(inner))
291            })
292        } else {
293            false
294        };
295        if !supported {
296            return Err(Error::new_spanned(
297                &field.ty,
298                "#[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",
299            ));
300        }
301        let field_name = field.ident.as_ref().unwrap();
302        let exposed = field_name.to_string().trim_start_matches("r#").to_owned();
303        fields
304            .push(quote_spanned! {field.span()=> inputs.add(#exposed, self.#field_name.clone()); });
305    }
306    Ok(quote! {
307        impl #runtime::js::JsInputs for #name {
308            fn js_inputs(&self) -> #runtime::js::Inputs {
309                let mut inputs = #runtime::js::Inputs::default();
310                #(#fields)*
311                inputs
312            }
313        }
314    })
315}