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) 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}