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    if path_name.is_none() && group_name.is_some() {
 61        // todo(settings_ui) derive path from settings
 62        panic!("path is required when group is specified");
 63    }
 64
 65    let ui_render_fn_body = generate_ui_item_body(group_name.as_ref(), path_name.as_ref(), &input);
 66
 67    let settings_ui_item_fn_body = path_name
 68        .as_ref()
 69        .map(|path_name| map_ui_item_to_render(path_name, quote! { Self }))
 70        .unwrap_or(quote! {
 71            settings::SettingsUiEntry {
 72                item: settings::SettingsUiEntryVariant::None
 73            }
 74        });
 75
 76    let expanded = quote! {
 77        impl #impl_generics settings::SettingsUi for #name #ty_generics #where_clause {
 78            fn settings_ui_item() -> settings::SettingsUiItem {
 79                #ui_render_fn_body
 80            }
 81
 82            fn settings_ui_entry() -> settings::SettingsUiEntry {
 83                #settings_ui_item_fn_body
 84            }
 85        }
 86    };
 87
 88    proc_macro::TokenStream::from(expanded)
 89}
 90
 91fn extract_type_from_option(ty: TokenStream) -> TokenStream {
 92    match option_inner_type(ty.clone()) {
 93        Some(inner_type) => inner_type,
 94        None => ty,
 95    }
 96}
 97
 98fn option_inner_type(ty: TokenStream) -> Option<TokenStream> {
 99    let ty = syn::parse2::<syn::Type>(ty).ok()?;
100    let syn::Type::Path(path) = ty else {
101        return None;
102    };
103    let segment = path.path.segments.last()?;
104    if segment.ident != "Option" {
105        return None;
106    }
107    let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
108        return None;
109    };
110    let arg = args.args.first()?;
111    let syn::GenericArgument::Type(ty) = arg else {
112        return None;
113    };
114    return Some(ty.to_token_stream());
115}
116
117fn map_ui_item_to_render(path: &str, ty: TokenStream) -> TokenStream {
118    let ty = extract_type_from_option(ty);
119    quote! {
120        settings::SettingsUiEntry {
121            item: match #ty::settings_ui_item() {
122                settings::SettingsUiItem::Group{title, items} => settings::SettingsUiEntryVariant::Group {
123                    title,
124                    path: #path,
125                    items,
126                },
127                settings::SettingsUiItem::Single(item) => settings::SettingsUiEntryVariant::Item {
128                    path: #path,
129                    item,
130                },
131                settings::SettingsUiItem::Dynamic{ options, determine_option } => settings::SettingsUiEntryVariant::Dynamic {
132                    path: #path,
133                    options,
134                    determine_option,
135                },
136                settings::SettingsUiItem::None => settings::SettingsUiEntryVariant::None,
137            }
138        }
139    }
140}
141
142fn generate_ui_item_body(
143    group_name: Option<&String>,
144    path_name: Option<&String>,
145    input: &syn::DeriveInput,
146) -> TokenStream {
147    match (group_name, path_name, &input.data) {
148        (_, _, Data::Union(_)) => unimplemented!("Derive SettingsUi for Unions"),
149        (None, None, Data::Struct(_)) => quote! {
150            settings::SettingsUiItem::None
151        },
152        (Some(_), None, Data::Struct(_)) => quote! {
153            settings::SettingsUiItem::None
154        },
155        (None, Some(_), Data::Struct(_)) => quote! {
156            settings::SettingsUiItem::None
157        },
158        (Some(group_name), _, Data::Struct(data_struct)) => {
159            let fields = data_struct
160                .fields
161                .iter()
162                .filter(|field| {
163                    !field.attrs.iter().any(|attr| {
164                        let mut has_skip = false;
165                        if attr.path().is_ident("settings_ui") {
166                            let _ = attr.parse_nested_meta(|meta| {
167                                if meta.path.is_ident("skip") {
168                                    has_skip = true;
169                                }
170                                Ok(())
171                            });
172                        }
173
174                        has_skip
175                    })
176                })
177                .map(|field| {
178                    (
179                        field.ident.clone().expect("tuple fields").to_string(),
180                        field.ty.to_token_stream(),
181                    )
182                })
183                .map(|(name, ty)| map_ui_item_to_render(&name, ty));
184
185            quote! {
186                settings::SettingsUiItem::Group{ title: #group_name, items: vec![#(#fields),*] }
187            }
188        }
189        (None, _, Data::Enum(data_enum)) => {
190            let mut lowercase = false;
191            for attr in &input.attrs {
192                if attr.path().is_ident("serde") {
193                    attr.parse_nested_meta(|meta| {
194                        if meta.path.is_ident("rename_all") {
195                            meta.input.parse::<Token![=]>()?;
196                            let lit = meta.input.parse::<LitStr>()?.value();
197                            // todo(settings_ui) snake case
198                            lowercase = lit == "lowercase" || lit == "snake_case";
199                        }
200                        Ok(())
201                    })
202                    .ok();
203                }
204            }
205            let length = data_enum.variants.len();
206
207            let variants = data_enum.variants.iter().map(|variant| {
208                let string = variant.ident.clone().to_string();
209
210                if lowercase {
211                    string.to_lowercase()
212                } else {
213                    string
214                }
215            });
216
217            if length > 6 {
218                quote! {
219                    settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::DropDown(&[#(#variants),*]))
220                }
221            } else {
222                quote! {
223                    settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::ToggleGroup(&[#(#variants),*]))
224                }
225            }
226        }
227        // todo(settings_ui) discriminated unions
228        (_, _, Data::Enum(_)) => quote! {
229            settings::SettingsUiItem::None
230        },
231    }
232}