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}