1use 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 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 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 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 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}