passless_config_doc/
lib.rs1use proc_macro::TokenStream;
2use quote::quote;
3use syn::{Data, DeriveInput, Expr, Fields, Lit, Meta, Type, parse_macro_input};
4
5fn 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
23fn extract_default(attrs: &[syn::Attribute]) -> Option<String> {
25 for attr in attrs {
26 if attr.path().is_ident("default") &&
27 let Ok(tokens) = attr.parse_args::<proc_macro2::TokenStream>()
29 {
30 let default_str = tokens.to_string();
31 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
41fn 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
51fn 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#[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 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 pub fn field_docs() -> &'static [(&'static str, &'static str, &'static str)] {
119 &[
120 #((#field_names, #field_help, #field_defaults),)*
121 ]
122 }
123
124 pub fn struct_doc() -> &'static str {
126 #struct_doc
127 }
128
129 pub fn to_toml_fields(&self) -> Vec<(&'static str, String)> {
131 vec![
132 #(#field_accessors),*
133 ]
134 }
135 }
136 };
137
138 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#[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 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 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 if !doc.is_empty() {
170 output_parts.push(quote! {
171 output.push_str(&format!("# {}\n", #doc));
172 });
173 }
174
175 if let Some(default) = default_value {
177 output_parts.push(quote! {
178 output.push_str(&format!("# Default: {}\n", #default));
179 });
180 }
181
182 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 let type_ident = syn::Ident::new(type_name, proc_macro2::Span::call_site());
189
190 output_parts.push(quote! {
191 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 output.push_str(&format!("[{}]\n", #field_name_str));
200
201 let field_values = self.#field_name.to_toml_fields();
203
204 for (name, doc, default) in #type_ident::field_docs() {
206 if !doc.is_empty() {
208 output.push_str(&format!("# {}\n", doc));
209 }
210
211
212 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 pub fn to_toml_with_comments(&self) -> String {
225 let mut output = String::new();
226 #(#output_parts)*
227 output
228 }
229 }
230 }
231}