settings_ui_macros.rs

  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) try get KEY from Settings if possible, and once we do,
 47                    // if can get key from settings, throw error if path also passed
 48                    if path_name.is_some() {
 49                        return Err(meta.error("Only one 'path' can be specified"));
 50                    }
 51                    meta.input.parse::<Token![=]>()?;
 52                    let lit: LitStr = meta.input.parse()?;
 53                    path_name = Some(lit.value());
 54                }
 55                Ok(())
 56            })
 57            .unwrap_or_else(|e| panic!("in #[settings_ui] attribute: {}", e));
 58        }
 59    }
 60
 61    let ui_item_fn_body = generate_ui_item_body(group_name.as_ref(), path_name.as_ref(), &input);
 62
 63    // todo(settings_ui): make group name optional, repurpose group as tag indicating item is group, and have "title" tag for custom title
 64    let title = group_name.unwrap_or(input.ident.to_string().to_title_case());
 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.to_title_case(), 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            let mut snake_case = false;
166            for attr in &input.attrs {
167                if attr.path().is_ident("serde") {
168                    attr.parse_nested_meta(|meta| {
169                        if meta.path.is_ident("rename_all") {
170                            meta.input.parse::<Token![=]>()?;
171                            let lit = meta.input.parse::<LitStr>()?.value();
172                            lowercase = lit == "lowercase";
173                            snake_case = lit == "snake_case";
174                        }
175                        Ok(())
176                    })
177                    .ok();
178                }
179            }
180            let length = data_enum.variants.len();
181
182            let variants = data_enum.variants.iter().map(|variant| {
183                let string = variant.ident.clone().to_string();
184
185                let title = string.to_title_case();
186                let string = if lowercase {
187                    string.to_lowercase()
188                } else if snake_case {
189                    string.to_snake_case()
190                } else {
191                    string
192                };
193
194                (string, title)
195            });
196
197            let (variants, labels): (Vec<_>, Vec<_>) = variants.unzip();
198
199            if length > 6 {
200                quote! {
201                    settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::DropDown{ variants: &[#(#variants),*], labels: &[#(#labels),*] })
202                }
203            } else {
204                quote! {
205                    settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::ToggleGroup{ variants: &[#(#variants),*], labels: &[#(#labels),*] })
206                }
207            }
208        }
209        // todo(settings_ui) discriminated unions
210        (_, _, Data::Enum(_)) => quote! {
211            settings::SettingsUiItem::None
212        },
213    }
214}