1use 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#[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
20struct 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#[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}