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 && 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#[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}