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}