Skip to main content

tomlmenu_derive/
lib.rs

1//! `Schema` derive — emits a static `MenuNode` tree describing the
2//! annotated type's editable surface.
3//!
4//! Output paths resolve as `::tomlmenu::*`. The derive is therefore valid
5//! in any crate whose dependency graph includes `tomlmenu`.
6//!
7//! Attribute vocabulary:
8//!
9//! * `#[tomlmenu(label = "...")]` — display name; optional, defaults to the
10//!   serde-resolved field name.
11//! * `#[tomlmenu(help = "...")]`  — help-pane text; optional, defaults to
12//!   `"<no help>"`.
13//! * `#[tomlmenu(skip)]`          — exclude from menu; serde behaviour
14//!   unchanged.
15//!
16//! Field type classification:
17//!
18//! * `bool` / `Option<bool>` → `LeafKind::Bool`.
19//! * Primitive unsigned int (u8..u64) or its `Option<...>` wrapper →
20//!   `LeafKind::UInt { min: 0, max: T::MAX as u64 }`.
21//! * `String` / `Option<String>` → `LeafKind::Text`.
22//! * Enum-typed field → recurses into the enum's own `Schema` impl,
23//!   yielding `LeafKind::Choice(&[variants...])` at emission.
24//! * `Vec<...>` (outer) and `Option<Vec<...>>` (inner) → reject with
25//!   `compile_error!` unless `#[tomlmenu(skip)]` is set.
26
27use proc_macro::TokenStream;
28use proc_macro2::TokenStream as TokenStream2;
29use quote::quote;
30use syn::{
31    Attribute, Data, DataEnum, DataStruct, DeriveInput, Expr, ExprLit, Field, Fields,
32    GenericArgument, Lit, LitStr, Meta, PathArguments, Type, TypePath, parse_macro_input,
33    spanned::Spanned,
34};
35
36#[proc_macro_derive(Schema, attributes(tomlmenu))]
37pub fn derive_schema(input: TokenStream) -> TokenStream {
38    let input = parse_macro_input!(input as DeriveInput);
39    expand(&input)
40        .unwrap_or_else(|err| err.to_compile_error())
41        .into()
42}
43
44fn expand(input: &DeriveInput) -> syn::Result<TokenStream2> {
45    let ty = &input.ident;
46    let serde_rename_all = read_serde_rename_all(&input.attrs)?;
47
48    match &input.data {
49        Data::Struct(s) => expand_struct(s, ty, serde_rename_all.as_deref()),
50        Data::Enum(e) => expand_enum(e, ty, serde_rename_all.as_deref()),
51        Data::Union(u) => Err(syn::Error::new(
52            u.union_token.span,
53            "Schema cannot be derived on a union",
54        )),
55    }
56}
57
58fn expand_struct(
59    s: &DataStruct,
60    ty: &syn::Ident,
61    _rename_all: Option<&str>,
62) -> syn::Result<TokenStream2> {
63    let Fields::Named(named) = &s.fields else {
64        return Err(syn::Error::new(
65            s.fields.span(),
66            "Schema requires named struct fields",
67        ));
68    };
69
70    let mut child_inits: Vec<TokenStream2> = Vec::new();
71    for field in &named.named {
72        let attrs = read_attrs(&field.attrs)?;
73        if attrs.skip {
74            continue;
75        }
76
77        let field_ident = field.ident.as_ref().expect("named field");
78        let serde_field_key =
79            read_serde_rename(&field.attrs)?.unwrap_or_else(|| field_ident.to_string());
80
81        let leaf_classification = classify_leaf(&field.ty, &attrs, field)?;
82
83        let label = attrs
84            .label
85            .clone()
86            .unwrap_or_else(|| serde_field_key.clone());
87        let help = attrs
88            .help
89            .clone()
90            .unwrap_or_else(|| "<no help>".to_string());
91        let key = serde_field_key;
92
93        let body_tokens: TokenStream2 = match leaf_classification {
94            LeafClass::Bool => quote! {
95                ::tomlmenu::MenuBody::Leaf(
96                    ::tomlmenu::LeafKind::Bool,
97                )
98            },
99            LeafClass::UInt(uint_ty) => {
100                let primitive: syn::Ident = syn::Ident::new(&uint_ty, field.ty.span());
101                quote! {
102                    ::tomlmenu::MenuBody::Leaf(
103                        ::tomlmenu::LeafKind::UInt {
104                            min: 0,
105                            max: <#primitive>::MAX as u64,
106                        },
107                    )
108                }
109            }
110            LeafClass::Text => quote! {
111                ::tomlmenu::MenuBody::Leaf(
112                    ::tomlmenu::LeafKind::Text,
113                )
114            },
115            LeafClass::Nested(ty_tokens) => {
116                quote! {
117                    *<#ty_tokens as ::tomlmenu::Schema>::SCHEMA_BODY
118                }
119            }
120        };
121
122        child_inits.push(quote! {
123            ::tomlmenu::MenuNode {
124                label: #label,
125                help: #help,
126                key: #key,
127                body: #body_tokens,
128            }
129        });
130    }
131
132    let children_static = syn::Ident::new(&format!("__TOMLMENU_SCHEMA_CHILDREN_{}", ty), ty.span());
133    let body_static = syn::Ident::new(&format!("__TOMLMENU_SCHEMA_BODY_{}", ty), ty.span());
134
135    Ok(quote! {
136        #[allow(non_upper_case_globals)]
137        static #children_static: &[::tomlmenu::MenuNode] = &[
138            #(#child_inits,)*
139        ];
140
141        #[allow(non_upper_case_globals)]
142        static #body_static: ::tomlmenu::MenuBody =
143            ::tomlmenu::MenuBody::Section(#children_static);
144
145        impl ::tomlmenu::Schema for #ty {
146            const SCHEMA_BODY: &'static ::tomlmenu::MenuBody = &#body_static;
147
148            fn menu_schema() -> ::tomlmenu::MenuNode {
149                ::tomlmenu::MenuNode {
150                    label: "",
151                    help: "",
152                    key: "",
153                    body: ::tomlmenu::MenuBody::Section(#children_static),
154                }
155            }
156        }
157    })
158}
159
160fn expand_enum(
161    e: &DataEnum,
162    ty: &syn::Ident,
163    rename_all: Option<&str>,
164) -> syn::Result<TokenStream2> {
165    let mut variant_names: Vec<String> = Vec::new();
166    for v in &e.variants {
167        if !matches!(v.fields, Fields::Unit) {
168            return Err(syn::Error::new(
169                v.span(),
170                "Schema enum variants must be unit-style",
171            ));
172        }
173        let variant_rename = read_serde_rename(&v.attrs)?;
174        let resolved = match variant_rename {
175            Some(name) => name,
176            None => apply_rename_all(&v.ident.to_string(), rename_all),
177        };
178        variant_names.push(resolved);
179    }
180
181    let lit_names_for_variants: Vec<TokenStream2> =
182        variant_names.iter().map(|n| quote! { #n }).collect();
183    let lit_names_for_method: Vec<TokenStream2> =
184        variant_names.iter().map(|n| quote! { #n }).collect();
185
186    let variants_static = syn::Ident::new(&format!("__TOMLMENU_SCHEMA_VARIANTS_{}", ty), ty.span());
187    let body_static = syn::Ident::new(&format!("__TOMLMENU_SCHEMA_BODY_{}", ty), ty.span());
188
189    Ok(quote! {
190        #[allow(non_upper_case_globals)]
191        static #variants_static: &[&'static str] = &[
192            #(#lit_names_for_variants,)*
193        ];
194
195        #[allow(non_upper_case_globals)]
196        static #body_static: ::tomlmenu::MenuBody =
197            ::tomlmenu::MenuBody::Leaf(
198                ::tomlmenu::LeafKind::Choice(#variants_static),
199            );
200
201        impl ::tomlmenu::Schema for #ty {
202            const SCHEMA_BODY: &'static ::tomlmenu::MenuBody = &#body_static;
203
204            fn menu_schema() -> ::tomlmenu::MenuNode {
205                ::tomlmenu::MenuNode {
206                    label: "",
207                    help: "",
208                    key: "",
209                    body: ::tomlmenu::MenuBody::Leaf(
210                        ::tomlmenu::LeafKind::Choice(&[
211                            #(#lit_names_for_method,)*
212                        ]),
213                    ),
214                }
215            }
216        }
217    })
218}
219
220#[derive(Default)]
221struct TomlmenuAttrs {
222    label: Option<String>,
223    help: Option<String>,
224    skip: bool,
225}
226
227fn read_attrs(attrs: &[Attribute]) -> syn::Result<TomlmenuAttrs> {
228    let mut out = TomlmenuAttrs::default();
229    for attr in attrs {
230        if !attr.path().is_ident("tomlmenu") {
231            continue;
232        }
233        attr.parse_nested_meta(|meta| {
234            if meta.path.is_ident("skip") {
235                out.skip = true;
236                return Ok(());
237            }
238            if meta.path.is_ident("label") {
239                let value: LitStr = meta.value()?.parse()?;
240                out.label = Some(value.value());
241                return Ok(());
242            }
243            if meta.path.is_ident("help") {
244                let value: LitStr = meta.value()?.parse()?;
245                out.help = Some(value.value());
246                return Ok(());
247            }
248            let ident = meta
249                .path
250                .get_ident()
251                .map(|i| i.to_string())
252                .unwrap_or_else(|| "<unknown>".to_string());
253            Err(meta.error(format!("unknown #[tomlmenu(...)] key `{ident}`")))
254        })?;
255    }
256    Ok(out)
257}
258
259fn read_serde_rename(attrs: &[Attribute]) -> syn::Result<Option<String>> {
260    for attr in attrs {
261        if !attr.path().is_ident("serde") {
262            continue;
263        }
264        let mut found = None;
265        let _ = attr.parse_nested_meta(|meta| {
266            if meta.path.is_ident("rename") {
267                let value: LitStr = meta.value()?.parse()?;
268                found = Some(value.value());
269            }
270            Ok(())
271        });
272        if found.is_some() {
273            return Ok(found);
274        }
275    }
276    Ok(None)
277}
278
279fn read_serde_rename_all(attrs: &[Attribute]) -> syn::Result<Option<String>> {
280    for attr in attrs {
281        if !attr.path().is_ident("serde") {
282            continue;
283        }
284        let mut found = None;
285        let _ = attr.parse_nested_meta(|meta| {
286            if meta.path.is_ident("rename_all") {
287                let value: LitStr = meta.value()?.parse()?;
288                found = Some(value.value());
289            }
290            Ok(())
291        });
292        if found.is_some() {
293            return Ok(found);
294        }
295        if let Meta::List(_) = &attr.meta {
296            continue;
297        }
298        if let Meta::NameValue(nv) = &attr.meta
299            && nv.path.is_ident("rename_all")
300            && let Expr::Lit(ExprLit {
301                lit: Lit::Str(s), ..
302            }) = &nv.value
303        {
304            return Ok(Some(s.value()));
305        }
306    }
307    Ok(None)
308}
309
310fn apply_rename_all(name: &str, rename_all: Option<&str>) -> String {
311    match rename_all {
312        Some("lowercase") => name.to_ascii_lowercase(),
313        Some("UPPERCASE") => name.to_ascii_uppercase(),
314        Some("snake_case") => camel_to_snake(name),
315        Some("kebab-case") => camel_to_snake(name).replace('_', "-"),
316        Some("SCREAMING_SNAKE_CASE") => camel_to_snake(name).to_ascii_uppercase(),
317        _ => name.to_string(),
318    }
319}
320
321fn camel_to_snake(name: &str) -> String {
322    let mut out = String::with_capacity(name.len() + 4);
323    for (i, ch) in name.chars().enumerate() {
324        if ch.is_ascii_uppercase() {
325            if i != 0 {
326                out.push('_');
327            }
328            out.push(ch.to_ascii_lowercase());
329        } else {
330            out.push(ch);
331        }
332    }
333    out
334}
335
336enum LeafClass {
337    Bool,
338    UInt(String),
339    Text,
340    Nested(TokenStream2),
341}
342
343fn classify_leaf(ty: &Type, attrs: &TomlmenuAttrs, field: &Field) -> syn::Result<LeafClass> {
344    let (peeled, was_option) = match peel_option(ty) {
345        Some(inner) => (inner, true),
346        None => (ty, false),
347    };
348
349    if is_vec(peeled) {
350        let span = if was_option { ty.span() } else { peeled.span() };
351        return Err(syn::Error::new(
352            span,
353            "Schema: `Vec<...>` and `Option<Vec<...>>` fields must carry `#[tomlmenu(skip)]`",
354        ));
355    }
356    let _ = attrs;
357    let _ = field;
358
359    if is_named_simple(peeled, "bool") {
360        return Ok(LeafClass::Bool);
361    }
362    if let Some(uint_name) = match_uint_primitive(peeled) {
363        return Ok(LeafClass::UInt(uint_name));
364    }
365    if is_named_simple(peeled, "String") {
366        return Ok(LeafClass::Text);
367    }
368    let path = match peeled {
369        Type::Path(TypePath { path, qself: None }) => path,
370        _ => {
371            return Err(syn::Error::new(
372                peeled.span(),
373                "Schema: unsupported field type",
374            ));
375        }
376    };
377    Ok(LeafClass::Nested(quote! { #path }))
378}
379
380fn peel_option(ty: &Type) -> Option<&Type> {
381    let Type::Path(TypePath { path, qself: None }) = ty else {
382        return None;
383    };
384    let seg = path.segments.last()?;
385    if seg.ident != "Option" {
386        return None;
387    }
388    let PathArguments::AngleBracketed(args) = &seg.arguments else {
389        return None;
390    };
391    let arg = args.args.first()?;
392    if let GenericArgument::Type(inner) = arg {
393        Some(inner)
394    } else {
395        None
396    }
397}
398
399fn is_vec(ty: &Type) -> bool {
400    let Type::Path(TypePath { path, qself: None }) = ty else {
401        return false;
402    };
403    path.segments
404        .last()
405        .map(|s| s.ident == "Vec")
406        .unwrap_or(false)
407}
408
409fn is_named_simple(ty: &Type, name: &str) -> bool {
410    let Type::Path(TypePath { path, qself: None }) = ty else {
411        return false;
412    };
413    let Some(seg) = path.segments.last() else {
414        return false;
415    };
416    seg.ident == name && matches!(seg.arguments, PathArguments::None)
417}
418
419fn match_uint_primitive(ty: &Type) -> Option<String> {
420    const PRIMS: &[&str] = &["u8", "u16", "u32", "u64", "usize"];
421    let Type::Path(TypePath { path, qself: None }) = ty else {
422        return None;
423    };
424    let seg = path.segments.last()?;
425    if !matches!(seg.arguments, PathArguments::None) {
426        return None;
427    }
428    let name = seg.ident.to_string();
429    PRIMS.iter().find(|p| **p == name).map(|s| s.to_string())
430}
431
432#[cfg(test)]
433mod tests {
434    use quote::quote;
435
436    use super::*;
437
438    fn parse(tokens: TokenStream2) -> DeriveInput {
439        syn::parse2(tokens).expect("fixture parses as DeriveInput")
440    }
441
442    fn expand_ok(tokens: TokenStream2) -> String {
443        expand(&parse(tokens))
444            .expect("expand returns Ok for a valid input")
445            .to_string()
446    }
447
448    fn expand_err(tokens: TokenStream2) -> String {
449        match expand(&parse(tokens)) {
450            Ok(_) => panic!("expected expand to error for this input"),
451            Err(e) => e.to_string(),
452        }
453    }
454
455    #[test]
456    fn rejects_unknown_attribute_key() {
457        let msg = expand_err(quote! {
458            struct Bad {
459                #[tomlmenu(bogus_key = "oops")]
460                field: Option<bool>,
461            }
462        });
463        assert!(
464            msg.contains("unknown #[tomlmenu(...)] key `bogus_key`"),
465            "unexpected error message: {msg}"
466        );
467    }
468
469    #[test]
470    fn rejects_bare_vec_field_without_skip() {
471        let msg = expand_err(quote! {
472            struct Bad {
473                extras: Vec<String>,
474            }
475        });
476        assert!(
477            msg.contains("must carry `#[tomlmenu(skip)]`"),
478            "unexpected error message: {msg}"
479        );
480    }
481
482    #[test]
483    fn rejects_option_vec_field_without_skip() {
484        let msg = expand_err(quote! {
485            struct Bad {
486                extras: Option<Vec<String>>,
487            }
488        });
489        assert!(
490            msg.contains("must carry `#[tomlmenu(skip)]`"),
491            "unexpected error message: {msg}"
492        );
493    }
494
495    #[test]
496    fn rejects_union() {
497        let msg = expand_err(quote! {
498            union Bad {
499                a: u32,
500                b: u64,
501            }
502        });
503        assert!(msg.contains("union"), "unexpected error message: {msg}");
504    }
505
506    #[test]
507    fn rejects_enum_with_non_unit_variant() {
508        let msg = expand_err(quote! {
509            enum Bad {
510                Unit,
511                Tuple(u32),
512            }
513        });
514        assert!(
515            msg.contains("unit-style"),
516            "unexpected error message: {msg}"
517        );
518    }
519
520    #[test]
521    fn accepts_skipped_vec_field() {
522        expand_ok(quote! {
523            struct Ok {
524                #[tomlmenu(skip)]
525                extras: Vec<String>,
526                name: Option<String>,
527            }
528        });
529    }
530
531    #[test]
532    fn struct_expansion_targets_tomlmenu_path() {
533        let out = expand_ok(quote! {
534            struct Sample {
535                name: Option<String>,
536            }
537        });
538        assert!(
539            out.contains(":: tomlmenu :: MenuNode"),
540            "emitted path not absolute into tomlmenu: {out}"
541        );
542    }
543
544    #[test]
545    fn enum_expansion_lists_variants_in_choice() {
546        let out = expand_ok(quote! {
547            #[serde(rename_all = "lowercase")]
548            enum Bus { Mmio, Pci }
549        });
550        assert!(out.contains("\"mmio\""), "missing mmio in: {out}");
551        assert!(out.contains("\"pci\""), "missing pci in: {out}");
552        assert!(out.contains("Choice"), "missing Choice variant: {out}");
553    }
554
555    #[test]
556    fn struct_field_uses_serde_renamed_key() {
557        let out = expand_ok(quote! {
558            struct Sample {
559                #[serde(rename = "kernel_base")]
560                kernel_base: Option<String>,
561            }
562        });
563        assert!(
564            out.contains("\"kernel_base\""),
565            "serde-renamed key not honoured: {out}"
566        );
567    }
568
569    #[test]
570    fn uint_field_emits_type_max_bound() {
571        let out = expand_ok(quote! {
572            struct Sample {
573                port: Option<u16>,
574            }
575        });
576        assert!(out.contains("u16"), "u16 primitive token missing: {out}");
577        assert!(out.contains("MAX"), "MAX bound missing: {out}");
578    }
579
580    #[test]
581    fn skipped_field_does_not_appear_in_output() {
582        let out = expand_ok(quote! {
583            struct Sample {
584                #[tomlmenu(skip)]
585                features: Option<Vec<String>>,
586                name: Option<String>,
587            }
588        });
589        assert!(
590            !out.contains("\"features\""),
591            "skipped field leaked into output: {out}"
592        );
593        assert!(out.contains("\"name\""), "non-skipped field missing: {out}");
594    }
595}