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) 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 let ui_item_fn_body = generate_ui_item_body(group_name.as_ref(), path_name.as_ref(), &input);
62
63 // todo(settings_ui): make group name optional, repurpose group as tag indicating item is group, and have "title" tag for custom title
64 let title = group_name.unwrap_or(input.ident.to_string().to_title_case());
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.to_title_case(), 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 let mut snake_case = false;
166 for attr in &input.attrs {
167 if attr.path().is_ident("serde") {
168 attr.parse_nested_meta(|meta| {
169 if meta.path.is_ident("rename_all") {
170 meta.input.parse::<Token![=]>()?;
171 let lit = meta.input.parse::<LitStr>()?.value();
172 lowercase = lit == "lowercase";
173 snake_case = lit == "snake_case";
174 }
175 Ok(())
176 })
177 .ok();
178 }
179 }
180 let length = data_enum.variants.len();
181
182 let variants = data_enum.variants.iter().map(|variant| {
183 let string = variant.ident.clone().to_string();
184
185 let title = string.to_title_case();
186 let string = if lowercase {
187 string.to_lowercase()
188 } else if snake_case {
189 string.to_snake_case()
190 } else {
191 string
192 };
193
194 (string, title)
195 });
196
197 let (variants, labels): (Vec<_>, Vec<_>) = variants.unzip();
198
199 if length > 6 {
200 quote! {
201 settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::DropDown{ variants: &[#(#variants),*], labels: &[#(#labels),*] })
202 }
203 } else {
204 quote! {
205 settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::ToggleGroup{ variants: &[#(#variants),*], labels: &[#(#labels),*] })
206 }
207 }
208 }
209 // todo(settings_ui) discriminated unions
210 (_, _, Data::Enum(_)) => quote! {
211 settings::SettingsUiItem::None
212 },
213 }
214}