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 map_ui_item_to_render(path: &str, ty: TokenStream) -> TokenStream {
 92    quote! {
 93        settings::SettingsUiEntry {
 94            item: match #ty::settings_ui_item() {
 95                settings::SettingsUiItem::Group{title, items} => settings::SettingsUiEntryVariant::Group {
 96                    title,
 97                    path: #path,
 98                    items,
 99                },
100                settings::SettingsUiItem::Single(item) => settings::SettingsUiEntryVariant::Item {
101                    path: #path,
102                    item,
103                },
104                settings::SettingsUiItem::Dynamic{ options, determine_option } => settings::SettingsUiEntryVariant::Dynamic {
105                    path: #path,
106                    options,
107                    determine_option,
108                },
109                settings::SettingsUiItem::None => settings::SettingsUiEntryVariant::None,
110            }
111        }
112    }
113}
114
115fn generate_ui_item_body(
116    group_name: Option<&String>,
117    path_name: Option<&String>,
118    input: &syn::DeriveInput,
119) -> TokenStream {
120    match (group_name, path_name, &input.data) {
121        (_, _, Data::Union(_)) => unimplemented!("Derive SettingsUi for Unions"),
122        (None, None, Data::Struct(_)) => quote! {
123            settings::SettingsUiItem::None
124        },
125        (Some(_), None, Data::Struct(_)) => quote! {
126            settings::SettingsUiItem::None
127        },
128        (None, Some(_), Data::Struct(_)) => quote! {
129            settings::SettingsUiItem::None
130        },
131        (Some(group_name), _, 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                .map(|(name, ty)| map_ui_item_to_render(&name, ty));
157
158            quote! {
159                settings::SettingsUiItem::Group{ title: #group_name, items: vec![#(#fields),*] }
160            }
161        }
162        (None, _, Data::Enum(data_enum)) => {
163            let mut lowercase = false;
164            for attr in &input.attrs {
165                if attr.path().is_ident("serde") {
166                    attr.parse_nested_meta(|meta| {
167                        if meta.path.is_ident("rename_all") {
168                            meta.input.parse::<Token![=]>()?;
169                            let lit = meta.input.parse::<LitStr>()?.value();
170                            // todo(settings_ui) snake case
171                            lowercase = lit == "lowercase" || lit == "snake_case";
172                        }
173                        Ok(())
174                    })
175                    .ok();
176                }
177            }
178            let length = data_enum.variants.len();
179
180            let variants = data_enum.variants.iter().map(|variant| {
181                let string = variant.ident.clone().to_string();
182
183                if lowercase {
184                    string.to_lowercase()
185                } else {
186                    string
187                }
188            });
189
190            if length > 6 {
191                quote! {
192                    settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::DropDown(&[#(#variants),*]))
193                }
194            } else {
195                quote! {
196                    settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::ToggleGroup(&[#(#variants),*]))
197                }
198            }
199        }
200        // todo(settings_ui) discriminated unions
201        (_, _, Data::Enum(_)) => quote! {
202            settings::SettingsUiItem::None
203        },
204    }
205}