tui_theme_builder_derive/
lib.rs

1use core::panic;
2use proc_macro::TokenStream;
3use proc_macro2::{Punct, Spacing, TokenStream as TokenStream2, TokenTree};
4use quote::quote;
5use syn::{parse::ParseStream, parse_macro_input, Attribute, Data, DeriveInput, Fields, Ident};
6
7/// # Panics
8/// - Panics if derive is not attached to a struct
9/// - Panics if no `context` attribute is found
10#[allow(clippy::too_many_lines)]
11#[proc_macro_derive(ThemeBuilder, attributes(context, builder, style))]
12pub fn derive_theme_builder(input: TokenStream) -> TokenStream {
13    let input = parse_macro_input!(input as DeriveInput);
14
15    let struct_name = &input.ident;
16
17    let Data::Struct(data) = &input.data else {
18        panic!("derive must be attached to a struct");
19    };
20
21    let builder_attr = extract_builder_attribute(&input.attrs);
22    let Some(builder_attr) = builder_attr else {
23        panic!("no `context` attribute found on struct");
24    };
25    let context_name = process_builder_struct_attribute(builder_attr);
26    let Some(context_name) = context_name else {
27        panic!("no `context` field found in builder annotation");
28    };
29
30    let Fields::Named(fields) = &data.fields else {
31        panic!("expected named fields, got {:?}", &data.fields)
32    };
33
34    let mut field_constructors: Vec<TokenStream2> = Vec::new();
35
36    for field in &fields.named {
37        let field_name = field.ident.as_ref().unwrap();
38        let field_type = &field.ty;
39
40        let mut field_constructor = quote! {};
41
42        // Handle `Style` tagged fields.
43        let attr = extract_style_attribute(&field.attrs);
44        if let Some(attr) = attr {
45            let style_values = process_style_attribute(attr);
46
47            field_constructor.extend(quote! {
48                #field_name: ratatui::style::Style::default()
49            });
50
51            if let Some(foreground_color) = style_values.foreground {
52                field_constructor.extend(quote! {
53                    .fg(context.#foreground_color.clone().into())
54                });
55            }
56
57            if let Some(background_color) = style_values.background {
58                field_constructor.extend(quote! {
59                    .bg(context.#background_color.clone().into())
60                });
61            }
62
63            if style_values.bold.is_some() {
64                field_constructor.extend(quote! {
65                    .add_modifier(ratatui::style::Modifier::BOLD)
66                });
67            }
68
69            if style_values.dim.is_some() {
70                field_constructor.extend(quote! {
71                    .add_modifier(ratatui::style::Modifier::DIM)
72                });
73            }
74
75            if style_values.italic.is_some() {
76                field_constructor.extend(quote! {
77                    .add_modifier(ratatui::style::Modifier::ITALIC)
78                });
79            }
80
81            if style_values.underlined.is_some() {
82                field_constructor.extend(quote! {
83                    .add_modifier(ratatui::style::Modifier::UNDERLINED)
84                });
85            }
86
87            if style_values.slow_blink.is_some() {
88                field_constructor.extend(quote! {
89                    .add_modifier(ratatui::style::Modifier::SLOW_BLINK)
90                });
91            }
92
93            if style_values.rapid_blink.is_some() {
94                field_constructor.extend(quote! {
95                    .add_modifier(ratatui::style::Modifier::RAPID_BLINK)
96                });
97            }
98
99            if style_values.reversed.is_some() {
100                field_constructor.extend(quote! {
101                    .add_modifier(ratatui::style::Modifier::REVERSED)
102                });
103            }
104
105            if style_values.hidden.is_some() {
106                field_constructor.extend(quote! {
107                    .add_modifier(ratatui::style::Modifier::HIDDEN)
108                });
109            }
110
111            if style_values.crossed_out.is_some() {
112                field_constructor.extend(quote! {
113                    .add_modifier(ratatui::style::Modifier::CROSSED_OUT)
114                });
115            }
116
117            field_constructors.push(field_constructor);
118            continue;
119        }
120
121        // Handle `builder` tagged fields.
122        let attr = extract_builder_attribute(&field.attrs);
123        if let Some(attr) = attr {
124            let value = process_builder_field_attribute(attr);
125            let Some(value) = value else {
126                panic!("missing value in `builder` on field `{:?}`", field_name);
127            };
128
129            match value {
130                BuilderFieldAttribute::Value(value) => {
131                    field_constructor.extend(quote! {
132                            #field_name: context.#value.clone()
133                    });
134                }
135                BuilderFieldAttribute::Default => {
136                    field_constructor.extend(quote! {
137                        #field_name: <#field_type>::default()
138                    });
139                }
140            }
141
142            field_constructors.push(field_constructor);
143            continue;
144        }
145
146        // Handle untagged fields.
147        field_constructor.extend(quote! {
148                #field_name: #field_type::build(context)
149        });
150
151        field_constructors.push(field_constructor);
152    }
153
154    let implementation = quote! {
155        impl tui_theme_builder::ThemeBuilder for #struct_name {
156            type Context = #context_name;
157            fn build(context: &#context_name) -> Self {
158                Self {
159                    #(#field_constructors),*
160                }
161            }
162        }
163    };
164
165    TokenStream::from(implementation)
166}
167
168/// A helper method to extract the `builder` attribute in a list of attributes.
169fn extract_builder_attribute(attrs: &[Attribute]) -> Option<&Attribute> {
170    attrs.iter().find(|attr| attr.path().is_ident("builder"))
171}
172
173/// A helper method that processes a field with builder annotation.
174fn process_builder_field_attribute(attr: &Attribute) -> Option<BuilderFieldAttribute> {
175    let mut attribute: Option<BuilderFieldAttribute> = None;
176
177    let _ = attr.parse_nested_meta(|meta| {
178        if meta.path.is_ident("value") {
179            let value = meta.value()?;
180            let value = extract_metadata_stream(value)?;
181            if value.to_string() == "default" {
182                attribute = Some(BuilderFieldAttribute::Default);
183            } else {
184                attribute = Some(BuilderFieldAttribute::Value(value));
185            }
186            Ok(())
187        } else {
188            Err(meta.error("unsupported attribute"))
189        }
190    });
191
192    attribute
193}
194
195enum BuilderFieldAttribute {
196    Value(TokenStream2),
197    Default,
198}
199
200/// Helper to that process the builder attribute of a struct and returns the
201/// ident of the context type.
202fn process_builder_struct_attribute(attr: &Attribute) -> Option<Ident> {
203    let mut context: Option<Ident> = None;
204
205    let _ = attr.parse_nested_meta(|meta| {
206        if meta.path.is_ident("context") {
207            let value = meta.value()?;
208            let ident: syn::Ident = value.parse()?;
209            context = Some(ident);
210            Ok(())
211        } else {
212            Err(meta.error("unsupported attribute"))
213        }
214    });
215
216    context
217}
218
219/// A helper method to extract the `style` attribute in a list of attributes.
220fn extract_style_attribute(attrs: &[Attribute]) -> Option<&Attribute> {
221    attrs.iter().find(|attr| attr.path().is_ident("style"))
222}
223
224/// A helper method that processes a field with style annotation.
225fn process_style_attribute(attr: &Attribute) -> StyleValues {
226    let mut foreground: Option<TokenStream2> = None;
227    let mut background: Option<TokenStream2> = None;
228    let mut bold: Option<bool> = None;
229    let mut dim: Option<bool> = None;
230    let mut italic: Option<bool> = None;
231    let mut underlined: Option<bool> = None;
232    let mut slow_blink: Option<bool> = None;
233    let mut rapid_blink: Option<bool> = None;
234    let mut reversed: Option<bool> = None;
235    let mut hidden: Option<bool> = None;
236    let mut crossed_out: Option<bool> = None;
237
238    let _ = attr.parse_nested_meta(|meta| {
239        if let Some(ident) = meta.path.get_ident() {
240            match ident.to_string().as_str() {
241                "bold" => bold = Some(true),
242                "dim" => dim = Some(true),
243                "italic" => italic = Some(true),
244                "underlined" => underlined = Some(true),
245                "slow_blink" => slow_blink = Some(true),
246                "rapid_blink" => rapid_blink = Some(true),
247                "reversed" => reversed = Some(true),
248                "hidden" => hidden = Some(true),
249                "crossed_out" => crossed_out = Some(true),
250                "fg" | "foreground" => {
251                    let value = meta.value()?;
252                    let ident = extract_metadata_stream(value).unwrap();
253                    foreground = Some(ident);
254                }
255                "bg" | "background" => {
256                    let value = meta.value()?;
257                    let ident = extract_metadata_stream(value)?;
258                    background = Some(ident);
259                }
260                _ => {}
261            }
262        }
263
264        Ok(())
265    });
266
267    StyleValues {
268        foreground,
269        background,
270        bold,
271        dim,
272        italic,
273        underlined,
274        slow_blink,
275        rapid_blink,
276        reversed,
277        hidden,
278        crossed_out,
279    }
280}
281
282struct StyleValues {
283    foreground: Option<TokenStream2>,
284    background: Option<TokenStream2>,
285    bold: Option<bool>,
286    dim: Option<bool>,
287    italic: Option<bool>,
288    underlined: Option<bool>,
289    slow_blink: Option<bool>,
290    rapid_blink: Option<bool>,
291    reversed: Option<bool>,
292    hidden: Option<bool>,
293    crossed_out: Option<bool>,
294}
295
296/// A helper method that parses a `ParseStream` to a `TokenStream`. It is necessary
297/// to handle nested fields such as `#[builder(value=footer.hide)]`
298fn extract_metadata_stream(input: ParseStream) -> Result<TokenStream2, syn::Error> {
299    let mut tokens = TokenStream2::new();
300    while !input.is_empty() {
301        if input.peek(Ident) {
302            let ident: Ident = input.parse()?;
303            tokens.extend(Some(TokenTree::Ident(ident)));
304        } else if input.peek(syn::Token![.]) {
305            let _dot: syn::Token![.] = input.parse()?;
306            tokens.extend(Some(TokenTree::Punct(Punct::new('.', Spacing::Alone))));
307        } else if input.peek(syn::Token![,]) {
308            break;
309        } else {
310            return Err(input.error(format!(
311                "expected an identifier or a dot, but got {input:?}",
312            )));
313        }
314    }
315
316    Ok(tokens)
317}