Skip to main content

basecoat_core_macros/
lib.rs

1use proc_macro::TokenStream;
2use proc_macro2::TokenStream as TokenStream2;
3use quote::{format_ident, quote};
4use syn::{
5    Data, DeriveInput, Field, Fields, Ident, LitStr, Meta, Type, parse_macro_input,
6    punctuated::Punctuated, token::Comma,
7};
8
9/// `#[derive(BasecoatProps)]` generates a builder for component prop structs.
10///
11/// # Field attributes
12///
13/// - `#[prop(default)]`          — field uses `Default::default()` if not provided in builder.
14/// - `#[prop(default = expr)]`   — field uses `expr` if not provided.
15/// - `#[prop(into)]`             — builder setter accepts `impl Into<FieldType>`.
16/// - `#[prop(optional)]`         — field is `Option<T>`; missing → `None`.
17/// - `#[prop(extend)]`           — marks the `AttrMap` catch-all field (at most one per struct).
18///
19/// # Builder design choice
20///
21/// We use a **runtime-panic builder** for v0.1. A typestate builder would require one
22/// phantom-type parameter per required field and substantially more generated code.
23/// For a UI component library where every field is either optional or has a default,
24/// the extra complexity of typestate adds no practical safety benefit. If a required
25/// field (no `default`/`optional`) is omitted, `build()` panics with a clear message.
26///
27/// # Hydration helper
28///
29/// A `const __BASECOAT_EXTEND_FIELD: Option<&'static str>` associated constant is
30/// emitted on the props struct so the `rsx!` macro (phase 2c) can discover at compile
31/// time where to push unknown HTML attributes.
32#[proc_macro_derive(BasecoatProps, attributes(prop))]
33pub fn derive_basecoat_props(input: TokenStream) -> TokenStream {
34    let input = parse_macro_input!(input as DeriveInput);
35    match derive_impl(input) {
36        Ok(ts) => ts.into(),
37        Err(e) => e.to_compile_error().into(),
38    }
39}
40
41// ── per-field parsed metadata ────────────────────────────────────────────────
42
43struct PropField {
44    ident: Ident,
45    ty: Type,
46    default_expr: Option<TokenStream2>, // None = required, Some(expr) = has default
47    into: bool,
48    extend: bool,
49}
50
51impl PropField {
52    fn from_field(field: &Field) -> syn::Result<Self> {
53        let ident = field
54            .ident
55            .clone()
56            .ok_or_else(|| syn::Error::new_spanned(field, "BasecoatProps requires named fields"))?;
57        let ty = field.ty.clone();
58
59        let mut default_expr: Option<TokenStream2> = None;
60        let mut into = false;
61        let mut extend = false;
62        let mut has_default_marker = false;
63
64        for attr in &field.attrs {
65            if !attr.path().is_ident("prop") {
66                continue;
67            }
68            let nested = attr.parse_args_with(Punctuated::<Meta, Comma>::parse_terminated)?;
69            for meta in nested {
70                match &meta {
71                    Meta::Path(p) if p.is_ident("default") => {
72                        has_default_marker = true;
73                        default_expr = Some(quote! { ::core::default::Default::default() });
74                    }
75                    Meta::NameValue(nv) if nv.path.is_ident("default") => {
76                        has_default_marker = true;
77                        let val = &nv.value;
78                        default_expr = Some(quote! { #val });
79                    }
80                    Meta::Path(p) if p.is_ident("into") => {
81                        into = true;
82                    }
83                    Meta::Path(p) if p.is_ident("optional") => {
84                        if !has_default_marker {
85                            default_expr = Some(quote! { ::core::option::Option::None });
86                        }
87                    }
88                    Meta::Path(p) if p.is_ident("extend") => {
89                        extend = true;
90                        if !has_default_marker {
91                            default_expr = Some(quote! { ::core::default::Default::default() });
92                        }
93                    }
94                    other => {
95                        return Err(syn::Error::new_spanned(
96                            other,
97                            "unknown prop attribute; expected default, into, optional, extend",
98                        ));
99                    }
100                }
101            }
102        }
103
104        Ok(PropField {
105            ident,
106            ty,
107            default_expr,
108            into,
109            extend,
110        })
111    }
112}
113
114// ── code generation ──────────────────────────────────────────────────────────
115
116fn derive_impl(input: DeriveInput) -> syn::Result<TokenStream2> {
117    let struct_ident = &input.ident;
118    let builder_ident = format_ident!("{}Builder", struct_ident);
119
120    let fields = match &input.data {
121        Data::Struct(ds) => match &ds.fields {
122            Fields::Named(f) => &f.named,
123            _ => {
124                return Err(syn::Error::new_spanned(
125                    &input.ident,
126                    "BasecoatProps only supports structs with named fields",
127                ));
128            }
129        },
130        _ => {
131            return Err(syn::Error::new_spanned(
132                &input.ident,
133                "BasecoatProps can only be derived on structs",
134            ));
135        }
136    };
137
138    let prop_fields: Vec<PropField> = fields
139        .iter()
140        .map(PropField::from_field)
141        .collect::<syn::Result<_>>()?;
142
143    // Validate: at most one #[prop(extend)] field
144    let extend_fields: Vec<_> = prop_fields.iter().filter(|f| f.extend).collect();
145    if extend_fields.len() > 1 {
146        return Err(syn::Error::new_spanned(
147            struct_ident,
148            "at most one #[prop(extend)] field is allowed per prop struct",
149        ));
150    }
151    let extend_field_name: Option<LitStr> = extend_fields
152        .first()
153        .map(|f| LitStr::new(&f.ident.to_string(), proc_macro2::Span::call_site()));
154
155    // Pre-collect token streams for each component of the generated code.
156
157    // Builder struct field declarations: `field_name: Option<FieldType>`
158    let builder_field_decls: Vec<TokenStream2> = prop_fields
159        .iter()
160        .map(|f| {
161            let ident = &f.ident;
162            let ty = &f.ty;
163            quote! { #ident: ::core::option::Option<#ty> }
164        })
165        .collect();
166
167    // Builder::new() field initialisers: `field_name: None`
168    let builder_none_inits: Vec<TokenStream2> = prop_fields
169        .iter()
170        .map(|f| {
171            let ident = &f.ident;
172            quote! { #ident: ::core::option::Option::None }
173        })
174        .collect();
175
176    // Setter methods on the builder
177    let setters: Vec<TokenStream2> = prop_fields
178        .iter()
179        .map(|f| {
180            let ident = &f.ident;
181            let ty = &f.ty;
182            if f.into {
183                quote! {
184                    pub fn #ident(mut self, val: impl ::core::convert::Into<#ty>) -> Self {
185                        self.#ident = ::core::option::Option::Some(val.into());
186                        self
187                    }
188                }
189            } else {
190                quote! {
191                    pub fn #ident(mut self, val: #ty) -> Self {
192                        self.#ident = ::core::option::Option::Some(val);
193                        self
194                    }
195                }
196            }
197        })
198        .collect();
199
200    // build() field resolutions
201    let struct_name_str = struct_ident.to_string();
202    let build_fields: Vec<TokenStream2> = prop_fields
203        .iter()
204        .map(|f| {
205            let ident = &f.ident;
206            let field_name_str = ident.to_string();
207            if let Some(default) = &f.default_expr {
208                quote! {
209                    #ident: self.#ident.unwrap_or_else(|| #default)
210                }
211            } else {
212                quote! {
213                    #ident: self.#ident.unwrap_or_else(|| {
214                        panic!(
215                            "required field `{}` was not set on `{}::builder()`",
216                            #field_name_str,
217                            #struct_name_str,
218                        )
219                    })
220                }
221            }
222        })
223        .collect();
224
225    // __BASECOAT_EXTEND_FIELD constant
226    let extend_const = if let Some(name) = &extend_field_name {
227        quote! {
228            pub const __BASECOAT_EXTEND_FIELD: ::core::option::Option<&'static str> =
229                ::core::option::Option::Some(#name);
230        }
231    } else {
232        quote! {
233            pub const __BASECOAT_EXTEND_FIELD: ::core::option::Option<&'static str> =
234                ::core::option::Option::None;
235        }
236    };
237
238    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
239
240    Ok(quote! {
241        impl #impl_generics #struct_ident #ty_generics #where_clause {
242            /// Create a builder for this prop struct.
243            pub fn builder() -> #builder_ident #ty_generics {
244                #builder_ident {
245                    #( #builder_none_inits, )*
246                }
247            }
248
249            #extend_const
250        }
251
252        pub struct #builder_ident #ty_generics #where_clause {
253            #( #builder_field_decls, )*
254        }
255
256        impl #impl_generics #builder_ident #ty_generics #where_clause {
257            #( #setters )*
258
259            pub fn build(self) -> #struct_ident #ty_generics {
260                #struct_ident {
261                    #( #build_fields, )*
262                }
263            }
264        }
265
266        impl #impl_generics ::core::convert::From<#builder_ident #ty_generics>
267            for #struct_ident #ty_generics #where_clause
268        {
269            fn from(b: #builder_ident #ty_generics) -> Self {
270                b.build()
271            }
272        }
273    })
274}