1use 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}