1use std::ops::Not;
2
3use heck::{ToSnakeCase as _, ToTitleCase as _};
4use proc_macro2::TokenStream;
5use quote::{ToTokens, quote};
6use syn::{Data, DeriveInput, LitStr, Token, parse_macro_input};
7
8/// Derive macro for the `SettingsUi` marker trait.
9///
10/// This macro automatically implements the `SettingsUi` trait for the annotated type.
11/// The `SettingsUi` trait is a marker trait used to indicate that a type can be
12/// displayed in the settings UI.
13///
14/// # Example
15///
16/// ```
17/// use settings::SettingsUi;
18///
19/// #[derive(SettingsUi)]
20/// #[settings_ui(group = "Standard")]
21/// struct MySettings {
22/// enabled: bool,
23/// count: usize,
24/// }
25/// ```
26#[proc_macro_derive(SettingsUi, attributes(settings_ui))]
27pub fn derive_settings_ui(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
28 let input = parse_macro_input!(input as DeriveInput);
29 let name = &input.ident;
30
31 // Handle generic parameters if present
32 let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
33
34 let mut group_name = Option::<String>::None;
35 let mut path_name = Option::<String>::None;
36
37 for attr in &input.attrs {
38 if attr.path().is_ident("settings_ui") {
39 attr.parse_nested_meta(|meta| {
40 if meta.path.is_ident("group") {
41 if group_name.is_some() {
42 return Err(meta.error("Only one 'group' path can be specified"));
43 }
44 meta.input.parse::<Token![=]>()?;
45 let lit: LitStr = meta.input.parse()?;
46 group_name = Some(lit.value());
47 } else if meta.path.is_ident("path") {
48 // todo(settings_ui) rely entirely on settings_key, remove path attribute
49 if path_name.is_some() {
50 return Err(meta.error("Only one 'path' can be specified, either with `path` in `settings_ui` or with `settings_key`"));
51 }
52 meta.input.parse::<Token![=]>()?;
53 let lit: LitStr = meta.input.parse()?;
54 path_name = Some(lit.value());
55 }
56 Ok(())
57 })
58 .unwrap_or_else(|e| panic!("in #[settings_ui] attribute: {}", e));
59 } else if let Some(settings_key) = parse_setting_key_attr(attr) {
60 // todo(settings_ui) either remove fallback key or handle it here
61 if path_name.is_some() && settings_key.key.is_some() {
62 panic!("Both 'path' and 'settings_key' are specified. Must specify only one");
63 }
64 path_name = settings_key.key;
65 }
66 }
67
68 let doc_str = parse_documentation_from_attrs(&input.attrs);
69
70 let ui_item_fn_body = generate_ui_item_body(group_name.as_ref(), path_name.as_ref(), &input);
71
72 // todo(settings_ui): make group name optional, repurpose group as tag indicating item is group, and have "title" tag for custom title
73 let title = group_name.unwrap_or(input.ident.to_string().to_title_case());
74
75 let ui_entry_fn_body = map_ui_item_to_entry(
76 path_name.as_deref(),
77 &title,
78 doc_str.as_deref(),
79 quote! { Self },
80 );
81
82 let expanded = quote! {
83 impl #impl_generics settings::SettingsUi for #name #ty_generics #where_clause {
84 fn settings_ui_item() -> settings::SettingsUiItem {
85 #ui_item_fn_body
86 }
87
88 fn settings_ui_entry() -> settings::SettingsUiEntry {
89 #ui_entry_fn_body
90 }
91 }
92 };
93
94 proc_macro::TokenStream::from(expanded)
95}
96
97fn extract_type_from_option(ty: TokenStream) -> TokenStream {
98 match option_inner_type(ty.clone()) {
99 Some(inner_type) => inner_type,
100 None => ty,
101 }
102}
103
104fn option_inner_type(ty: TokenStream) -> Option<TokenStream> {
105 let ty = syn::parse2::<syn::Type>(ty).ok()?;
106 let syn::Type::Path(path) = ty else {
107 return None;
108 };
109 let segment = path.path.segments.last()?;
110 if segment.ident != "Option" {
111 return None;
112 }
113 let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
114 return None;
115 };
116 let arg = args.args.first()?;
117 let syn::GenericArgument::Type(ty) = arg else {
118 return None;
119 };
120 return Some(ty.to_token_stream());
121}
122
123fn map_ui_item_to_entry(
124 path: Option<&str>,
125 title: &str,
126 doc_str: Option<&str>,
127 ty: TokenStream,
128) -> TokenStream {
129 let ty = extract_type_from_option(ty);
130 // todo(settings_ui): does quote! just work with options?
131 let path = path.map_or_else(|| quote! {None}, |path| quote! {Some(#path)});
132 let doc_str = doc_str.map_or_else(|| quote! {None}, |doc_str| quote! {Some(#doc_str)});
133 quote! {
134 settings::SettingsUiEntry {
135 title: #title,
136 path: #path,
137 item: #ty::settings_ui_item(),
138 documentation: #doc_str,
139 }
140 }
141}
142
143fn generate_ui_item_body(
144 group_name: Option<&String>,
145 path_name: Option<&String>,
146 input: &syn::DeriveInput,
147) -> TokenStream {
148 match (group_name, path_name, &input.data) {
149 (_, _, Data::Union(_)) => unimplemented!("Derive SettingsUi for Unions"),
150 (None, _, Data::Struct(_)) => quote! {
151 settings::SettingsUiItem::None
152 },
153 (Some(_), _, Data::Struct(data_struct)) => {
154 let struct_serde_attrs = parse_serde_attributes(&input.attrs);
155 let fields = data_struct
156 .fields
157 .iter()
158 .filter(|field| {
159 !field.attrs.iter().any(|attr| {
160 let mut has_skip = false;
161 if attr.path().is_ident("settings_ui") {
162 let _ = attr.parse_nested_meta(|meta| {
163 if meta.path.is_ident("skip") {
164 has_skip = true;
165 }
166 Ok(())
167 });
168 }
169
170 has_skip
171 })
172 })
173 .map(|field| {
174 let field_serde_attrs = parse_serde_attributes(&field.attrs);
175 let name = field.ident.clone().expect("tuple fields").to_string();
176 let doc_str = parse_documentation_from_attrs(&field.attrs);
177
178 (
179 name.to_title_case(),
180 doc_str,
181 field_serde_attrs.flatten.not().then(|| {
182 struct_serde_attrs.apply_rename_to_field(&field_serde_attrs, &name)
183 }),
184 field.ty.to_token_stream(),
185 )
186 })
187 // todo(settings_ui): Re-format field name as nice title, and support setting different title with attr
188 .map(|(title, doc_str, path, ty)| {
189 map_ui_item_to_entry(path.as_deref(), &title, doc_str.as_deref(), ty)
190 });
191
192 quote! {
193 settings::SettingsUiItem::Group(settings::SettingsUiItemGroup{ items: vec![#(#fields),*] })
194 }
195 }
196 (None, _, Data::Enum(data_enum)) => {
197 let serde_attrs = parse_serde_attributes(&input.attrs);
198 let length = data_enum.variants.len();
199
200 let variants = data_enum.variants.iter().map(|variant| {
201 let string = variant.ident.clone().to_string();
202
203 let title = string.to_title_case();
204 let string = serde_attrs.rename_all.apply(&string);
205
206 (string, title)
207 });
208
209 let (variants, labels): (Vec<_>, Vec<_>) = variants.unzip();
210
211 if length > 6 {
212 quote! {
213 settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::DropDown{ variants: &[#(#variants),*], labels: &[#(#labels),*] })
214 }
215 } else {
216 quote! {
217 settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::ToggleGroup{ variants: &[#(#variants),*], labels: &[#(#labels),*] })
218 }
219 }
220 }
221 // todo(settings_ui) discriminated unions
222 (_, _, Data::Enum(_)) => quote! {
223 settings::SettingsUiItem::None
224 },
225 }
226}
227
228struct SerdeOptions {
229 rename_all: SerdeRenameAll,
230 rename: Option<String>,
231 flatten: bool,
232 _alias: Option<String>, // todo(settings_ui)
233}
234
235#[derive(PartialEq)]
236enum SerdeRenameAll {
237 Lowercase,
238 SnakeCase,
239 None,
240}
241
242impl SerdeRenameAll {
243 fn apply(&self, name: &str) -> String {
244 match self {
245 SerdeRenameAll::Lowercase => name.to_lowercase(),
246 SerdeRenameAll::SnakeCase => name.to_snake_case(),
247 SerdeRenameAll::None => name.to_string(),
248 }
249 }
250}
251
252impl SerdeOptions {
253 fn apply_rename_to_field(&self, field_options: &Self, name: &str) -> String {
254 // field renames take precedence over struct rename all cases
255 if let Some(rename) = &field_options.rename {
256 return rename.clone();
257 }
258 return self.rename_all.apply(name);
259 }
260}
261
262fn parse_serde_attributes(attrs: &[syn::Attribute]) -> SerdeOptions {
263 let mut options = SerdeOptions {
264 rename_all: SerdeRenameAll::None,
265 rename: None,
266 flatten: false,
267 _alias: None,
268 };
269
270 for attr in attrs {
271 if !attr.path().is_ident("serde") {
272 continue;
273 }
274 attr.parse_nested_meta(|meta| {
275 if meta.path.is_ident("rename_all") {
276 meta.input.parse::<Token![=]>()?;
277 let lit = meta.input.parse::<LitStr>()?.value();
278
279 if options.rename_all != SerdeRenameAll::None {
280 return Err(meta.error("duplicate `rename_all` attribute"));
281 } else if lit == "lowercase" {
282 options.rename_all = SerdeRenameAll::Lowercase;
283 } else if lit == "snake_case" {
284 options.rename_all = SerdeRenameAll::SnakeCase;
285 } else {
286 return Err(meta.error(format!("invalid `rename_all` attribute: {}", lit)));
287 }
288 // todo(settings_ui): Other options?
289 } else if meta.path.is_ident("flatten") {
290 options.flatten = true;
291 } else if meta.path.is_ident("rename") {
292 if options.rename.is_some() {
293 return Err(meta.error("Can only have one rename attribute"));
294 }
295
296 meta.input.parse::<Token![=]>()?;
297 let lit = meta.input.parse::<LitStr>()?.value();
298 options.rename = Some(lit);
299 }
300 Ok(())
301 })
302 .unwrap();
303 }
304
305 return options;
306}
307
308fn parse_documentation_from_attrs(attrs: &[syn::Attribute]) -> Option<String> {
309 let mut doc_str = Option::<String>::None;
310 for attr in attrs {
311 if attr.path().is_ident("doc") {
312 // /// ...
313 // becomes
314 // #[doc = "..."]
315 use syn::{Expr::Lit, ExprLit, Lit::Str, Meta, MetaNameValue};
316 if let Meta::NameValue(MetaNameValue {
317 value:
318 Lit(ExprLit {
319 lit: Str(ref lit_str),
320 ..
321 }),
322 ..
323 }) = attr.meta
324 {
325 let doc = lit_str.value();
326 let doc_str = doc_str.get_or_insert_default();
327 doc_str.push_str(doc.trim());
328 doc_str.push('\n');
329 }
330 }
331 }
332 return doc_str;
333}
334
335struct SettingsKey {
336 key: Option<String>,
337 fallback_key: Option<String>,
338}
339
340fn parse_setting_key_attr(attr: &syn::Attribute) -> Option<SettingsKey> {
341 if !attr.path().is_ident("settings_key") {
342 return None;
343 }
344
345 let mut settings_key = SettingsKey {
346 key: None,
347 fallback_key: None,
348 };
349
350 let mut found_none = false;
351
352 attr.parse_nested_meta(|meta| {
353 if meta.path.is_ident("None") {
354 found_none = true;
355 } else if meta.path.is_ident("key") {
356 if settings_key.key.is_some() {
357 return Err(meta.error("Only one 'group' path can be specified"));
358 }
359 meta.input.parse::<Token![=]>()?;
360 let lit: LitStr = meta.input.parse()?;
361 settings_key.key = Some(lit.value());
362 } else if meta.path.is_ident("fallback_key") {
363 if found_none {
364 return Err(meta.error("Cannot specify 'fallback_key' and 'None'"));
365 }
366
367 if settings_key.fallback_key.is_some() {
368 return Err(meta.error("Only one 'fallback_key' can be specified"));
369 }
370
371 meta.input.parse::<Token![=]>()?;
372 let lit: LitStr = meta.input.parse()?;
373 settings_key.fallback_key = Some(lit.value());
374 }
375 Ok(())
376 })
377 .unwrap_or_else(|e| panic!("in #[settings_key] attribute: {}", e));
378
379 if found_none && settings_key.fallback_key.is_some() {
380 panic!("in #[settings_key] attribute: Cannot specify 'None' and 'fallback_key'");
381 }
382 if found_none && settings_key.key.is_some() {
383 panic!("in #[settings_key] attribute: Cannot specify 'None' and 'key'");
384 }
385 if !found_none && settings_key.key.is_none() {
386 panic!("in #[settings_key] attribute: 'key' must be specified");
387 }
388
389 return Some(settings_key);
390}
391
392#[proc_macro_derive(SettingsKey, attributes(settings_key))]
393pub fn derive_settings_key(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
394 let input = parse_macro_input!(input as DeriveInput);
395 let name = &input.ident;
396
397 // Handle generic parameters if present
398 let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
399
400 let mut settings_key = Option::<SettingsKey>::None;
401
402 for attr in &input.attrs {
403 let parsed_settings_key = parse_setting_key_attr(attr);
404 if parsed_settings_key.is_some() && settings_key.is_some() {
405 panic!("Duplicate #[settings_key] attribute");
406 }
407 settings_key = settings_key.or(parsed_settings_key);
408 }
409
410 let Some(SettingsKey { key, fallback_key }) = settings_key else {
411 panic!("Missing #[settings_key] attribute");
412 };
413
414 let key = key.map_or_else(|| quote! {None}, |key| quote! {Some(#key)});
415 let fallback_key = fallback_key.map_or_else(
416 || quote! {None},
417 |fallback_key| quote! {Some(#fallback_key)},
418 );
419
420 let expanded = quote! {
421 impl #impl_generics settings::SettingsKey for #name #ty_generics #where_clause {
422 const KEY: Option<&'static str> = #key;
423
424 const FALLBACK_KEY: Option<&'static str> = #fallback_key;
425 };
426 };
427
428 proc_macro::TokenStream::from(expanded)
429}
430
431#[cfg(test)]
432mod tests {
433 use syn::{Attribute, parse_quote};
434
435 use super::*;
436
437 #[test]
438 fn test_extract_key() {
439 let input: Attribute = parse_quote!(
440 #[settings_key(key = "my_key")]
441 );
442 let settings_key = parse_setting_key_attr(&input).unwrap();
443 assert_eq!(settings_key.key, Some("my_key".to_string()));
444 assert_eq!(settings_key.fallback_key, None);
445 }
446
447 #[test]
448 fn test_empty_key() {
449 let input: Attribute = parse_quote!(
450 #[settings_key(None)]
451 );
452 let settings_key = parse_setting_key_attr(&input).unwrap();
453 assert_eq!(settings_key.key, None);
454 assert_eq!(settings_key.fallback_key, None);
455 }
456}