passless_config_doc/
lib.rs

1use proc_macro::TokenStream;
2use quote::quote;
3use syn::{Data, DeriveInput, Expr, Fields, Lit, Meta, Type, parse_macro_input};
4
5/// Extract doc comments from attributes
6fn extract_doc(attrs: &[syn::Attribute]) -> String {
7    attrs
8        .iter()
9        .filter_map(|attr| {
10            if attr.path().is_ident("doc")
11                && let Meta::NameValue(nv) = &attr.meta
12                && let Expr::Lit(expr_lit) = &nv.value
13                && let Lit::Str(lit_str) = &expr_lit.lit
14            {
15                return Some(lit_str.value().trim().to_string());
16            }
17            None
18        })
19        .collect::<Vec<_>>()
20        .join(" ")
21}
22
23/// Extract default value from #[default(...)] attribute
24fn extract_default(attrs: &[syn::Attribute]) -> Option<String> {
25    for attr in attrs {
26        if attr.path().is_ident("default") &&
27            // Extract the tokens inside #[default(...)]
28            let Ok(tokens) = attr.parse_args::<proc_macro2::TokenStream>()
29        {
30            let default_str = tokens.to_string();
31            // Clean up the string - remove quotes if it's a string literal
32            if default_str.starts_with("\"") && default_str.ends_with("\"") {
33                return Some(default_str.trim_matches('"').to_string());
34            }
35            return Some(default_str);
36        }
37    }
38    None
39}
40
41/// Get the type name from a Type
42fn get_type_name(ty: &Type) -> Option<String> {
43    if let Type::Path(type_path) = ty
44        && let Some(segment) = type_path.path.segments.last()
45    {
46        return Some(segment.ident.to_string());
47    }
48    None
49}
50
51/// Check if a type is a config struct (ends with "Config")
52fn is_config_type(ty: &Type) -> bool {
53    get_type_name(ty)
54        .map(|name| name.ends_with("Config"))
55        .unwrap_or(false)
56}
57
58/// Derive macro to extract doc comments and generate TOML config with comments
59///
60/// This macro generates `field_docs()` and for AppConfig, also generates
61/// `to_toml_with_comments()` that creates a fully documented TOML config.
62///
63/// Documentation is extracted from:
64/// - Struct-level doc comments (for section headers)
65/// - Field-level doc comments (for field descriptions)
66/// - #[default(...)] attributes (for default values)
67#[proc_macro_derive(ConfigDoc, attributes(config_example))]
68pub fn derive_config_doc(input: TokenStream) -> TokenStream {
69    let input = parse_macro_input!(input as DeriveInput);
70    let name = &input.ident;
71    let struct_doc = extract_doc(&input.attrs);
72
73    let fields = match &input.data {
74        Data::Struct(data) => match &data.fields {
75            Fields::Named(fields) => &fields.named,
76            _ => panic!("ConfigDoc only supports structs with named fields"),
77        },
78        _ => panic!("ConfigDoc only supports structs"),
79    };
80
81    let mut field_info = Vec::new();
82
83    for field in fields {
84        let field_name = field.ident.as_ref().unwrap();
85        let doc = extract_doc(&field.attrs);
86        let default_value = extract_default(&field.attrs);
87        let type_name = get_type_name(&field.ty);
88        let is_config = is_config_type(&field.ty);
89
90        field_info.push((field_name.clone(), doc, default_value, is_config, type_name));
91    }
92
93    let field_names: Vec<_> = field_info
94        .iter()
95        .map(|(n, _, _, _, _)| n.to_string())
96        .collect();
97    let field_help: Vec<_> = field_info.iter().map(|(_, d, _, _, _)| d).collect();
98    let field_defaults: Vec<_> = field_info
99        .iter()
100        .map(|(_, _, default, _, _)| default.as_deref().unwrap_or(""))
101        .collect();
102
103    // Build field access expressions for to_toml_fields
104    let field_accessors: Vec<_> = field_info
105        .iter()
106        .filter(|(_, _, _, is_config, _)| !is_config)
107        .map(|(field_name, _, _, _, _)| {
108            quote! {
109                (stringify!(#field_name), format!("{:?}", self.#field_name))
110            }
111        })
112        .collect();
113
114    let mut expanded = quote! {
115        impl #name {
116            /// Get documentation for all fields
117            /// Returns an array of (field_name, documentation, default) tuples
118            pub fn field_docs() -> &'static [(&'static str, &'static str, &'static str)] {
119                &[
120                    #((#field_names, #field_help, #field_defaults),)*
121                ]
122            }
123
124            /// Get struct-level documentation
125            pub fn struct_doc() -> &'static str {
126                #struct_doc
127            }
128
129            /// Get field values as TOML-formatted strings
130            pub fn to_toml_fields(&self) -> Vec<(&'static str, String)> {
131                vec![
132                    #(#field_accessors),*
133                ]
134            }
135        }
136    };
137
138    // If this is AppConfig, generate to_toml_with_comments
139    if name == "AppConfig" {
140        let toml_gen = generate_toml_method(&field_info);
141        expanded = quote! {
142            #expanded
143            #toml_gen
144        };
145    }
146
147    TokenStream::from(expanded)
148}
149
150/// Generate the to_toml_with_comments method for AppConfig
151#[allow(clippy::type_complexity)]
152fn generate_toml_method(
153    fields: &[(syn::Ident, String, Option<String>, bool, Option<String>)],
154) -> proc_macro2::TokenStream {
155    let mut output_parts = Vec::new();
156
157    // Header
158    output_parts.push(quote! {
159        output.push_str("# Passless Configuration File Example\n");
160        output.push_str("# Place this file at: ~/.config/passless/config.toml\n\n");
161    });
162
163    // Process fields
164    for (field_name, doc, default_value, is_config, type_name) in fields {
165        let field_name_str = field_name.to_string();
166
167        if !is_config {
168            // Simple field - add to root section
169            if !doc.is_empty() {
170                output_parts.push(quote! {
171                    output.push_str(&format!("# {}\n", #doc));
172                });
173            }
174
175            // Add default comment if available
176            if let Some(default) = default_value {
177                output_parts.push(quote! {
178                    output.push_str(&format!("# Default: {}\n", #default));
179                });
180            }
181
182            // Output the value
183            output_parts.push(quote! {
184                output.push_str(&format!("{} = {:?}\n\n", #field_name_str, self.#field_name));
185            });
186        } else if let Some(type_name) = type_name {
187            // Config struct - generate section dynamically
188            let type_ident = syn::Ident::new(type_name, proc_macro2::Span::call_site());
189
190            output_parts.push(quote! {
191                // Section header with struct doc
192                if !#type_ident::struct_doc().is_empty() {
193                    output.push_str("# ");
194                    output.push_str(#type_ident::struct_doc());
195                    output.push_str("\n");
196                }
197
198                // TOML section header
199                output.push_str(&format!("[{}]\n", #field_name_str));
200
201                // Get field values from the nested struct
202                let field_values = self.#field_name.to_toml_fields();
203
204                // Iterate through all fields in the config struct
205                for (name, doc, default) in #type_ident::field_docs() {
206                    // Field documentation
207                    if !doc.is_empty() {
208                        output.push_str(&format!("# {}\n", doc));
209                    }
210
211
212                    // Find the value for this field
213                    if let Some((_, value)) = field_values.iter().find(|(n, _)| *n == *name) {
214                        output.push_str(&format!("{} = {}\n\n", name, value));
215                    }
216                }
217            });
218        }
219    }
220
221    quote! {
222        impl AppConfig {
223            /// Generate TOML configuration with comments extracted from doc comments
224            pub fn to_toml_with_comments(&self) -> String {
225                let mut output = String::new();
226                #(#output_parts)*
227                output
228            }
229        }
230    }
231}