Skip to main content

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