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