cnfg_derive/
lib.rs

1use darling::{Error, FromField, FromMeta};
2use proc_macro::TokenStream;
3use proc_macro2::Span;
4use quote::{ToTokens, quote};
5use syn::{Attribute, Data, DeriveInput, Expr, Fields, Lit, Meta, Type, parse_macro_input};
6
7/// Parsed representation of a field with #[cnfg(...)] attributes.
8#[derive(Debug, FromField)]
9#[darling(attributes(cnfg))]
10struct CnfgField {
11    ident: Option<syn::Ident>,
12    ty: syn::Type,
13
14    #[darling(default)]
15    default: Option<syn::Lit>,
16
17    #[darling(default)]
18    env: Option<String>,
19
20    /// CLI flag support (bare or explicit).
21    #[darling(default)]
22    cli: Option<CliAttr>,
23
24    #[darling(default)]
25    required: bool,
26
27    #[darling(default)]
28    nested: bool,
29
30    #[darling(default, multiple, rename = "validate")]
31    validators: Vec<ValidatorAttr>,
32}
33
34/// Represents `#[cnfg(cli)]` or `#[cnfg(cli = "--flag")]`.
35#[derive(Debug, Clone)]
36enum CliAttr {
37    /// bare form: `#[cnfg(cli)]`
38    Flag,
39    /// explicit flag form: `#[cnfg(cli = "--custom")]`
40    Custom(String),
41}
42
43impl FromMeta for CliAttr {
44    fn from_meta(meta: &syn::Meta) -> Result<Self, Error> {
45        match meta {
46            syn::Meta::Path(_) => Ok(CliAttr::Flag),
47            syn::Meta::NameValue(nv) => match &nv.value {
48                syn::Expr::Lit(expr_lit) => parse_cli_lit(&expr_lit.lit),
49                other => Err(Error::custom("expected a literal value").with_span(other)),
50            },
51            syn::Meta::List(list) => Err(Error::custom(
52                "unsupported cli format; use #[cnfg(cli)] or #[cnfg(cli = \"--flag\")]",
53            )
54            .with_span(list)),
55        }
56    }
57}
58
59fn parse_cli_lit(lit: &Lit) -> Result<CliAttr, Error> {
60    match lit {
61        Lit::Str(s) => Ok(CliAttr::Custom(s.value())),
62        Lit::Bool(b) => {
63            if b.value() {
64                Ok(CliAttr::Flag)
65            } else {
66                Err(Error::custom(
67                    "use #[cnfg(cli)] to enable CLI parsing; remove the attribute to disable it",
68                )
69                .with_span(lit))
70            }
71        }
72        _ => Err(Error::custom("expected a string flag or boolean true").with_span(lit)),
73    }
74}
75
76/// Validator attributes: range, regex, url.
77#[derive(Debug, FromMeta)]
78#[darling(rename_all = "kebab-case")]
79enum ValidatorAttr {
80    Range(RangeArgs),
81    Regex(String),
82    Url,
83}
84
85#[derive(Debug, Default, FromMeta)]
86struct RangeArgs {
87    #[darling(default)]
88    min: Option<f64>,
89    #[darling(default)]
90    max: Option<f64>,
91}
92
93#[proc_macro_derive(Cnfg, attributes(cnfg))]
94pub fn derive_cnfg(input: TokenStream) -> TokenStream {
95    let input = parse_macro_input!(input as DeriveInput);
96    let name = input.ident;
97
98    let struct_doc_tokens = doc_option_tokens(doc_from_attrs(&input.attrs));
99
100    let fields = match &input.data {
101        Data::Struct(ds) => match &ds.fields {
102            Fields::Named(n) => &n.named,
103            _ => panic!("Cnfg expects a struct with named fields"),
104        },
105        _ => panic!("Cnfg expects a struct"),
106    };
107
108    let mut defaults_kv = Vec::new();
109    let mut field_spec_stmts = Vec::new();
110    let mut cli_spec_stmts = Vec::new();
111    let mut required_stmts = Vec::new();
112    let mut validate_body = Vec::new();
113
114    for f in fields {
115        let cf = CnfgField::from_field(f).expect("parse #[cnfg] attributes");
116        let ident = cf.ident.clone().expect("cnfg requires named fields");
117        let fname = ident.to_string();
118        let path_lit = syn::LitStr::new(&fname, Span::call_site());
119        let field_name_lit = path_lit.clone();
120        let required_flag = cf.required;
121        let nested_flag = cf.nested;
122        let field_doc_for_field = doc_option_tokens(doc_from_attrs(&f.attrs));
123        let field_doc_for_cli = field_doc_for_field.clone();
124        let env_tokens = option_str_tokens(cf.env.as_deref());
125        let (is_option, inner_ty) = option_inner(&cf.ty);
126        let nested_ty = if nested_flag && is_option {
127            inner_ty
128        } else {
129            &cf.ty
130        };
131
132        let mut field_kind = kind_for_type(&cf.ty);
133        if nested_flag {
134            field_kind = quote! { cnfg::Kind::Object };
135        }
136
137        let default_literal = cf.default.as_ref().map(default_literal);
138        let default_tokens_field = option_str_tokens(default_literal.as_deref());
139        let default_tokens_cli = default_tokens_field.clone();
140
141        if let Some(lit) = cf.default.clone() {
142            defaults_kv.push(quote! {
143                map.insert(#fname.to_string(), serde_json::json!(#lit));
144            });
145        } else if nested_flag {
146            defaults_kv.push(quote! {
147                map.insert(#fname.to_string(), <#nested_ty as cnfg::ConfigMeta>::defaults_json());
148            });
149        }
150
151        field_spec_stmts.push(quote! {
152            items.push(cnfg::FieldSpec {
153                name: #field_name_lit,
154                env: #env_tokens,
155                path: #path_lit,
156                doc: #field_doc_for_field,
157                kind: #field_kind,
158                default: #default_tokens_field,
159                required: #required_flag,
160            });
161        });
162
163        if required_flag {
164            required_stmts.push(quote! {
165                required.push(#path_lit);
166            });
167        }
168
169        if let Some(cli_attr) = &cf.cli {
170            let flag_raw = match cli_attr {
171                CliAttr::Flag => fname.replace('_', "-"),
172                CliAttr::Custom(explicit) => explicit.trim_start_matches("--").to_string(),
173            };
174            let flag_lit = syn::LitStr::new(&flag_raw, Span::call_site());
175            let cli_kind = kind_for_type(&cf.ty);
176            let takes_value_tokens = if is_bool(inner_ty) {
177                quote! { false }
178            } else {
179                quote! { true }
180            };
181            cli_spec_stmts.push(quote! {
182                items.push(cnfg::CliSpec {
183                    flag: #flag_lit,
184                    field: #field_name_lit,
185                    kind: #cli_kind,
186                    path: #path_lit,
187                    doc: #field_doc_for_cli,
188                    takes_value: #takes_value_tokens,
189                    default: #default_tokens_cli,
190                    required: #required_flag,
191                });
192            });
193        }
194
195        for v in cf.validators.iter() {
196            match v {
197                ValidatorAttr::Range(args) => {
198                    let checks = range_checks(&ident, &cf.ty, args.min, args.max);
199                    validate_body.push(checks);
200                }
201                ValidatorAttr::Regex(pattern) => {
202                    if is_string_type(&cf.ty) {
203                        if is_option_type(&cf.ty) {
204                            validate_body.push(quote! {
205                                if let Some(s) = &self.#ident {
206                                    let re = regex::Regex::new(#pattern).expect("invalid regex");
207                                    if !re.is_match(s) {
208                                        errs.push(cnfg::error::Issue {
209                                            field: #fname.to_string(),
210                                            kind: cnfg::error::IssueKind::Regex,
211                                            message: format!("regex not matched: {}", #pattern),
212                                        });
213                                    }
214                                }
215                            });
216                        } else {
217                            validate_body.push(quote! {
218                                let re = regex::Regex::new(#pattern).expect("invalid regex");
219                                if !re.is_match(&self.#ident) {
220                                    errs.push(cnfg::error::Issue {
221                                        field: #fname.to_string(),
222                                        kind: cnfg::error::IssueKind::Regex,
223                                        message: format!("regex not matched: {}", #pattern),
224                                    });
225                                }
226                            });
227                        }
228                    }
229                }
230                ValidatorAttr::Url => {
231                    if is_string_type(&cf.ty) {
232                        if is_option_type(&cf.ty) {
233                            validate_body.push(quote! {
234                                if let Some(s) = &self.#ident {
235                                    if url::Url::parse(s).is_err() {
236                                        errs.push(cnfg::error::Issue {
237                                            field: #fname.to_string(),
238                                            kind: cnfg::error::IssueKind::Url,
239                                            message: "invalid URL".to_string(),
240                                        });
241                                    }
242                                }
243                            });
244                        } else {
245                            validate_body.push(quote! {
246                                if url::Url::parse(&self.#ident).is_err() {
247                                    errs.push(cnfg::error::Issue {
248                                        field: #fname.to_string(),
249                                        kind: cnfg::error::IssueKind::Url,
250                                        message: "invalid URL".to_string(),
251                                    });
252                                }
253                            });
254                        }
255                    }
256                }
257            }
258        }
259
260        if nested_flag {
261            let prefix = path_lit.clone();
262            field_spec_stmts.push(quote! {
263                for nested in <#nested_ty as cnfg::ConfigMeta>::field_specs() {
264                    items.push(nested.with_prefix(#prefix));
265                }
266            });
267            cli_spec_stmts.push(quote! {
268                for nested in <#nested_ty as cnfg::ConfigMeta>::cli_specs() {
269                    items.push(nested.with_prefix(#prefix));
270                }
271            });
272            if !is_option {
273                required_stmts.push(quote! {
274                    for nested in <#nested_ty as cnfg::ConfigMeta>::required_fields() {
275                        required.push(cnfg::util::leak_string(format!("{}.{nested}", #prefix)));
276                    }
277                });
278            }
279            if is_option {
280                validate_body.push(quote! {
281                    if let Some(value) = &self.#ident {
282                        if let Err(nested_errs) = <#nested_ty as cnfg::Validate>::validate(value) {
283                            errs.extend(nested_errs.with_prefix(#prefix));
284                        }
285                    }
286                });
287            } else {
288                validate_body.push(quote! {
289                    if let Err(nested_errs) = <#nested_ty as cnfg::Validate>::validate(&self.#ident) {
290                        errs.extend(nested_errs.with_prefix(#prefix));
291                    }
292                });
293            }
294        }
295    }
296
297    let tokens = quote! {
298        impl cnfg::ConfigMeta for #name {
299            fn defaults_json() -> serde_json::Value {
300                let mut map = serde_json::Map::new();
301                #(#defaults_kv)*
302                serde_json::Value::Object(map)
303            }
304            fn field_specs() -> &'static [cnfg::FieldSpec] {
305                static FIELD_SPECS: std::sync::OnceLock<Vec<cnfg::FieldSpec>> = std::sync::OnceLock::new();
306                FIELD_SPECS.get_or_init(|| {
307                    let mut items = Vec::new();
308                    #(#field_spec_stmts)*
309                    items
310                }).as_slice()
311            }
312            fn cli_specs() -> &'static [cnfg::CliSpec] {
313                static CLI_SPECS: std::sync::OnceLock<Vec<cnfg::CliSpec>> = std::sync::OnceLock::new();
314                CLI_SPECS.get_or_init(|| {
315                    let mut items = Vec::new();
316                    #(#cli_spec_stmts)*
317                    items
318                }).as_slice()
319            }
320            fn required_fields() -> &'static [&'static str] {
321                static REQUIRED: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new();
322                REQUIRED.get_or_init(|| {
323                    let mut required = Vec::new();
324                    #(#required_stmts)*
325                    required
326                }).as_slice()
327            }
328            fn doc() -> Option<&'static str> {
329                #struct_doc_tokens
330            }
331        }
332
333        impl cnfg::Validate for #name {
334            fn validate(&self) -> Result<(), cnfg::ValidationErrors> {
335                let mut errs = cnfg::ValidationErrors::new();
336                #(#validate_body)*
337                if errs.is_empty() { Ok(()) } else { Err(errs) }
338            }
339        }
340
341        impl cnfg::LoaderExt for #name {
342            fn validate(&self) -> Result<(), cnfg::ValidationErrors> {
343                <Self as cnfg::Validate>::validate(self)
344            }
345        }
346
347        impl #name {
348            /// Load config using defaults, files, env, CLI, and validations.
349            pub fn load() -> Result<Self, cnfg::CnfgError> {
350                <Self as cnfg::LoaderExt>::load()
351            }
352        }
353    };
354    tokens.into()
355}
356
357// ---------- helpers ----------
358
359fn kind_for_type(ty: &Type) -> proc_macro2::TokenStream {
360    let (is_option, inner) = option_inner(ty);
361    let t = if is_option { inner } else { ty };
362    if is_bool(t) {
363        quote! { cnfg::Kind::Bool }
364    } else if is_int(t) {
365        quote! { cnfg::Kind::Int }
366    } else if is_float(t) {
367        quote! { cnfg::Kind::Float }
368    } else {
369        quote! { cnfg::Kind::String }
370    }
371}
372
373fn option_inner<'a>(ty: &'a Type) -> (bool, &'a Type) {
374    if let Type::Path(tp) = ty {
375        if tp.path.segments.len() == 1 && tp.path.segments[0].ident == "Option" {
376            if let syn::PathArguments::AngleBracketed(ab) = &tp.path.segments[0].arguments {
377                if let Some(syn::GenericArgument::Type(inner)) = ab.args.first() {
378                    return (true, inner);
379                }
380            }
381        }
382    }
383    (false, ty)
384}
385
386fn is_option_type(ty: &Type) -> bool {
387    option_inner(ty).0
388}
389
390fn is_string_type(ty: &Type) -> bool {
391    let (_, inner) = option_inner(ty);
392    match inner {
393        Type::Path(tp) => tp
394            .path
395            .segments
396            .last()
397            .map(|s| s.ident == "String")
398            .unwrap_or(false),
399        _ => false,
400    }
401}
402
403fn is_bool(ty: &Type) -> bool {
404    is_ident(ty, &["bool"])
405}
406
407fn is_float(ty: &Type) -> bool {
408    is_ident(ty, &["f32", "f64"])
409}
410
411fn is_int(ty: &Type) -> bool {
412    is_ident(
413        ty,
414        &[
415            "i8", "i16", "i32", "i64", "i128", "u8", "u16", "u32", "u64", "u128",
416        ],
417    )
418}
419
420fn is_ident(ty: &Type, names: &[&str]) -> bool {
421    if let Type::Path(tp) = ty {
422        if let Some(seg) = tp.path.segments.last() {
423            return names.iter().any(|n| seg.ident == *n);
424        }
425    }
426    false
427}
428
429fn range_checks(
430    ident: &syn::Ident,
431    ty: &Type,
432    min: Option<f64>,
433    max: Option<f64>,
434) -> proc_macro2::TokenStream {
435    if !(is_int(ty)
436        || is_float(ty)
437        || (is_option_type(ty) && {
438            let (_, inner) = option_inner(ty);
439            is_int(inner) || is_float(inner)
440        }))
441    {
442        return quote! {};
443    }
444
445    let fname = ident.to_string();
446
447    if is_option_type(ty) {
448        let min_clause = min
449            .map(|m| {
450                quote! {
451                    if __f < #m as f64 {
452                        errs.push(cnfg::error::Issue {
453                            field: #fname.to_string(),
454                            kind: cnfg::error::IssueKind::Range,
455                            message: format!("must be >= {}", #m),
456                        });
457                    }
458                }
459            })
460            .unwrap_or_else(|| quote! {});
461        let max_clause = max
462            .map(|m| {
463                quote! {
464                    if __f > #m as f64 {
465                        errs.push(cnfg::error::Issue {
466                            field: #fname.to_string(),
467                            kind: cnfg::error::IssueKind::Range,
468                            message: format!("must be <= {}", #m),
469                        });
470                    }
471                }
472            })
473            .unwrap_or_else(|| quote! {});
474        quote! {
475            if let Some(__v) = &self.#ident {
476                let __f: f64 = (*__v) as f64;
477                #min_clause
478                #max_clause
479            }
480        }
481    } else {
482        let min_clause = min
483            .map(|m| {
484                quote! {
485                    if __f < #m as f64 {
486                        errs.push(cnfg::error::Issue {
487                            field: #fname.to_string(),
488                            kind: cnfg::error::IssueKind::Range,
489                            message: format!("must be >= {}", #m),
490                        });
491                    }
492                }
493            })
494            .unwrap_or_else(|| quote! {});
495        let max_clause = max
496            .map(|m| {
497                quote! {
498                    if __f > #m as f64 {
499                        errs.push(cnfg::error::Issue {
500                            field: #fname.to_string(),
501                            kind: cnfg::error::IssueKind::Range,
502                            message: format!("must be <= {}", #m),
503                        });
504                    }
505                }
506            })
507            .unwrap_or_else(|| quote! {});
508        quote! {
509            let __f: f64 = (self.#ident) as f64;
510            #min_clause
511            #max_clause
512        }
513    }
514}
515
516fn doc_from_attrs(attrs: &[Attribute]) -> Option<String> {
517    let mut docs = Vec::new();
518    for attr in attrs {
519        if let Meta::NameValue(nv) = attr.meta.clone() {
520            if nv.path.is_ident("doc") {
521                if let Expr::Lit(expr_lit) = nv.value {
522                    if let Lit::Str(lit_str) = expr_lit.lit {
523                        let line = lit_str.value().trim().to_string();
524                        if !line.is_empty() {
525                            docs.push(line);
526                        }
527                    }
528                }
529            }
530        }
531    }
532    if docs.is_empty() {
533        None
534    } else {
535        Some(docs.join("\n"))
536    }
537}
538
539fn doc_option_tokens(doc: Option<String>) -> proc_macro2::TokenStream {
540    match doc {
541        Some(text) => {
542            let lit = syn::LitStr::new(&text, Span::call_site());
543            quote! { Some(#lit) }
544        }
545        None => quote! { None },
546    }
547}
548
549fn option_str_tokens(value: Option<&str>) -> proc_macro2::TokenStream {
550    match value {
551        Some(text) => {
552            let lit = syn::LitStr::new(text, Span::call_site());
553            quote! { Some(#lit) }
554        }
555        None => quote! { None },
556    }
557}
558
559fn default_literal(lit: &Lit) -> String {
560    match lit {
561        Lit::Str(s) => s.value(),
562        Lit::Bool(b) => b.value().to_string(),
563        Lit::Int(i) => i.base10_digits().to_string(),
564        Lit::Float(f) => f.base10_digits().to_string(),
565        _ => lit.to_token_stream().to_string(),
566    }
567}