Skip to main content

confroid_derive/
lib.rs

1//! Derive macro for [`confroid`](https://docs.rs/confroid).
2//!
3//! `#[derive(Config)]` generates a `confroid::FromEnv` implementation for a
4//! named struct. See the `confroid` crate for usage.
5
6use darling::util::Flag;
7use darling::{FromDeriveInput, FromField, ast};
8use proc_macro::TokenStream;
9use quote::quote;
10use syn::{DeriveInput, parse_macro_input};
11
12/// An arbitrary expression captured from an attribute value.
13///
14/// darling's built-in `syn::Expr` handling rejects bare literals such as
15/// `default = 8080`, so we capture the expression ourselves — literals wrapped
16/// as literal expressions, everything else cloned verbatim.
17#[derive(Clone)]
18struct MetaExpr(#[allow(dead_code)] syn::Expr);
19
20impl darling::FromMeta for MetaExpr {
21    fn from_expr(expr: &syn::Expr) -> darling::Result<Self> {
22        Ok(MetaExpr(expr.clone()))
23    }
24
25    fn from_value(value: &syn::Lit) -> darling::Result<Self> {
26        Ok(MetaExpr(lit_to_expr(value)))
27    }
28}
29
30/// The `default` attribute: either bare (`default` -> `Default::default()`) or
31/// an explicit expression (`default = expr`).
32///
33/// This is a dedicated type rather than `Override<MetaExpr>` because darling's
34/// `Override` rejects non-literal expression values before delegating, which
35/// would reject a path default such as `default = MY_CONST`.
36#[derive(Clone)]
37enum DefaultKind {
38    Inherit,
39    Explicit(syn::Expr),
40}
41
42impl darling::FromMeta for DefaultKind {
43    fn from_word() -> darling::Result<Self> {
44        Ok(DefaultKind::Inherit)
45    }
46
47    fn from_expr(expr: &syn::Expr) -> darling::Result<Self> {
48        Ok(DefaultKind::Explicit(expr.clone()))
49    }
50
51    fn from_value(value: &syn::Lit) -> darling::Result<Self> {
52        Ok(DefaultKind::Explicit(lit_to_expr(value)))
53    }
54}
55
56fn lit_to_expr(lit: &syn::Lit) -> syn::Expr {
57    syn::Expr::Lit(syn::ExprLit {
58        attrs: Vec::new(),
59        lit: lit.clone(),
60    })
61}
62
63#[derive(FromField)]
64#[darling(attributes(confroid), forward_attrs(doc))]
65#[allow(dead_code)]
66struct FieldOpts {
67    ident: Option<syn::Ident>,
68    ty: syn::Type,
69    /// Override the derived environment variable name / prefix.
70    name: Option<String>,
71    /// `default` (bare) -> `Default::default()`, `default = expr` -> `expr`.
72    default: Option<DefaultKind>,
73    /// Example value, used only for documentation generation.
74    example: Option<MetaExpr>,
75    /// Parse a single delimited variable into a `Vec` instead of indexed keys.
76    auto_vec: Flag,
77    /// Delimiter for `auto_vec` (defaults to `,`).
78    auto_vec_delimiter: Option<String>,
79    /// Forwarded `#[doc]` attributes, used for documentation generation.
80    attrs: Vec<syn::Attribute>,
81}
82
83#[derive(FromDeriveInput)]
84#[darling(attributes(confroid), supports(struct_named))]
85struct InputOpts {
86    ident: syn::Ident,
87    generics: syn::Generics,
88    data: ast::Data<darling::util::Ignored, FieldOpts>,
89    /// Prefix all environment variables read by this struct.
90    prefix: Option<String>,
91}
92
93/// Derive `confroid::FromEnv` for a named struct.
94///
95/// `Config` maps struct fields to environment variables. Field names are
96/// converted to `SCREAMING_SNAKE_CASE`, and nested segments are joined with
97/// `__`.
98///
99/// # Example
100///
101/// ```ignore
102/// #[derive(confroid::Config)]
103/// #[confroid(prefix = "GUPPY")]
104/// struct Config {
105///     #[confroid(default = 8080)]
106///     port: u16,
107///     database: Database,
108/// }
109///
110/// #[derive(confroid::Config)]
111/// struct Database {
112///     #[confroid(name = "URL")]
113///     connection_string: String,
114/// }
115///
116/// // Reads `GUPPY__PORT` and `GUPPY__DATABASE__URL`.
117/// let config: Config = confroid::from_env()?;
118/// # Ok::<(), confroid::ConfroidError>(())
119/// ```
120///
121/// # Container attributes
122///
123/// Container attributes are placed on the struct below `#[derive(Config)]`:
124///
125/// - **`prefix = "..."`** — prepend a segment to every environment variable
126///   represented by the struct. For example, `#[confroid(prefix = "GUPPY")]`
127///   makes a field named `port` read from `GUPPY__PORT`. Prefixes compose when
128///   a prefixed struct is nested inside another config.
129///
130/// # Field attributes
131///
132/// Field attributes customize naming, fallback values, parsing, and generated
133/// configuration documentation:
134///
135/// - **`name = "..."`** — override the field's environment-variable segment.
136///   On a nested field, the override applies to the entire nested prefix.
137/// - **`default`** — use [`Default::default()`] when the variable is absent.
138/// - **`default = expression`** — use an explicit fallback when the variable is
139///   absent. String literals and compatible expressions are converted with
140///   [`Into::into`]. A present value that fails to parse still returns an error.
141/// - **`example = expression`** — attach an example value for
142///   `confroid::env_example` and `confroid::markdown_table` when the `docs`
143///   feature is enabled. It does not affect parsing.
144/// - **`auto_vec`** — parse a `Vec<T>` from one delimited variable rather than
145///   indexed variables such as `NAMES__0` and `NAMES__1`.
146/// - **`auto_vec_delimiter = "..."`** — set the delimiter used by `auto_vec`;
147///   the default is `,`.
148///
149/// Attributes can be combined:
150///
151/// ```ignore
152/// #[derive(confroid::Config)]
153/// struct Config {
154///     #[confroid(
155///         name = "ALLOWED_HOSTS",
156///         auto_vec,
157///         auto_vec_delimiter = ";",
158///         default
159///     )]
160///     hosts: Vec<String>,
161/// }
162/// ```
163///
164/// # Supported structs
165///
166/// The derive supports structs with named fields. Each field's type must
167/// implement `confroid::FromEnv`. Optional values, nested configs, vectors, and
168/// hash maps use their `FromEnv` implementations to determine presence and
169/// deserialize their children.
170#[proc_macro_derive(Config, attributes(confroid))]
171pub fn derive_config(input: TokenStream) -> TokenStream {
172    let di = parse_macro_input!(input as DeriveInput);
173    let opts = match InputOpts::from_derive_input(&di) {
174        Ok(opts) => opts,
175        Err(err) => return err.write_errors().into(),
176    };
177    match expand(&opts) {
178        Ok(tokens) => tokens.into(),
179        Err(err) => err.write_errors().into(),
180    }
181}
182
183fn expand(opts: &InputOpts) -> darling::Result<proc_macro2::TokenStream> {
184    let ident = &opts.ident;
185    let prefix = opts.prefix.as_deref().unwrap_or("");
186    let (impl_generics, ty_generics, where_clause) = opts.generics.split_for_impl();
187
188    let fields = opts
189        .data
190        .as_ref()
191        .take_struct()
192        .expect("supports(struct_named) guarantees a struct")
193        .fields;
194
195    let mut errors = darling::Error::accumulator();
196    let mut field_inits = Vec::with_capacity(fields.len());
197    let mut field_docs = Vec::with_capacity(fields.len());
198
199    for field in fields {
200        let fname = field
201            .ident
202            .as_ref()
203            .expect("named struct fields always have an ident");
204        let fname_str = fname.to_string();
205        let var_seg = field
206            .name
207            .clone()
208            .unwrap_or_else(|| screaming_snake(&fname_str));
209        let fty = &field.ty;
210
211        // The core read expression evaluates to `confroid::Result<#fty>`.
212        let read_expr = if field.auto_vec.is_present() {
213            match vec_inner(fty) {
214                Some(elem_ty) => {
215                    let delim = field
216                        .auto_vec_delimiter
217                        .clone()
218                        .unwrap_or_else(|| ",".to_string());
219                    auto_vec_read(&var_seg, &fname_str, elem_ty, &delim)
220                }
221                None => {
222                    errors.push(
223                        darling::Error::custom("`auto_vec` requires a `Vec<T>` field")
224                            .with_span(fty),
225                    );
226                    continue;
227                }
228            }
229        } else {
230            quote! {
231                {
232                    let __ctx = ctx.field(#var_seg, #fname_str);
233                    <#fty as ::confroid::FromEnv>::from_env(&__ctx, env)
234                }
235            }
236        };
237
238        let init = match &field.default {
239            None => quote! { #fname: (#read_expr)? },
240            Some(DefaultKind::Inherit) => quote! {
241                #fname: match #read_expr {
242                    ::core::result::Result::Ok(__v) => __v,
243                    ::core::result::Result::Err(::confroid::ConfroidError::EnvVarNotFound { .. }) =>
244                        <#fty as ::core::default::Default>::default(),
245                    ::core::result::Result::Err(__e) => return ::core::result::Result::Err(__e),
246                }
247            },
248            Some(DefaultKind::Explicit(expr)) => {
249                let default_val = default_value(expr);
250                quote! {
251                    #fname: match #read_expr {
252                        ::core::result::Result::Ok(__v) => __v,
253                        ::core::result::Result::Err(::confroid::ConfroidError::EnvVarNotFound { .. }) =>
254                            #default_val,
255                        ::core::result::Result::Err(__e) => return ::core::result::Result::Err(__e),
256                    }
257                }
258            }
259        };
260
261        field_inits.push(init);
262
263        // Documentation metadata for this field.
264        let doc_tokens = match extract_doc(&field.attrs) {
265            Some(doc) => quote! { ::core::option::Option::Some(#doc) },
266            None => quote! { ::core::option::Option::None },
267        };
268        // Default/example annotations are only rendered for displayable scalar
269        // leaves; collections (and a bare default on an `Option`) have no
270        // single displayable value.
271        let is_collection = type_is_named(fty, "Vec") || type_is_named(fty, "HashMap");
272        let is_option = type_is_named(fty, "Option");
273        let none = quote! { ::core::option::Option::None };
274        let default_doc = match &field.default {
275            Some(DefaultKind::Inherit) if !is_collection && !is_option => quote! {
276                ::core::option::Option::Some(
277                    ::std::format!("{}", <#fty as ::core::default::Default>::default())
278                )
279            },
280            Some(DefaultKind::Explicit(expr)) if !is_collection => quote! {
281                ::core::option::Option::Some(::std::format!("{}", #expr))
282            },
283            _ => none.clone(),
284        };
285        let example_doc = match &field.example {
286            Some(MetaExpr(expr)) if !is_collection => quote! {
287                ::core::option::Option::Some(::std::format!("{}", #expr))
288            },
289            _ => none.clone(),
290        };
291        field_docs.push(quote! {
292            ::confroid::FieldDoc {
293                var_seg: #var_seg,
294                name: #fname_str,
295                doc: #doc_tokens,
296                default: #default_doc,
297                example: #example_doc,
298                children: <#fty as ::confroid::Documented>::doc_fields(),
299            }
300        });
301    }
302
303    errors.finish()?;
304
305    let documented_fields = if prefix.is_empty() {
306        quote! { ::std::vec![#(#field_docs),*] }
307    } else {
308        quote! {
309            ::std::vec![::confroid::FieldDoc {
310                var_seg: #prefix,
311                name: "",
312                doc: ::core::option::Option::None,
313                default: ::core::option::Option::None,
314                example: ::core::option::Option::None,
315                children: ::core::option::Option::Some(::std::vec![#(#field_docs),*]),
316            }]
317        }
318    };
319
320    let documented_impl = if cfg!(feature = "docs") {
321        let ident_str = ident.to_string();
322        quote! {
323            #[automatically_derived]
324            impl #impl_generics ::confroid::Documented for #ident #ty_generics #where_clause {
325                fn doc_name() -> &'static str {
326                    #ident_str
327                }
328
329                fn doc_fields() -> ::core::option::Option<::std::vec::Vec<::confroid::FieldDoc>> {
330                    ::core::option::Option::Some(#documented_fields)
331                }
332            }
333        }
334    } else {
335        quote! {}
336    };
337
338    Ok(quote! {
339        #[automatically_derived]
340        impl #impl_generics ::confroid::FromEnv for #ident #ty_generics #where_clause {
341            fn from_env(
342                ctx: &::confroid::Ctx,
343                env: &::confroid::Env,
344            ) -> ::confroid::Result<Self> {
345                let __confroid_ctx = ctx.prefix(#prefix);
346                let ctx = &__confroid_ctx;
347                ::core::result::Result::Ok(Self {
348                    #(#field_inits),*
349                })
350            }
351
352            fn is_present(ctx: &::confroid::Ctx, env: &::confroid::Env) -> bool {
353                let __confroid_ctx = ctx.prefix(#prefix);
354                !env.immediate_children(&__confroid_ctx.var).is_empty()
355            }
356        }
357
358        #documented_impl
359    })
360}
361
362/// Delimited-value parsing for an `auto_vec` field.
363fn auto_vec_read(
364    var_seg: &str,
365    fname_str: &str,
366    elem_ty: &syn::Type,
367    delim: &str,
368) -> proc_macro2::TokenStream {
369    quote! {
370        {
371            let __ctx = ctx.field(#var_seg, #fname_str);
372            match env.get(&__ctx.var) {
373                ::core::option::Option::None => ::core::result::Result::Err(
374                    ::confroid::ConfroidError::EnvVarNotFound {
375                        var_name: __ctx.var.clone(),
376                        field: __ctx.path.clone(),
377                    }
378                ),
379                ::core::option::Option::Some(__raw) if __raw.is_empty() =>
380                    ::core::result::Result::Ok(::std::vec::Vec::new()),
381                ::core::option::Option::Some(__raw) => {
382                    let mut __out: ::std::vec::Vec<#elem_ty> = ::std::vec::Vec::new();
383                    let mut __result = ::core::result::Result::Ok(());
384                    for __part in __raw.split(#delim) {
385                        match __part.parse::<#elem_ty>() {
386                            ::core::result::Result::Ok(__v) => __out.push(__v),
387                            ::core::result::Result::Err(__e) => {
388                                __result = ::core::result::Result::Err(
389                                    ::confroid::ConfroidError::EnvVarInvalid {
390                                        var_name: __ctx.var.clone(),
391                                        field: __ctx.path.clone(),
392                                        value: __part.to_string(),
393                                        parser_error: __e.to_string(),
394                                    }
395                                );
396                                break;
397                            }
398                        }
399                    }
400                    __result.map(|()| __out)
401                }
402            }
403        }
404    }
405}
406
407/// Tokens for an explicit `default = expr`.
408///
409/// Numeric literals are emitted verbatim so integer/float literal inference
410/// coerces them to the field type. Everything else is wrapped in `Into::into`
411/// so that, for example, a `&str` default lands in a `String` field.
412fn default_value(expr: &syn::Expr) -> proc_macro2::TokenStream {
413    if is_numeric_literal(expr) {
414        quote! { #expr }
415    } else {
416        quote! { ::core::convert::Into::into(#expr) }
417    }
418}
419
420fn is_numeric_literal(expr: &syn::Expr) -> bool {
421    match expr {
422        syn::Expr::Lit(syn::ExprLit { lit, .. }) => {
423            matches!(lit, syn::Lit::Int(_) | syn::Lit::Float(_))
424        }
425        syn::Expr::Unary(syn::ExprUnary {
426            op: syn::UnOp::Neg(_),
427            expr,
428            ..
429        }) => is_numeric_literal(expr),
430        syn::Expr::Group(g) => is_numeric_literal(&g.expr),
431        syn::Expr::Paren(p) => is_numeric_literal(&p.expr),
432        _ => false,
433    }
434}
435
436/// Whether `ty`'s last path segment is the given identifier (e.g. `Vec`).
437fn type_is_named(ty: &syn::Type, name: &str) -> bool {
438    matches!(ty, syn::Type::Path(tp) if tp.path.segments.last().is_some_and(|s| s.ident == name))
439}
440
441/// The element type `T` of a `Vec<T>`, if `ty` is a vector.
442fn vec_inner(ty: &syn::Type) -> Option<&syn::Type> {
443    let syn::Type::Path(tp) = ty else {
444        return None;
445    };
446    let seg = tp.path.segments.last()?;
447    if seg.ident != "Vec" {
448        return None;
449    }
450    let syn::PathArguments::AngleBracketed(args) = &seg.arguments else {
451        return None;
452    };
453    args.args.iter().find_map(|arg| match arg {
454        syn::GenericArgument::Type(inner) => Some(inner),
455        _ => None,
456    })
457}
458
459/// Collect and clean the text of `#[doc]` attributes into a single string.
460fn extract_doc(attrs: &[syn::Attribute]) -> Option<String> {
461    let mut lines = Vec::new();
462    for attr in attrs {
463        if !attr.path().is_ident("doc") {
464            continue;
465        }
466        if let syn::Meta::NameValue(nv) = &attr.meta
467            && let syn::Expr::Lit(syn::ExprLit {
468                lit: syn::Lit::Str(s),
469                ..
470            }) = &nv.value
471        {
472            lines.push(s.value().trim().to_string());
473        }
474    }
475    if lines.is_empty() {
476        None
477    } else {
478        Some(lines.join(" "))
479    }
480}
481
482/// Convert an identifier to `SCREAMING_SNAKE_CASE`.
483fn screaming_snake(s: &str) -> String {
484    let mut out = String::new();
485    let mut prev_is_word = false;
486    for ch in s.chars() {
487        if ch == '_' {
488            out.push('_');
489            prev_is_word = false;
490            continue;
491        }
492        if ch.is_uppercase() && prev_is_word {
493            out.push('_');
494        }
495        out.extend(ch.to_uppercase());
496        prev_is_word = ch.is_lowercase() || ch.is_ascii_digit();
497    }
498    out
499}