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