yew_struct_component_macro/
lib.rs

1//! Define [Yew](https://yew.rs/) components using structs.
2
3extern crate proc_macro;
4
5use proc_macro2::TokenStream;
6use quote::{quote, ToTokens};
7use syn::{
8    parse_macro_input, spanned::Spanned, AttrStyle, Attribute, Data, DeriveInput, Ident, LitBool,
9    LitStr, Meta, Type,
10};
11
12#[derive(Debug, Default)]
13struct StructComponentAttrArgs {
14    tag: Option<String>,
15    dynamic_tag: Option<bool>,
16    no_children: Option<bool>,
17}
18
19fn parse_struct_component_attr(attr: &Attribute) -> Result<StructComponentAttrArgs, syn::Error> {
20    if !matches!(attr.style, AttrStyle::Outer) {
21        Err(syn::Error::new(attr.span(), "not an inner attribute"))
22    } else if let Meta::List(list) = &attr.meta {
23        let mut args = StructComponentAttrArgs::default();
24
25        list.parse_nested_meta(|meta| {
26            if meta.path.is_ident("tag") {
27                let value = meta.value().and_then(|value| value.parse::<LitStr>())?;
28
29                args.tag = Some(value.value());
30
31                Ok(())
32            } else if meta.path.is_ident("dynamic_tag") {
33                let value = meta.value().and_then(|value| value.parse::<LitBool>())?;
34
35                args.dynamic_tag = Some(value.value());
36
37                Ok(())
38            } else if meta.path.is_ident("no_children") {
39                let value = meta.value().and_then(|value| value.parse::<LitBool>())?;
40
41                args.no_children = Some(value.value());
42
43                Ok(())
44            } else {
45                Err(meta.error("unknown property"))
46            }
47        })?;
48
49        Ok(args)
50    } else {
51        Err(syn::Error::new(attr.span(), "not a list"))
52    }
53}
54
55#[proc_macro_attribute]
56pub fn struct_component(
57    _attr: proc_macro::TokenStream,
58    item: proc_macro::TokenStream,
59) -> proc_macro::TokenStream {
60    item
61}
62
63#[proc_macro_derive(StructComponent, attributes(struct_component))]
64pub fn derive_struct_component(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
65    let derive_input = parse_macro_input!(input as DeriveInput);
66
67    let mut args = StructComponentAttrArgs::default();
68    for attr in &derive_input.attrs {
69        if attr.path().is_ident("struct_component") {
70            match parse_struct_component_attr(attr) {
71                Ok(result) => {
72                    args = result;
73                }
74                Err(error) => {
75                    return error.to_compile_error().into();
76                }
77            }
78        }
79    }
80
81    if let Data::Struct(data_struct) = &derive_input.data {
82        let ident = derive_input.ident.clone();
83
84        let mut attributes: Vec<TokenStream> = vec![];
85        let mut attribute_checked: Option<TokenStream> = None;
86        let mut attribute_value: Option<TokenStream> = None;
87        let mut listeners: Vec<Ident> = vec![];
88        let mut attributes_map: Option<TokenStream> = None;
89        let mut tag: Option<TokenStream> = None;
90        let mut node_ref: Option<TokenStream> = None;
91
92        for field in &data_struct.fields {
93            if let Some(ident) = &field.ident {
94                if let Some(attr) = field
95                    .attrs
96                    .iter()
97                    .find(|attr| attr.path().is_ident("struct_component"))
98                {
99                    match parse_struct_component_attr(attr) {
100                        Ok(args) => {
101                            if args.dynamic_tag.is_some_and(|dynamic_tag| dynamic_tag) {
102                                tag = Some(quote! {
103                                    self.#ident.to_string()
104                                });
105
106                                continue;
107                            }
108                        }
109                        Err(error) => {
110                            return error.to_compile_error().into();
111                        }
112                    }
113                }
114
115                if ident == "attributes" {
116                    attributes_map = Some(quote! {
117                        .chain(
118                            self.attributes
119                                .into_iter()
120                                .flatten()
121                                .flat_map(|(key, value)| value.map(|value| (
122                                    ::yew::virtual_dom::AttrValue::from(key),
123                                    ::yew::virtual_dom::AttributeOrProperty::Attribute(AttrValue::from(value)),
124                                )),
125                            ),
126                        )
127                    });
128
129                    continue;
130                }
131
132                if ident == "node_ref" {
133                    node_ref = Some(quote! {
134                        tag.node_ref = self.node_ref;
135                    });
136
137                    continue;
138                }
139
140                if ident.to_string().starts_with("on") {
141                    if let Type::Path(path) = &field.ty {
142                        let first = path.path.segments.first();
143                        if first.is_some_and(|segment| segment.ident == "Callback") {
144                            listeners.push(ident.clone());
145
146                            continue;
147                        }
148                    }
149                }
150
151                if ident == "checked" {
152                    attribute_checked = Some(quote! {
153                        tag.set_checked(self.checked);
154                    });
155                }
156
157                if ident == "value" {
158                    attribute_value = Some(quote! {
159                        tag.set_value(self.value.clone());
160                    });
161                }
162
163                match &field.ty {
164                    Type::Path(path) => {
165                        let name = ident.to_string().replace("_", "-");
166                        let name = if name.starts_with("r#") {
167                            name.strip_prefix("r#").expect("String should have prefix.")
168                        } else {
169                            name.as_str()
170                        }
171                        .to_token_stream();
172
173                        let first = path.path.segments.first();
174
175                        attributes.push(if first.is_some_and(|segment| segment.ident == "bool") {
176                            quote! {
177                                self.#ident.then_some((
178                                    ::yew::virtual_dom::AttrValue::from(#name),
179                                    ::yew::virtual_dom::AttributeOrProperty::Attribute(
180                                        ::yew::virtual_dom::AttrValue::from("")
181                                    ),
182                                ))
183                            }
184                        } else if first.is_some_and(|segment| segment.ident == "AttrValue") {
185                            quote! {
186                                Some((
187                                    ::yew::virtual_dom::AttrValue::from(#name),
188                                    ::yew::virtual_dom::AttributeOrProperty::Attribute(self.#ident),
189                                ))
190                            }
191                        } else if first.is_some_and(|segment| segment.ident == "Option") {
192                            quote! {
193                                self.#ident.map(|value| (
194                                    ::yew::virtual_dom::AttrValue::from(#name),
195                                    ::yew::virtual_dom::AttributeOrProperty::Attribute(
196                                        ::yew::virtual_dom::AttrValue::from(value)
197                                    ),
198                                ))
199                            }
200                        } else if first.is_some_and(|segment| segment.ident == "Style") {
201                            quote! {
202                                self.#ident.as_ref().map(|value| (
203                                    ::yew::virtual_dom::AttrValue::from(#name),
204                                    ::yew::virtual_dom::AttributeOrProperty::Attribute(
205                                        ::yew::virtual_dom::AttrValue::from(value)
206                                    ),
207                                ))
208                            }
209                        } else {
210                            quote! {
211                                Some((
212                                    ::yew::virtual_dom::AttrValue::from(#name),
213                                    ::yew::virtual_dom::AttributeOrProperty::Attribute(
214                                        ::yew::virtual_dom::AttrValue::from(self.#ident)
215                                    ),
216                                ))
217                            }
218                        });
219                    }
220                    _ => {
221                        return syn::Error::new(field.ty.span(), "expected type path")
222                            .to_compile_error()
223                            .into()
224                    }
225                }
226            }
227        }
228
229        let tag = if let Some(tag) =
230            tag.or_else(|| args.tag.map(|tag| tag.as_str().to_token_stream()))
231        {
232            tag
233        } else {
234            return syn::Error::new(derive_input.span(), "`#[struct_component(tag = \"\")] or #[struct_component(dynamic_tag = true)]` is required")
235                    .to_compile_error()
236                    .into();
237        };
238
239        let arguments = if args.no_children.unwrap_or(false) {
240            quote! {
241                self
242            }
243        } else {
244            quote! {
245                self, children: ::yew::prelude::Html
246            }
247        };
248
249        let children = (!args.no_children.unwrap_or(false)).then(|| {
250            quote! {
251                tag.add_child(children);
252            }
253        });
254
255        quote! {
256            impl #ident {
257                pub fn render(#arguments) -> ::yew::prelude::Html {
258                    let mut tag = ::yew::virtual_dom::VTag::new(#tag);
259                    #node_ref
260
261                    #attribute_checked
262                    #attribute_value
263                    tag.set_attributes(::yew::virtual_dom::Attributes::IndexMap(
264                        ::std::rc::Rc::new(
265                            [
266                                #(#attributes,)*
267                            ]
268                            .into_iter()
269                            .flatten()
270                            #attributes_map
271                            .collect(),
272                        ),
273                    ));
274
275                    tag.set_listeners(::std::boxed::Box::new([
276                        #(::yew::html::#listeners::Wrapper::__macro_new(
277                            self.#listeners,
278                        ),)*
279                    ]));
280
281                    #children
282
283                    tag.into()
284                }
285            }
286        }
287        .into()
288    } else {
289        syn::Error::new(derive_input.span(), "expected struct")
290            .to_compile_error()
291            .into()
292    }
293}