Skip to main content

cli_engine_macros/
lib.rs

1//! `#[derive(EnvConfig)]` — see `cli_engine::env_config` for the trait and
2//! runtime pieces this macro's generated code calls into.
3//!
4//! Per-field `#[env_config(...)]` attributes:
5//!
6//! - `key = "..."` — TOML key to look up (default: the field's Rust name).
7//! - `env = "SUFFIX"` — opt-in environment-variable suffix.
8//! - `default = <expr>` — literal fallback of the field's own type.
9//! - `default_fn = <path>` — `fn(&SourceChain<'_>) -> T`, computed lazily.
10//! - `from_toml = <path>` — `fn(&cli_engine::env_config::toml::Value) -> Result<T, String>`,
11//!   replaces the default `T: DeserializeOwned` conversion. Name the
12//!   parameter type through `cli_engine::env_config::toml` (re-exported)
13//!   rather than a direct `toml` dependency of your own, so your crate
14//!   doesn't need to track cli-engine's `toml` version.
15//! - `from_env = <path>` — `fn(&str) -> Result<T, String>`, replaces the
16//!   default `T: FromStr` conversion.
17//! - `to_toml = <path>` — `fn(T) -> cli_engine::env_config::toml::Value`,
18//!   replaces the default `T: Into<toml::Value>` conversion used when
19//!   building an
20//!   [`EnvTable`](../cli_engine/environments/struct.EnvTable.html) *from* an
21//!   instance (see `impl From<Self> for EnvTable`, generated alongside
22//!   `EnvConfig` so a compiled-in environment can be registered as a plain
23//!   struct value via `Environments::with_environment`).
24//! - `allow_blank` — bare marker (no value); by default, a source that
25//!   answers with an empty-or-whitespace-only string, or an empty TOML
26//!   array, is treated as not having answered at all, so the field keeps
27//!   looking at the rest of the `SourceChain` (and ultimately falls to
28//!   `default`/`default_fn`) instead of accepting `""`/`[]` literally. This
29//!   default fits nearly every field: a blank or empty override is
30//!   essentially always a mistake or an unset placeholder, never a real
31//!   value. Set `allow_blank` on the rare field where an explicit `""` or
32//!   `[]` is itself a meaningful, literal answer distinct from "unset."
33//!
34//! `default` and `default_fn` are mutually exclusive.
35
36use proc_macro::TokenStream;
37use quote::quote;
38use syn::{Data, DeriveInput, Fields, parse_macro_input};
39
40#[proc_macro_derive(EnvConfig, attributes(env_config))]
41pub fn derive_env_config(input: TokenStream) -> TokenStream {
42    let input = parse_macro_input!(input as DeriveInput);
43    expand(input)
44        .unwrap_or_else(syn::Error::into_compile_error)
45        .into()
46}
47
48#[derive(Default)]
49struct FieldAttrs {
50    key: Option<syn::LitStr>,
51    env: Option<syn::LitStr>,
52    default: Option<syn::Expr>,
53    default_fn: Option<syn::Expr>,
54    from_toml: Option<syn::Expr>,
55    from_env: Option<syn::Expr>,
56    to_toml: Option<syn::Expr>,
57    allow_blank: bool,
58}
59
60impl FieldAttrs {
61    fn parse(attrs: &[syn::Attribute]) -> syn::Result<Self> {
62        let mut out = Self::default();
63        for attr in attrs {
64            if !attr.path().is_ident("env_config") {
65                continue;
66            }
67            let metas = attr.parse_args_with(
68                syn::punctuated::Punctuated::<syn::Meta, syn::Token![,]>::parse_terminated,
69            )?;
70            for meta in metas {
71                match meta {
72                    syn::Meta::Path(path) if path.is_ident("allow_blank") => {
73                        out.allow_blank = true;
74                    }
75                    syn::Meta::NameValue(nv) => {
76                        let Some(name) = nv.path.get_ident().map(ToString::to_string) else {
77                            return Err(syn::Error::new_spanned(nv.path, "expected an identifier"));
78                        };
79                        match name.as_str() {
80                            "key" => out.key = Some(expect_lit_str(&nv.value)?),
81                            "env" => out.env = Some(expect_lit_str(&nv.value)?),
82                            "default" => out.default = Some(nv.value),
83                            "default_fn" => out.default_fn = Some(nv.value),
84                            "from_toml" => out.from_toml = Some(nv.value),
85                            "from_env" => out.from_env = Some(nv.value),
86                            "to_toml" => out.to_toml = Some(nv.value),
87                            other => {
88                                return Err(syn::Error::new_spanned(
89                                    nv.path,
90                                    format!(
91                                        "unknown env_config attribute `{other}`; expected one of key, env, default, default_fn, from_toml, from_env, to_toml, allow_blank"
92                                    ),
93                                ));
94                            }
95                        }
96                    }
97                    other => {
98                        return Err(syn::Error::new_spanned(
99                            other,
100                            "expected `name = value` or the bare marker `allow_blank` inside env_config(...)",
101                        ));
102                    }
103                }
104            }
105        }
106        if let (Some(_), Some(default_fn)) = (&out.default, &out.default_fn) {
107            return Err(syn::Error::new_spanned(
108                default_fn,
109                "`default` and `default_fn` are mutually exclusive",
110            ));
111        }
112        Ok(out)
113    }
114}
115
116fn expect_lit_str(expr: &syn::Expr) -> syn::Result<syn::LitStr> {
117    match expr {
118        syn::Expr::Lit(syn::ExprLit {
119            lit: syn::Lit::Str(s),
120            ..
121        }) => Ok(s.clone()),
122        other => Err(syn::Error::new_spanned(other, "expected a string literal")),
123    }
124}
125
126fn expand(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
127    let ident = &input.ident;
128    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
129
130    let Data::Struct(data) = &input.data else {
131        return Err(syn::Error::new_spanned(
132            &input,
133            "EnvConfig can only be derived for structs with named fields",
134        ));
135    };
136    let Fields::Named(fields) = &data.fields else {
137        return Err(syn::Error::new_spanned(
138            &input,
139            "EnvConfig requires named fields",
140        ));
141    };
142
143    let mut field_idents = Vec::new();
144    let mut field_stmts = Vec::new();
145    let mut table_stmts = Vec::new();
146
147    for field in &fields.named {
148        let field_ident = field
149            .ident
150            .as_ref()
151            .expect("Fields::Named guarantees an ident");
152        let ty = &field.ty;
153        let attrs = FieldAttrs::parse(&field.attrs)?;
154
155        let field_name_lit = syn::LitStr::new(&field_ident.to_string(), field_ident.span());
156        let key_lit = attrs.key.clone().unwrap_or_else(|| field_name_lit.clone());
157        let env_expr = match &attrs.env {
158            Some(lit) => quote! { ::core::option::Option::Some(#lit) },
159            None => quote! { ::core::option::Option::None },
160        };
161        let allow_blank_lit = attrs.allow_blank;
162        // Always wrapped in an explicit closure, never passed as a bare `fn`
163        // item — `resolve_field`'s `impl Fn(&toml::Value) -> ...` parameter
164        // is higher-ranked over the reference's lifetime, and rustc's
165        // function-pointer-to-HRTB-closure coercion is unreliable once `T`
166        // also needs to be inferred from a second, similarly-shaped
167        // parameter (`from_env`) at the same call site — it silently pins a
168        // bare `fn(&Value) -> _` item to one concrete lifetime and then
169        // rejects it. A closure has no such inference wrinkle.
170        let from_toml_expr = match &attrs.from_toml {
171            Some(expr) => {
172                quote! { |value: &::cli_engine::env_config::toml::Value| (#expr)(value) }
173            }
174            None => {
175                quote! { |value: &::cli_engine::env_config::toml::Value| ::cli_engine::env_config::default_from_toml::<#ty>(value) }
176            }
177        };
178        // Only require `T: FromStr` when the field is actually env-var
179        // overridable (`env` given) or a custom `from_env` is provided —
180        // `resolve_field` never calls `from_env` when `env_suffix` is `None`,
181        // but it still needs *some* well-typed callable to pass in, so a
182        // field with neither must not force a `FromStr` bound it doesn't need
183        // (e.g. `Vec<String>`, which has no `FromStr`, used TOML-only).
184        let from_env_expr = match (&attrs.from_env, &attrs.env) {
185            (Some(expr), _) => quote! { |raw: &str| (#expr)(raw) },
186            (None, Some(_)) => {
187                quote! { |raw: &str| ::cli_engine::env_config::default_from_env::<#ty>(raw) }
188            }
189            (None, None) => quote! {
190                |_raw: &str| -> ::core::result::Result<#ty, ::std::string::String> {
191                    ::core::result::Result::Err(::std::string::String::new())
192                }
193            },
194        };
195        let default_arm = if let Some(expr) = &attrs.default {
196            quote! { #expr }
197        } else if let Some(expr) = &attrs.default_fn {
198            quote! { (#expr)(sources) }
199        } else {
200            quote! {
201                return ::core::result::Result::Err(
202                    ::cli_engine::env_config::EnvConfigError::MissingField { field: #field_name_lit }
203                )
204            }
205        };
206
207        // Dual of `from_toml_expr`, for the `From<Self> for EnvTable`
208        // direction below — same reasoning applies, an explicit closure
209        // rather than a bare `fn` item.
210        let to_toml_expr = match &attrs.to_toml {
211            Some(expr) => quote! { (#expr)(value.#field_ident) },
212            None => {
213                quote! { ::core::convert::Into::<::cli_engine::env_config::toml::Value>::into(value.#field_ident) }
214            }
215        };
216
217        field_idents.push(field_ident.clone());
218        field_stmts.push(quote! {
219            let #field_ident: #ty = match ::cli_engine::env_config::resolve_field::<#ty>(
220                sources,
221                #field_name_lit,
222                #key_lit,
223                #env_expr,
224                #allow_blank_lit,
225                #from_toml_expr,
226                #from_env_expr,
227            )? {
228                ::core::option::Option::Some(value) => value,
229                ::core::option::Option::None => #default_arm,
230            };
231        });
232        table_stmts.push(quote! {
233            table = table.with(#key_lit, #to_toml_expr);
234        });
235    }
236
237    Ok(quote! {
238        #[automatically_derived]
239        impl #impl_generics ::cli_engine::env_config::EnvConfig for #ident #ty_generics #where_clause {
240            fn assemble(
241                sources: &::cli_engine::env_config::SourceChain<'_>,
242            ) -> ::core::result::Result<Self, ::cli_engine::env_config::EnvConfigError> {
243                #(#field_stmts)*
244                ::core::result::Result::Ok(Self { #(#field_idents,)* })
245            }
246        }
247
248        #[automatically_derived]
249        impl #impl_generics ::core::convert::From<#ident #ty_generics> for ::cli_engine::environments::EnvTable #where_clause {
250            /// Lets a compiled-in environment be registered as a plain struct
251            /// value — `Environments::with_environment(name, MyConfig { .. })`
252            /// — instead of a stringly-keyed `EnvTable`. Every field is
253            /// written unconditionally (a struct literal has no "absent"
254            /// state), using the same `key` each field's assembly
255            /// instructions use.
256            fn from(value: #ident #ty_generics) -> Self {
257                let mut table = ::cli_engine::environments::EnvTable::new();
258                #(#table_stmts)*
259                table
260            }
261        }
262    })
263}