1use proc_macro2::TokenStream;
2use quote::{ToTokens, quote};
3use syn::{Data, DeriveInput, LitStr, Token, parse_macro_input};
4
5/// Derive macro for the `SettingsUi` marker trait.
6///
7/// This macro automatically implements the `SettingsUi` trait for the annotated type.
8/// The `SettingsUi` trait is a marker trait used to indicate that a type can be
9/// displayed in the settings UI.
10///
11/// # Example
12///
13/// ```
14/// use settings::SettingsUi;
15///
16/// #[derive(SettingsUi)]
17/// #[settings_ui(group = "Standard")]
18/// struct MySettings {
19/// enabled: bool,
20/// count: usize,
21/// }
22/// ```
23#[proc_macro_derive(SettingsUi, attributes(settings_ui))]
24pub fn derive_settings_ui(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
25 let input = parse_macro_input!(input as DeriveInput);
26 let name = &input.ident;
27
28 // Handle generic parameters if present
29 let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
30
31 let mut group_name = Option::<String>::None;
32 let mut path_name = Option::<String>::None;
33
34 for attr in &input.attrs {
35 if attr.path().is_ident("settings_ui") {
36 attr.parse_nested_meta(|meta| {
37 if meta.path.is_ident("group") {
38 if group_name.is_some() {
39 return Err(meta.error("Only one 'group' path can be specified"));
40 }
41 meta.input.parse::<Token![=]>()?;
42 let lit: LitStr = meta.input.parse()?;
43 group_name = Some(lit.value());
44 } else if meta.path.is_ident("path") {
45 // todo(settings_ui) try get KEY from Settings if possible, and once we do,
46 // if can get key from settings, throw error if path also passed
47 if path_name.is_some() {
48 return Err(meta.error("Only one 'path' can be specified"));
49 }
50 meta.input.parse::<Token![=]>()?;
51 let lit: LitStr = meta.input.parse()?;
52 path_name = Some(lit.value());
53 }
54 Ok(())
55 })
56 .unwrap_or_else(|e| panic!("in #[settings_ui] attribute: {}", e));
57 }
58 }
59
60 let ui_item_fn_body = generate_ui_item_body(group_name.as_ref(), path_name.as_ref(), &input);
61
62 // todo(settings_ui): Reformat title to be title case with spaces if group name not present,
63 // and make group name optional, repurpose group as tag indicating item is group
64 let title = group_name.unwrap_or(input.ident.to_string());
65
66 let ui_entry_fn_body = map_ui_item_to_entry(path_name.as_deref(), &title, quote! { Self });
67
68 let expanded = quote! {
69 impl #impl_generics settings::SettingsUi for #name #ty_generics #where_clause {
70 fn settings_ui_item() -> settings::SettingsUiItem {
71 #ui_item_fn_body
72 }
73
74 fn settings_ui_entry() -> settings::SettingsUiEntry {
75 #ui_entry_fn_body
76 }
77 }
78 };
79
80 proc_macro::TokenStream::from(expanded)
81}
82
83fn extract_type_from_option(ty: TokenStream) -> TokenStream {
84 match option_inner_type(ty.clone()) {
85 Some(inner_type) => inner_type,
86 None => ty,
87 }
88}
89
90fn option_inner_type(ty: TokenStream) -> Option<TokenStream> {
91 let ty = syn::parse2::<syn::Type>(ty).ok()?;
92 let syn::Type::Path(path) = ty else {
93 return None;
94 };
95 let segment = path.path.segments.last()?;
96 if segment.ident != "Option" {
97 return None;
98 }
99 let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
100 return None;
101 };
102 let arg = args.args.first()?;
103 let syn::GenericArgument::Type(ty) = arg else {
104 return None;
105 };
106 return Some(ty.to_token_stream());
107}
108
109fn map_ui_item_to_entry(path: Option<&str>, title: &str, ty: TokenStream) -> TokenStream {
110 let ty = extract_type_from_option(ty);
111 let path = path.map_or_else(|| quote! {None}, |path| quote! {Some(#path)});
112 quote! {
113 settings::SettingsUiEntry {
114 title: #title,
115 path: #path,
116 item: #ty::settings_ui_item(),
117 }
118 }
119}
120
121fn generate_ui_item_body(
122 group_name: Option<&String>,
123 path_name: Option<&String>,
124 input: &syn::DeriveInput,
125) -> TokenStream {
126 match (group_name, path_name, &input.data) {
127 (_, _, Data::Union(_)) => unimplemented!("Derive SettingsUi for Unions"),
128 (None, _, Data::Struct(_)) => quote! {
129 settings::SettingsUiItem::None
130 },
131 (Some(_), _, Data::Struct(data_struct)) => {
132 let fields = data_struct
133 .fields
134 .iter()
135 .filter(|field| {
136 !field.attrs.iter().any(|attr| {
137 let mut has_skip = false;
138 if attr.path().is_ident("settings_ui") {
139 let _ = attr.parse_nested_meta(|meta| {
140 if meta.path.is_ident("skip") {
141 has_skip = true;
142 }
143 Ok(())
144 });
145 }
146
147 has_skip
148 })
149 })
150 .map(|field| {
151 (
152 field.ident.clone().expect("tuple fields").to_string(),
153 field.ty.to_token_stream(),
154 )
155 })
156 // todo(settings_ui): Re-format field name as nice title, and support setting different title with attr
157 .map(|(name, ty)| map_ui_item_to_entry(Some(&name), &name, ty));
158
159 quote! {
160 settings::SettingsUiItem::Group(settings::SettingsUiItemGroup{ items: vec![#(#fields),*] })
161 }
162 }
163 (None, _, Data::Enum(data_enum)) => {
164 let mut lowercase = false;
165 for attr in &input.attrs {
166 if attr.path().is_ident("serde") {
167 attr.parse_nested_meta(|meta| {
168 if meta.path.is_ident("rename_all") {
169 meta.input.parse::<Token![=]>()?;
170 let lit = meta.input.parse::<LitStr>()?.value();
171 // todo(settings_ui) snake case
172 lowercase = lit == "lowercase" || lit == "snake_case";
173 }
174 Ok(())
175 })
176 .ok();
177 }
178 }
179 let length = data_enum.variants.len();
180
181 let variants = data_enum.variants.iter().map(|variant| {
182 let string = variant.ident.clone().to_string();
183
184 if lowercase {
185 string.to_lowercase()
186 } else {
187 string
188 }
189 });
190
191 if length > 6 {
192 quote! {
193 settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::DropDown(&[#(#variants),*]))
194 }
195 } else {
196 quote! {
197 settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::ToggleGroup(&[#(#variants),*]))
198 }
199 }
200 }
201 // todo(settings_ui) discriminated unions
202 (_, _, Data::Enum(_)) => quote! {
203 settings::SettingsUiItem::None
204 },
205 }
206}