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#[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 #[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#[derive(Debug, Clone)]
36enum CliAttr {
37 Flag,
39 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#[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 pub fn load() -> Result<Self, cnfg::CnfgError> {
350 <Self as cnfg::LoaderExt>::load()
351 }
352 }
353 };
354 tokens.into()
355}
356
357fn 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}