settings_ui_macros.rs

  1use proc_macro2::TokenStream;
  2use quote::{ToTokens, quote};
  3use syn::{Data, DeriveInput, LitStr, Token, parse_macro_input};
  4
  5/// Derive macro for the `SettingsUi` marker trait.
  6///
  7/// This macro automatically implements the `SettingsUi` trait for the annotated type.
  8/// The `SettingsUi` trait is a marker trait used to indicate that a type can be
  9/// displayed in the settings UI.
 10///
 11/// # Example
 12///
 13/// ```
 14/// use settings::SettingsUi;
 15///
 16/// #[derive(SettingsUi)]
 17/// #[settings_ui(group = "Standard")]
 18/// struct MySettings {
 19///     enabled: bool,
 20///     count: usize,
 21/// }
 22/// ```
 23#[proc_macro_derive(SettingsUi, attributes(settings_ui))]
 24pub fn derive_settings_ui(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
 25    let input = parse_macro_input!(input as DeriveInput);
 26    let name = &input.ident;
 27
 28    // Handle generic parameters if present
 29    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
 30
 31    let mut group_name = Option::<String>::None;
 32    let mut path_name = Option::<String>::None;
 33
 34    for attr in &input.attrs {
 35        if attr.path().is_ident("settings_ui") {
 36            attr.parse_nested_meta(|meta| {
 37                if meta.path.is_ident("group") {
 38                    if group_name.is_some() {
 39                        return Err(meta.error("Only one 'group' path can be specified"));
 40                    }
 41                    meta.input.parse::<Token![=]>()?;
 42                    let lit: LitStr = meta.input.parse()?;
 43                    group_name = Some(lit.value());
 44                } else if meta.path.is_ident("path") {
 45                    // todo(settings_ui) try get KEY from Settings if possible, and once we do,
 46                    // if can get key from settings, throw error if path also passed
 47                    if path_name.is_some() {
 48                        return Err(meta.error("Only one 'path' can be specified"));
 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        }
 58    }
 59
 60    let ui_item_fn_body = generate_ui_item_body(group_name.as_ref(), path_name.as_ref(), &input);
 61
 62    // todo(settings_ui): Reformat title to be title case with spaces if group name not present,
 63    // and make group name optional, repurpose group as tag indicating item is group
 64    let title = group_name.unwrap_or(input.ident.to_string());
 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, 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            for attr in &input.attrs {
166                if attr.path().is_ident("serde") {
167                    attr.parse_nested_meta(|meta| {
168                        if meta.path.is_ident("rename_all") {
169                            meta.input.parse::<Token![=]>()?;
170                            let lit = meta.input.parse::<LitStr>()?.value();
171                            // todo(settings_ui) snake case
172                            lowercase = lit == "lowercase" || lit == "snake_case";
173                        }
174                        Ok(())
175                    })
176                    .ok();
177                }
178            }
179            let length = data_enum.variants.len();
180
181            let variants = data_enum.variants.iter().map(|variant| {
182                let string = variant.ident.clone().to_string();
183
184                if lowercase {
185                    string.to_lowercase()
186                } else {
187                    string
188                }
189            });
190
191            if length > 6 {
192                quote! {
193                    settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::DropDown(&[#(#variants),*]))
194                }
195            } else {
196                quote! {
197                    settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::ToggleGroup(&[#(#variants),*]))
198                }
199            }
200        }
201        // todo(settings_ui) discriminated unions
202        (_, _, Data::Enum(_)) => quote! {
203            settings::SettingsUiItem::None
204        },
205    }
206}