settings_ui_macros.rs

  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}