1use heck::{ToSnakeCase as _, ToTitleCase as _};
2use proc_macro2::TokenStream;
3use quote::{ToTokens, quote};
4use syn::{Data, DeriveInput, LitStr, Token, parse_macro_input};
5
6/// Derive macro for the `SettingsUi` marker trait.
7///
8/// This macro automatically implements the `SettingsUi` trait for the annotated type.
9/// The `SettingsUi` trait is a marker trait used to indicate that a type can be
10/// displayed in the settings UI.
11///
12/// # Example
13///
14/// ```
15/// use settings::SettingsUi;
16///
17/// #[derive(SettingsUi)]
18/// #[settings_ui(group = "Standard")]
19/// struct MySettings {
20/// enabled: bool,
21/// count: usize,
22/// }
23/// ```
24#[proc_macro_derive(SettingsUi, attributes(settings_ui))]
25pub fn derive_settings_ui(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
26 let input = parse_macro_input!(input as DeriveInput);
27 let name = &input.ident;
28
29 // Handle generic parameters if present
30 let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
31
32 let mut group_name = Option::<String>::None;
33 let mut path_name = Option::<String>::None;
34
35 for attr in &input.attrs {
36 if attr.path().is_ident("settings_ui") {
37 attr.parse_nested_meta(|meta| {
38 if meta.path.is_ident("group") {
39 if group_name.is_some() {
40 return Err(meta.error("Only one 'group' path can be specified"));
41 }
42 meta.input.parse::<Token![=]>()?;
43 let lit: LitStr = meta.input.parse()?;
44 group_name = Some(lit.value());
45 } else if meta.path.is_ident("path") {
46 // todo(settings_ui) rely entirely on settings_key, remove path attribute
47 if path_name.is_some() {
48 return Err(meta.error("Only one 'path' can be specified, either with `path` in `settings_ui` or with `settings_key`"));
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 } else if let Some(settings_key) = parse_setting_key_attr(attr) {
58 // todo(settings_ui) either remove fallback key or handle it here
59 if path_name.is_some() && settings_key.key.is_some() {
60 panic!("Both 'path' and 'settings_key' are specified. Must specify only one");
61 }
62 path_name = settings_key.key;
63 }
64 }
65
66 let ui_item_fn_body = generate_ui_item_body(group_name.as_ref(), path_name.as_ref(), &input);
67
68 // todo(settings_ui): make group name optional, repurpose group as tag indicating item is group, and have "title" tag for custom title
69 let title = group_name.unwrap_or(input.ident.to_string().to_title_case());
70
71 let ui_entry_fn_body = map_ui_item_to_entry(path_name.as_deref(), &title, quote! { Self });
72
73 let expanded = quote! {
74 impl #impl_generics settings::SettingsUi for #name #ty_generics #where_clause {
75 fn settings_ui_item() -> settings::SettingsUiItem {
76 #ui_item_fn_body
77 }
78
79 fn settings_ui_entry() -> settings::SettingsUiEntry {
80 #ui_entry_fn_body
81 }
82 }
83 };
84
85 proc_macro::TokenStream::from(expanded)
86}
87
88fn extract_type_from_option(ty: TokenStream) -> TokenStream {
89 match option_inner_type(ty.clone()) {
90 Some(inner_type) => inner_type,
91 None => ty,
92 }
93}
94
95fn option_inner_type(ty: TokenStream) -> Option<TokenStream> {
96 let ty = syn::parse2::<syn::Type>(ty).ok()?;
97 let syn::Type::Path(path) = ty else {
98 return None;
99 };
100 let segment = path.path.segments.last()?;
101 if segment.ident != "Option" {
102 return None;
103 }
104 let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
105 return None;
106 };
107 let arg = args.args.first()?;
108 let syn::GenericArgument::Type(ty) = arg else {
109 return None;
110 };
111 return Some(ty.to_token_stream());
112}
113
114fn map_ui_item_to_entry(path: Option<&str>, title: &str, ty: TokenStream) -> TokenStream {
115 let ty = extract_type_from_option(ty);
116 let path = path.map_or_else(|| quote! {None}, |path| quote! {Some(#path)});
117 quote! {
118 settings::SettingsUiEntry {
119 title: #title,
120 path: #path,
121 item: #ty::settings_ui_item(),
122 }
123 }
124}
125
126fn generate_ui_item_body(
127 group_name: Option<&String>,
128 path_name: Option<&String>,
129 input: &syn::DeriveInput,
130) -> TokenStream {
131 match (group_name, path_name, &input.data) {
132 (_, _, Data::Union(_)) => unimplemented!("Derive SettingsUi for Unions"),
133 (None, _, Data::Struct(_)) => quote! {
134 settings::SettingsUiItem::None
135 },
136 (Some(_), _, Data::Struct(data_struct)) => {
137 let fields = data_struct
138 .fields
139 .iter()
140 .filter(|field| {
141 !field.attrs.iter().any(|attr| {
142 let mut has_skip = false;
143 if attr.path().is_ident("settings_ui") {
144 let _ = attr.parse_nested_meta(|meta| {
145 if meta.path.is_ident("skip") {
146 has_skip = true;
147 }
148 Ok(())
149 });
150 }
151
152 has_skip
153 })
154 })
155 .map(|field| {
156 (
157 field.ident.clone().expect("tuple fields").to_string(),
158 field.ty.to_token_stream(),
159 )
160 })
161 // todo(settings_ui): Re-format field name as nice title, and support setting different title with attr
162 .map(|(name, ty)| map_ui_item_to_entry(Some(&name), &name.to_title_case(), ty));
163
164 quote! {
165 settings::SettingsUiItem::Group(settings::SettingsUiItemGroup{ items: vec![#(#fields),*] })
166 }
167 }
168 (None, _, Data::Enum(data_enum)) => {
169 let mut lowercase = false;
170 let mut snake_case = false;
171 for attr in &input.attrs {
172 if attr.path().is_ident("serde") {
173 attr.parse_nested_meta(|meta| {
174 if meta.path.is_ident("rename_all") {
175 meta.input.parse::<Token![=]>()?;
176 let lit = meta.input.parse::<LitStr>()?.value();
177 lowercase = lit == "lowercase";
178 snake_case = lit == "snake_case";
179 }
180 Ok(())
181 })
182 .ok();
183 }
184 }
185 let length = data_enum.variants.len();
186
187 let variants = data_enum.variants.iter().map(|variant| {
188 let string = variant.ident.clone().to_string();
189
190 let title = string.to_title_case();
191 let string = if lowercase {
192 string.to_lowercase()
193 } else if snake_case {
194 string.to_snake_case()
195 } else {
196 string
197 };
198
199 (string, title)
200 });
201
202 let (variants, labels): (Vec<_>, Vec<_>) = variants.unzip();
203
204 if length > 6 {
205 quote! {
206 settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::DropDown{ variants: &[#(#variants),*], labels: &[#(#labels),*] })
207 }
208 } else {
209 quote! {
210 settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::ToggleGroup{ variants: &[#(#variants),*], labels: &[#(#labels),*] })
211 }
212 }
213 }
214 // todo(settings_ui) discriminated unions
215 (_, _, Data::Enum(_)) => quote! {
216 settings::SettingsUiItem::None
217 },
218 }
219}
220
221struct SettingsKey {
222 key: Option<String>,
223 fallback_key: Option<String>,
224}
225
226fn parse_setting_key_attr(attr: &syn::Attribute) -> Option<SettingsKey> {
227 if !attr.path().is_ident("settings_key") {
228 return None;
229 }
230
231 let mut settings_key = SettingsKey {
232 key: None,
233 fallback_key: None,
234 };
235
236 let mut found_none = false;
237
238 attr.parse_nested_meta(|meta| {
239 if meta.path.is_ident("None") {
240 found_none = true;
241 } else if meta.path.is_ident("key") {
242 if settings_key.key.is_some() {
243 return Err(meta.error("Only one 'group' path can be specified"));
244 }
245 meta.input.parse::<Token![=]>()?;
246 let lit: LitStr = meta.input.parse()?;
247 settings_key.key = Some(lit.value());
248 } else if meta.path.is_ident("fallback_key") {
249 if found_none {
250 return Err(meta.error("Cannot specify 'fallback_key' and 'None'"));
251 }
252
253 if settings_key.fallback_key.is_some() {
254 return Err(meta.error("Only one 'fallback_key' can be specified"));
255 }
256
257 meta.input.parse::<Token![=]>()?;
258 let lit: LitStr = meta.input.parse()?;
259 settings_key.fallback_key = Some(lit.value());
260 }
261 Ok(())
262 })
263 .unwrap_or_else(|e| panic!("in #[settings_key] attribute: {}", e));
264
265 if found_none && settings_key.fallback_key.is_some() {
266 panic!("in #[settings_key] attribute: Cannot specify 'None' and 'fallback_key'");
267 }
268 if found_none && settings_key.key.is_some() {
269 panic!("in #[settings_key] attribute: Cannot specify 'None' and 'key'");
270 }
271 if !found_none && settings_key.key.is_none() {
272 panic!("in #[settings_key] attribute: 'key' must be specified");
273 }
274
275 return Some(settings_key);
276}
277
278#[proc_macro_derive(SettingsKey, attributes(settings_key))]
279pub fn derive_settings_key(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
280 let input = parse_macro_input!(input as DeriveInput);
281 let name = &input.ident;
282
283 // Handle generic parameters if present
284 let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
285
286 let mut settings_key = Option::<SettingsKey>::None;
287
288 for attr in &input.attrs {
289 let parsed_settings_key = parse_setting_key_attr(attr);
290 if parsed_settings_key.is_some() && settings_key.is_some() {
291 panic!("Duplicate #[settings_key] attribute");
292 }
293 settings_key = parsed_settings_key;
294 }
295
296 let Some(SettingsKey { key, fallback_key }) = settings_key else {
297 panic!("Missing #[settings_key] attribute");
298 };
299
300 let key = key.map_or_else(|| quote! {None}, |key| quote! {Some(#key)});
301 let fallback_key = fallback_key.map_or_else(
302 || quote! {None},
303 |fallback_key| quote! {Some(#fallback_key)},
304 );
305
306 let expanded = quote! {
307 impl #impl_generics settings::SettingsKey for #name #ty_generics #where_clause {
308 const KEY: Option<&'static str> = #key;
309
310 const FALLBACK_KEY: Option<&'static str> = #fallback_key;
311 };
312 };
313
314 proc_macro::TokenStream::from(expanded)
315}
316
317#[cfg(test)]
318mod tests {
319 use syn::{Attribute, parse_quote};
320
321 use super::*;
322
323 #[test]
324 fn test_extract_key() {
325 let input: Attribute = parse_quote!(
326 #[settings_key(key = "my_key")]
327 );
328 let settings_key = parse_setting_key_attr(&input).unwrap();
329 assert_eq!(settings_key.key, Some("my_key".to_string()));
330 assert_eq!(settings_key.fallback_key, None);
331 }
332
333 #[test]
334 fn test_empty_key() {
335 let input: Attribute = parse_quote!(
336 #[settings_key(None)]
337 );
338 let settings_key = parse_setting_key_attr(&input).unwrap();
339 assert_eq!(settings_key.key, None);
340 assert_eq!(settings_key.fallback_key, None);
341 }
342}