1use proc_macro::TokenStream;
96use quote::quote;
97use syn::{Attribute, Data, DeriveInput, Expr, Field, Fields, Lit, Meta, Type, parse_macro_input};
98
99#[proc_macro_derive(SettingsSchema, attributes(schema, setting))]
101pub fn derive_settings_schema(input: TokenStream) -> TokenStream {
102 let input = parse_macro_input!(input as DeriveInput);
103
104 match derive_settings_schema_impl(&input) {
105 Ok(expanded) => TokenStream::from(expanded),
106 Err(err) => TokenStream::from(err.to_compile_error()),
107 }
108}
109
110fn derive_settings_schema_impl(
111 input: &DeriveInput,
112) -> Result<proc_macro2::TokenStream, syn::Error> {
113 let name = &input.ident;
114 let container_attrs = parse_container_attrs(&input.attrs)?;
115
116 let fields = match &input.data {
117 Data::Struct(data) => match &data.fields {
118 Fields::Named(fields) => {
119 if fields.named.is_empty() {
120 return Err(syn::Error::new_spanned(
121 input,
122 "SettingsSchema can only be derived for structs with named fields",
123 ));
124 }
125 &fields.named
126 }
127 _ => {
128 return Err(syn::Error::new_spanned(
129 input,
130 "SettingsSchema can only be derived for structs with named fields",
131 ));
132 }
133 },
134 _ => {
135 return Err(syn::Error::new_spanned(
136 input,
137 "SettingsSchema can only be derived for structs, not enums or unions",
138 ));
139 }
140 };
141
142 let mut metadata_entries = Vec::new();
143 let mut errors = None::<syn::Error>;
144
145 for field in fields {
146 match process_single_field(field, &container_attrs) {
147 Ok(Some(entry)) => metadata_entries.push(entry),
148 Ok(None) => {} Err(e) => {
150 if let Some(ref mut combined) = errors {
151 combined.combine(e);
152 } else {
153 errors = Some(e);
154 }
155 }
156 }
157 }
158
159 if let Some(err) = errors {
160 return Err(err);
161 }
162
163 Ok(quote! {
164 impl rcman::SettingsSchema for #name {
165 fn get_metadata() -> std::collections::HashMap<String, rcman::SettingMetadata> {
166 let defaults = <#name as Default>::default();
167 let mut map = std::collections::HashMap::new();
168 #(#metadata_entries)*
169 map
170 }
171 }
172 })
173}
174
175fn process_single_field(
176 field: &Field,
177 container_attrs: &ContainerAttrs,
178) -> Result<Option<proc_macro2::TokenStream>, syn::Error> {
179 let attrs = parse_field_attrs(&field.attrs)?;
180 if attrs.skip {
181 return Ok(None);
182 }
183
184 let mut cfg_attrs = Vec::new();
185 for attr in &field.attrs {
186 if attr.path().is_ident("cfg") {
187 cfg_attrs.push(attr);
188 }
189 }
190
191 let entry = process_field(field, &attrs, container_attrs)?;
192
193 if cfg_attrs.is_empty() {
194 Ok(Some(entry))
195 } else {
196 Ok(Some(quote! {
197 #(#cfg_attrs)*
198 {
199 #entry
200 }
201 }))
202 }
203}
204
205fn process_field(
206 field: &Field,
207 attrs: &FieldAttrs,
208 container_attrs: &ContainerAttrs,
209) -> Result<proc_macro2::TokenStream, syn::Error> {
210 let Some(field_name) = &field.ident else {
211 return Err(syn::Error::new_spanned(
212 field,
213 "Field must have a name (internal error: expected named field)",
214 ));
215 };
216 let field_type = &field.ty;
217
218 if attrs.nested || is_nested_struct(field_type) {
220 return Ok(generate_nested_field_constructor(field_name, field_type));
221 }
222
223 let inner_ty = extract_inner_type_from_option(field_type).unwrap_or(field_type);
224 let type_info = classify_type(inner_ty);
225
226 if let TypeInfo::Unknown = type_info {
228 return Err(syn::Error::new_spanned(
229 field_type,
230 "Unsupported type for SettingsSchema. Use `#[setting(skip)]` to ignore it, or `#[setting(nested)]` if it is a custom schema struct.",
231 ));
232 }
233
234 validate_field_type_constraints(field, type_info, attrs)?;
235
236 let category_str = resolve_field_category(field, attrs, container_attrs)?;
237 let final_field_name = attrs
238 .rename
239 .clone()
240 .unwrap_or_else(|| field_name.to_string());
241
242 let key = if category_str.is_empty() {
243 final_field_name.clone()
244 } else {
245 format!("{category_str}.{final_field_name}")
246 };
247
248 let constructor = generate_field_constructor(field_name, field_type, type_info, attrs);
249 let modifiers = generate_field_modifiers(attrs);
250
251 Ok(quote! {
252 map.insert(
253 #key.to_string(),
254 { #constructor } #(#modifiers)*
255 );
256 })
257}
258
259fn generate_nested_field_constructor(
260 field_name: &syn::Ident,
261 field_type: &syn::Type,
262) -> proc_macro2::TokenStream {
263 let prefix = field_name.to_string();
264 quote! {
265 for (key, meta) in <#field_type as rcman::SettingsSchema>::get_metadata() {
268 let field_only = key.rsplit('.').next().unwrap_or(&key);
270 let prefixed_key = format!("{}.{}", #prefix, field_only);
271 map.insert(prefixed_key, meta);
273 }
274 }
275}
276
277fn validate_field_type_constraints(
278 field: &Field,
279 type_info: TypeInfo,
280 attrs: &FieldAttrs,
281) -> Result<(), syn::Error> {
282 if let (Some(min), Some(max)) = (attrs.min, attrs.max)
284 && min > max
285 {
286 return Err(syn::Error::new_spanned(
287 field,
288 format!("`min` ({min}) cannot be greater than `max` ({max})"),
289 ));
290 }
291
292 if let Some(step) = attrs.step
293 && step <= 0.0
294 {
295 return Err(syn::Error::new_spanned(
296 field,
297 format!("`step` must be positive, got {step}"),
298 ));
299 }
300
301 match type_info {
302 TypeInfo::Number => {
303 if attrs.pattern.is_some() {
304 return Err(syn::Error::new_spanned(
305 field,
306 "`pattern` is only valid for text settings, not numbers",
307 ));
308 }
309 }
310 TypeInfo::Text | TypeInfo::Path => {
311 if attrs.min.is_some() || attrs.max.is_some() || attrs.step.is_some() {
312 return Err(syn::Error::new_spanned(
313 field,
314 "`min/max/step` are only valid for numeric settings, not text",
315 ));
316 }
317 }
318 TypeInfo::Toggle => {
319 if attrs.min.is_some() || attrs.max.is_some() || attrs.step.is_some() {
320 return Err(syn::Error::new_spanned(
321 field,
322 "`min/max/step` are only valid for numeric settings, not booleans",
323 ));
324 }
325 if attrs.pattern.is_some() {
326 return Err(syn::Error::new_spanned(
327 field,
328 "`pattern` is only valid for text settings, not booleans",
329 ));
330 }
331 if !attrs.options.is_empty() {
332 return Err(syn::Error::new_spanned(
333 field,
334 "`options` are only valid for text/number settings, not booleans",
335 ));
336 }
337 }
338 TypeInfo::List => {
339 if attrs.min.is_some() || attrs.max.is_some() || attrs.step.is_some() {
340 return Err(syn::Error::new_spanned(
341 field,
342 "`min/max/step` are only valid for numeric settings, not lists",
343 ));
344 }
345 if attrs.pattern.is_some() {
346 return Err(syn::Error::new_spanned(
347 field,
348 "`pattern` is only valid for text settings, not lists",
349 ));
350 }
351 if !attrs.options.is_empty() {
352 return Err(syn::Error::new_spanned(
353 field,
354 "`options` are only valid for text/number settings, not lists",
355 ));
356 }
357 }
358 TypeInfo::Unknown => unreachable!(),
359 }
360 Ok(())
361}
362
363fn resolve_field_category(
364 field: &Field,
365 attrs: &FieldAttrs,
366 container_attrs: &ContainerAttrs,
367) -> Result<String, syn::Error> {
368 attrs
369 .category
370 .as_ref()
371 .or(container_attrs.category.as_ref())
372 .cloned()
373 .ok_or_else(|| {
374 syn::Error::new_spanned(
375 field,
376 "Category is required. Add #[schema(category = \"name\")] to the struct or #[setting(category = \"name\")] to this field",
377 )
378 })
379}
380
381fn generate_field_constructor(
382 field_name: &syn::Ident,
383 field_type: &syn::Type,
384 type_info: TypeInfo,
385 attrs: &FieldAttrs,
386) -> proc_macro2::TokenStream {
387 if attrs.options.is_empty() {
388 generate_setting_type(field_name, field_type, type_info)
389 } else {
390 let options: Vec<_> = attrs
391 .options
392 .iter()
393 .map(|(val, lbl)| {
394 quote! { rcman::SettingOption::new(#val, #lbl) }
395 })
396 .collect();
397 quote! {
398 rcman::SettingMetadata::select(
399 defaults.#field_name.clone(),
400 vec![#(#options),*]
401 )
402 }
403 }
404}
405
406fn generate_field_modifiers(attrs: &FieldAttrs) -> Vec<proc_macro2::TokenStream> {
407 let mut modifiers = Vec::new();
408
409 if let Some(min) = attrs.min {
410 modifiers.push(quote! { .min(#min) });
411 }
412 if let Some(max) = attrs.max {
413 modifiers.push(quote! { .max(#max) });
414 }
415 if let Some(step) = attrs.step {
416 modifiers.push(quote! { .step(#step) });
417 }
418 if let Some(pattern) = &attrs.pattern {
419 modifiers.push(quote! { .pattern(#pattern) });
420 }
421 if attrs.secret {
422 modifiers.push(quote! { .secret() });
423 }
424 if !attrs.reserved.is_empty() {
425 let reserved_items = &attrs.reserved;
426 modifiers.push(quote! { .reserved(vec![#(#reserved_items.to_string()),*]) });
427 }
428
429 for (key, value) in &attrs.metadata_str {
430 modifiers.push(quote! { .meta_str(#key, #value) });
431 }
432 for (key, value) in &attrs.metadata_bool {
433 modifiers.push(quote! { .meta_bool(#key, #value) });
434 }
435 for (key, value) in &attrs.metadata_num {
436 modifiers.push(quote! { .meta_num(#key, #value) });
437 }
438
439 modifiers
440}
441
442fn parse_field_attrs(attrs: &[Attribute]) -> Result<FieldAttrs, syn::Error> {
443 let mut result = FieldAttrs::default();
444
445 for attr in attrs {
446 if attr.path().is_ident("setting") {
447 let nested = attr.parse_args_with(
448 syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
449 )?;
450
451 for meta in nested {
452 parse_single_field_attr(meta, &mut result)?;
453 }
454 }
455 }
456
457 Ok(result)
458}
459
460fn parse_single_field_attr(meta: Meta, result: &mut FieldAttrs) -> Result<(), syn::Error> {
461 match meta {
462 Meta::Path(path) => {
463 if path.is_ident("secret") {
464 result.secret = true;
465 } else if path.is_ident("skip") {
466 result.skip = true;
467 } else if path.is_ident("nested") {
468 result.nested = true;
469 }
470 }
471 Meta::NameValue(nv) => {
472 let value = &nv.value;
473 if nv.path.is_ident("category") {
474 result.category = Some(parse_lit_str(value, "category")?);
475 } else if nv.path.is_ident("min") {
476 result.min = parse_number_constraint(parse_lit_expr(value, "min")?, "min")?;
477 } else if nv.path.is_ident("max") {
478 result.max = parse_number_constraint(parse_lit_expr(value, "max")?, "max")?;
479 } else if nv.path.is_ident("step") {
480 result.step = parse_number_constraint(parse_lit_expr(value, "step")?, "step")?;
481 } else if nv.path.is_ident("pattern") {
482 result.pattern = Some(parse_lit_str(value, "pattern")?);
483 } else if nv.path.is_ident("rename") {
484 result.rename = Some(parse_lit_str(value, "rename")?);
485 } else {
486 let key = nv
487 .path
488 .get_ident()
489 .map(std::string::ToString::to_string)
490 .unwrap_or_default();
491 let lit = parse_lit_expr(value, &key)?;
492 parse_metadata_value(key, lit, result)?;
493 }
494 }
495 Meta::List(list) => {
496 if list.path.is_ident("options") {
497 parse_options_list(&list, result)?;
498 } else if list.path.is_ident("reserved") {
499 parse_reserved_list(&list, result)?;
500 }
501 }
502 }
503 Ok(())
504}
505
506fn parse_lit_str(expr: &syn::Expr, name: &str) -> Result<String, syn::Error> {
507 if let syn::Expr::Lit(lit) = expr
508 && let Lit::Str(s) = &lit.lit
509 {
510 return Ok(s.value());
511 }
512 Err(syn::Error::new_spanned(
513 expr,
514 format!("#[setting({name})] must be a string literal"),
515 ))
516}
517
518fn parse_lit_expr<'a>(expr: &'a syn::Expr, name: &str) -> Result<&'a syn::ExprLit, syn::Error> {
519 if let syn::Expr::Lit(lit) = expr {
520 Ok(lit)
521 } else {
522 Err(syn::Error::new_spanned(
523 expr,
524 format!("#[setting({name})] must be a literal"),
525 ))
526 }
527}
528
529#[derive(Default)]
531struct ContainerAttrs {
532 category: Option<String>,
533}
534
535#[derive(Default)]
537struct FieldAttrs {
538 category: Option<String>,
539 min: Option<f64>,
540 max: Option<f64>,
541 step: Option<f64>,
542 pattern: Option<String>,
543 options: Vec<(String, String)>, reserved: Vec<String>,
545 secret: bool,
546 skip: bool,
547 nested: bool, rename: Option<String>,
549 metadata_str: Vec<(String, String)>,
551 metadata_bool: Vec<(String, bool)>,
552 metadata_num: Vec<(String, f64)>,
553}
554
555fn parse_container_attrs(attrs: &[Attribute]) -> Result<ContainerAttrs, syn::Error> {
556 let mut result = ContainerAttrs::default();
557
558 for attr in attrs {
559 if attr.path().is_ident("schema") {
560 let nested = attr.parse_args_with(
561 syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
562 )?;
563
564 for meta in nested {
565 if let Meta::NameValue(nv) = meta
566 && nv.path.is_ident("category")
567 {
568 if let Expr::Lit(lit) = &nv.value {
569 if let Lit::Str(s) = &lit.lit {
570 result.category = Some(s.value());
571 } else {
572 return Err(syn::Error::new_spanned(
573 lit,
574 "#[schema(category)] must be a string literal",
575 ));
576 }
577 } else {
578 return Err(syn::Error::new_spanned(
579 &nv.value,
580 "#[schema(category)] must be a string literal, not an expression",
581 ));
582 }
583 }
584 }
585 }
586 }
587
588 Ok(result)
589}
590
591fn parse_number_constraint(
593 lit: &syn::ExprLit,
594 constraint_name: &str,
595) -> Result<Option<f64>, syn::Error> {
596 match &lit.lit {
597 Lit::Float(f) => Ok(f.base10_parse().ok()),
598 Lit::Int(i) => Ok(i.base10_parse().ok()),
599 Lit::Str(_) => Err(syn::Error::new_spanned(
600 lit,
601 format!(
602 "#[setting({constraint_name})] expects a number, found string literal (hint: remove quotes, use `{constraint_name} = 10`)"
603 ),
604 )),
605 Lit::Bool(_) => Err(syn::Error::new_spanned(
606 lit,
607 format!(
608 "#[setting({constraint_name})] expects a number, found boolean (hint: use `{constraint_name} = 10`)"
609 ),
610 )),
611 _ => Err(syn::Error::new_spanned(
612 lit,
613 format!(
614 "#[setting({constraint_name})] must be a number literal (e.g., `{constraint_name} = 10` or `{constraint_name} = 10.5`)"
615 ),
616 )),
617 }
618}
619
620fn parse_metadata_value(
622 key: String,
623 lit: &syn::ExprLit,
624 result: &mut FieldAttrs,
625) -> Result<(), syn::Error> {
626 match &lit.lit {
627 Lit::Str(s) => {
628 result.metadata_str.push((key, s.value()));
629 Ok(())
630 }
631 Lit::Bool(b) => {
632 result.metadata_bool.push((key, b.value()));
633 Ok(())
634 }
635 Lit::Int(i) => {
636 if let Ok(val) = i.base10_parse::<i64>() {
637 #[allow(clippy::cast_precision_loss)]
638 result.metadata_num.push((key, val as f64));
639 }
640 Ok(())
641 }
642 Lit::Float(f) => {
643 if let Ok(val) = f.base10_parse::<f64>() {
644 result.metadata_num.push((key, val));
645 }
646 Ok(())
647 }
648 _ => Err(syn::Error::new_spanned(
649 lit,
650 format!(
651 "Metadata value for '{key}' must be a string, number, or boolean literal (hint: use \\\"text\\\", 123, or true/false)"
652 ),
653 )),
654 }
655}
656
657fn parse_options_list(list: &syn::MetaList, result: &mut FieldAttrs) -> Result<(), syn::Error> {
659 let items = list
660 .parse_args_with(syn::punctuated::Punctuated::<Expr, syn::Token![,]>::parse_terminated)?;
661
662 for item in items {
663 let Expr::Tuple(tuple) = &item else {
664 return Err(syn::Error::new_spanned(
665 &item,
666 "#[setting(options)] must be an array of tuples: [(\"val\", \"Label\"), ...]",
667 ));
668 };
669
670 if tuple.elems.len() != 2 {
671 return Err(syn::Error::new_spanned(
672 tuple,
673 "#[setting(options)] tuples must have exactly 2 elements: (\"value\", \"Label\")",
674 ));
675 }
676
677 let mut vals = tuple.elems.iter();
678 match (vals.next(), vals.next()) {
679 (Some(Expr::Lit(v)), Some(Expr::Lit(l))) => match (&v.lit, &l.lit) {
680 (Lit::Str(val), Lit::Str(label)) => {
681 result.options.push((val.value(), label.value()));
682 }
683 _ => {
684 return Err(syn::Error::new_spanned(
685 tuple,
686 "#[setting(options)] tuple elements must be string literals",
687 ));
688 }
689 },
690 _ => {
691 return Err(syn::Error::new_spanned(
692 tuple,
693 "#[setting(options)] tuple elements must be string literals",
694 ));
695 }
696 }
697 }
698 Ok(())
699}
700
701fn parse_reserved_list(list: &syn::MetaList, result: &mut FieldAttrs) -> Result<(), syn::Error> {
702 let items = list
703 .parse_args_with(syn::punctuated::Punctuated::<Expr, syn::Token![,]>::parse_terminated)?;
704
705 for item in items {
706 if let Expr::Lit(lit) = item {
707 if let Lit::Str(s) = lit.lit {
708 result.reserved.push(s.value());
709 } else {
710 return Err(syn::Error::new_spanned(
711 lit,
712 "#[setting(reserved)] values must be string literals",
713 ));
714 }
715 } else {
716 return Err(syn::Error::new_spanned(
717 item,
718 "#[setting(reserved)] values must be string literals",
719 ));
720 }
721 }
722 Ok(())
723}
724
725#[derive(Copy, Clone)]
727enum TypeInfo {
728 Toggle, Text, Path, Number, List, Unknown, }
735
736fn get_last_path_segment_ident(ty: &Type) -> Option<&syn::Ident> {
739 if let Type::Path(path) = ty {
740 path.path.segments.last().map(|seg| &seg.ident)
741 } else {
742 None
743 }
744}
745
746fn classify_type(ty: &Type) -> TypeInfo {
751 if let Some(ident) = get_last_path_segment_ident(ty) {
752 let name = ident.to_string();
753 match name.as_str() {
754 "bool" => return TypeInfo::Toggle,
755 "String" => return TypeInfo::Text,
756 "PathBuf" => return TypeInfo::Path,
757 "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64"
758 | "u128" | "usize" | "f32" | "f64" => return TypeInfo::Number,
759 "Vec" => return TypeInfo::List,
761 "str" | "char" | "OsString" | "CString" | "Duration" | "Instant" | "SystemTime"
763 | "Box" | "Rc" | "Arc" | "Cow" | "VecDeque" | "HashMap" | "HashSet" | "BTreeMap"
764 | "BTreeSet" | "LinkedList" | "Option" | "Result" => {
765 return TypeInfo::Unknown;
766 }
767 _ => return TypeInfo::Unknown,
768 }
769 }
770
771 TypeInfo::Unknown
772}
773
774fn extract_inner_type_from_option(ty: &Type) -> Option<&Type> {
776 if let Type::Path(path) = ty
777 && let Some(segment) = path.path.segments.last()
778 && segment.ident == "Option"
779 && let syn::PathArguments::AngleBracketed(args) = &segment.arguments
780 && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first()
781 {
782 return Some(inner_ty);
783 }
784 None
785}
786
787fn is_nested_struct(ty: &Type) -> bool {
794 if let Some(inner) = extract_inner_type_from_option(ty) {
796 return is_nested_struct(inner);
797 }
798
799 if let Type::Path(path_ty) = ty
801 && get_last_path_segment_ident(ty).is_some()
802 {
803 if path_ty.path.segments.last().unwrap().arguments.is_empty() {
805 return matches!(classify_type(ty), TypeInfo::Unknown);
807 }
808 }
809 false
810}
811
812fn generate_setting_type(
814 field_name: &syn::Ident,
815 ty: &Type,
816 type_info: TypeInfo,
817) -> proc_macro2::TokenStream {
818 let is_option = extract_inner_type_from_option(ty).is_some();
819
820 match type_info {
821 TypeInfo::Toggle => {
822 if is_option {
823 quote! { rcman::SettingMetadata::toggle(defaults.#field_name.unwrap_or_default()) }
824 } else {
825 quote! { rcman::SettingMetadata::toggle(defaults.#field_name) }
826 }
827 }
828 TypeInfo::Text => {
829 if is_option {
830 quote! { rcman::SettingMetadata::text(defaults.#field_name.clone().unwrap_or_default()) }
831 } else {
832 quote! { rcman::SettingMetadata::text(defaults.#field_name.clone()) }
833 }
834 }
835 TypeInfo::Path => {
836 if is_option {
837 quote! {
838 rcman::SettingMetadata::text(
839 defaults.#field_name.as_ref()
840 .map(|p| p.to_string_lossy().into_owned())
841 .unwrap_or_default()
842 )
843 .meta_str("input_type", "path")
844 }
845 } else {
846 quote! {
847 rcman::SettingMetadata::text(
848 defaults.#field_name.to_string_lossy().into_owned()
849 )
850 .meta_str("input_type", "path")
851 }
852 }
853 }
854 TypeInfo::Number => {
855 if is_option {
856 quote! { rcman::SettingMetadata::number(defaults.#field_name.unwrap_or_default() as f64) }
857 } else {
858 quote! { rcman::SettingMetadata::number(defaults.#field_name as f64) }
859 }
860 }
861 TypeInfo::List => {
862 quote! {
863 rcman::SettingMetadata::list(
864 &(defaults.#field_name
865 .iter()
866 .map(|it| it.to_string())
867 .collect::<Vec<String>>())[..]
868 )
869 }
870 }
871 TypeInfo::Unknown => {
872 unreachable!("Unknown types are rejected in process_field")
873 }
874 }
875}