Skip to main content

native_theme_derive/
lib.rs

1// native-theme-derive: proc-macro crate for ThemeWidget and ThemeFields derives.
2//
3// `ThemeWidget` generates paired Option/Resolved struct hierarchies, FIELD_NAMES,
4// merge/is_empty, validate_widget, and check_ranges from field attributes.
5//
6// `ThemeFields` (Phase 93-05 G5) generates a single `inventory::submit!` call
7// that registers a plain struct's serialized field names in the
8// `crate::resolve::FieldInfo` registry for TOML linting. Replaces the
9// hand-authored `FIELD_NAMES` constants on non-widget model types.
10
11use proc_macro::TokenStream;
12use syn::{DeriveInput, parse_macro_input};
13
14mod gen_inherit;
15mod gen_merge;
16mod gen_ranges;
17mod gen_structs;
18mod gen_validate;
19mod parse;
20
21/// Derive macro that generates a companion Resolved struct and impl blocks
22/// for theme widget types.
23///
24/// # Struct-level attributes
25///
26/// - `#[theme_layer(border_kind = "full"|"partial"|"none")]` -- border validation mode
27/// - `#[theme_layer(resolved_name = "CustomName")]` -- override resolved struct name
28/// - `#[theme_layer(skip_inventory)]` -- skip inventory::submit! for non-per-variant widgets
29/// - `#[theme_inherit(border_kind = "full"|"full_lg"|"partial")]` -- border INHERITANCE mode
30///   (Phase 94-01 G6; parallel to theme_layer.border_kind which drives validation)
31/// - `#[theme_inherit(font = "<field>")]` -- font field that inherits from `defaults.font`
32///   (repeatable: list declares item_font + header_font, dialog declares title_font + body_font)
33///
34/// # Field-level attributes
35///
36/// - `#[theme(category = "option"|"soft_option")]` -- field merge/validation category (default: "option")
37/// - `#[theme(nested, resolved_type = "ResolvedFontSpec")]` -- nested validated type
38/// - `#[theme(check = "non_negative"|"positive")]` -- range check
39/// - `#[theme(range = "0.0..=1.0")]` -- f32 range check
40/// - `#[theme(range_u16 = "100..=900")]` -- u16 range check
41/// - `#[theme(min_max_pair = "other_field")]` -- min/max pair validation
42/// - `#[theme(inherit_from = "defaults.accent_color")]` -- uniform inheritance source from defaults
43#[proc_macro_derive(ThemeWidget, attributes(theme, theme_layer, theme_inherit))]
44pub fn derive_theme_widget(input: TokenStream) -> TokenStream {
45    let input = parse_macro_input!(input as DeriveInput);
46
47    match derive_inner(input) {
48        Ok(tokens) => tokens.into(),
49        Err(err) => err.to_compile_error().into(),
50    }
51}
52
53fn derive_inner(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
54    let opt_name = &input.ident;
55
56    // Parse struct-level attributes (both families, parse orthogonally)
57    let layer = parse::parse_layer_attrs(&input.attrs)?;
58    let inherit_meta = parse::parse_inherit_attrs(&input.attrs)?;
59
60    // Parse field metadata
61    let fields = match &input.data {
62        syn::Data::Struct(data) => parse::parse_fields(&data.fields)?,
63        _ => {
64            return Err(syn::Error::new_spanned(
65                &input.ident,
66                "ThemeWidget can only be derived on structs",
67            ));
68        }
69    };
70
71    // Collect doc attributes from the input struct
72    let doc_attrs: Vec<_> = input
73        .attrs
74        .iter()
75        .filter(|a| a.path().is_ident("doc"))
76        .cloned()
77        .collect();
78
79    // Generate code
80    let structs = gen_structs::gen_structs(opt_name, &fields, &layer, &doc_attrs);
81    let merge = gen_merge::gen_merge(opt_name, &fields);
82    let validate = gen_validate::gen_validate(opt_name, &fields, &layer);
83    let ranges = gen_ranges::gen_ranges(opt_name, &fields, &layer);
84    let inherit = gen_inherit::gen_inherit(opt_name, &fields, &layer);
85    let border_inherit = gen_inherit::gen_border_inherit(opt_name, &inherit_meta);
86    let font_inherit = gen_inherit::gen_font_inherit(opt_name, &inherit_meta);
87    let inventory = gen_inventory_submit(opt_name, &layer);
88
89    Ok(quote::quote! {
90        #structs
91        #merge
92        #validate
93        #ranges
94        #inherit
95        #border_inherit
96        #font_inherit
97        #inventory
98    })
99}
100
101/// Generate `inventory::submit!` call for widget registry.
102///
103/// Derives the widget_name from the struct name by stripping "Theme" suffix
104/// and converting to snake_case (e.g., `ButtonTheme` -> `"button"`,
105/// `SegmentedControlTheme` -> `"segmented_control"`).
106///
107/// Skips generation if the struct has `#[theme_layer(skip_inventory)]`.
108fn gen_inventory_submit(
109    opt_name: &syn::Ident,
110    layer: &parse::LayerMeta,
111) -> proc_macro2::TokenStream {
112    if layer.skip_inventory {
113        return proc_macro2::TokenStream::new();
114    }
115
116    let name_str = opt_name.to_string();
117    let widget_name = to_snake_case(name_str.strip_suffix("Theme").unwrap_or(&name_str));
118
119    quote::quote! {
120        inventory::submit!(crate::resolve::WidgetFieldInfo {
121            widget_name: #widget_name,
122            field_names: #opt_name::FIELD_NAMES,
123        });
124    }
125}
126
127/// Convert a PascalCase identifier to snake_case.
128fn to_snake_case(s: &str) -> String {
129    let mut result = String::with_capacity(s.len().saturating_add(4));
130    for (i, ch) in s.chars().enumerate() {
131        if ch.is_uppercase() && i > 0 {
132            result.push('_');
133        }
134        result.push(ch.to_ascii_lowercase());
135    }
136    result
137}
138
139/// Derive macro that registers a plain struct's serialized field names in the
140/// `crate::resolve::FieldInfo` inventory for TOML linting (Phase 93-05 G5).
141///
142/// By default the macro introspects the struct's fields and honours any
143/// `#[serde(rename = "...")]` attributes (so `corner_radius` annotated with
144/// `#[serde(rename = "corner_radius_px")]` is registered under the wire name).
145///
146/// # Struct-level attributes
147///
148/// - `#[theme_layer(fields = "a, b_px, c")]` -- explicit field-name list.
149///   Use this for serde-proxy structs (like `FontSpec`, which serializes
150///   through a private `FontSpecRaw` proxy) where the user-facing struct's
151///   field names do not match the wire format.
152///
153/// # Example
154///
155/// ```ignore
156/// use native_theme_derive::ThemeFields;
157///
158/// // Introspection path: serde renames picked up automatically.
159/// #[derive(ThemeFields, serde::Serialize, serde::Deserialize)]
160/// pub struct Simple {
161///     pub a: i32,
162///     #[serde(rename = "bar")]
163///     pub b: i32,
164/// }
165/// // Registers { struct_name: "Simple", field_names: &["a", "bar"] }.
166///
167/// // Explicit-override path: used when serde proxy field names differ.
168/// #[derive(ThemeFields, serde::Serialize, serde::Deserialize)]
169/// #[serde(try_from = "HasProxyRaw", into = "HasProxyRaw")]
170/// #[theme_layer(fields = "x, y_px, z")]
171/// pub struct HasProxy { /* ... */ }
172/// ```
173///
174/// This derive emits ONLY an `inventory::submit!` call; it does not emit a
175/// `FIELD_NAMES` associated constant. To also get widget-style codegen
176/// (Resolved pair + merge + validate + `FIELD_NAMES`), combine with
177/// `#[derive(ThemeWidget)]`.
178#[proc_macro_derive(ThemeFields, attributes(theme_layer))]
179pub fn derive_theme_fields(input: TokenStream) -> TokenStream {
180    let input = parse_macro_input!(input as DeriveInput);
181
182    match derive_fields_inner(input) {
183        Ok(tokens) => tokens.into(),
184        Err(err) => err.to_compile_error().into(),
185    }
186}
187
188fn derive_fields_inner(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
189    let struct_name = &input.ident;
190    let layer = parse::parse_layer_attrs(&input.attrs)?;
191
192    let field_names: Vec<String> = if let Some(ref explicit) = layer.explicit_fields {
193        explicit.clone()
194    } else {
195        let data = match &input.data {
196            syn::Data::Struct(s) => s,
197            _ => {
198                return Err(syn::Error::new_spanned(
199                    &input.ident,
200                    "ThemeFields can only be derived on structs",
201                ));
202            }
203        };
204        parse::parse_fields(&data.fields)?
205            .iter()
206            .map(|f| {
207                f.serde_rename
208                    .clone()
209                    .unwrap_or_else(|| f.ident.to_string())
210            })
211            .collect()
212    };
213
214    let struct_name_str = struct_name.to_string();
215    let entries: Vec<proc_macro2::TokenStream> =
216        field_names.iter().map(|n| quote::quote! { #n, }).collect();
217
218    // Emit at item level directly -- mirrors the pattern used by the widget
219    // derive at line 111 above. The inventory registry path
220    // `crate::resolve::FieldInfo` works because this derive is only ever
221    // consumed inside the `native_theme` crate.
222    Ok(quote::quote! {
223        inventory::submit!(crate::resolve::FieldInfo {
224            struct_name: #struct_name_str,
225            field_names: &[#(#entries)*],
226        });
227    })
228}