use crate::options::{DefaultKind, InputOpts};
use quote::{format_ident, quote};
pub(crate) fn expand(opts: &InputOpts) -> syn::Result<proc_macro2::TokenStream> {
let ident = &opts.ident;
let prefix = opts.prefix.as_deref().unwrap_or("");
let (impl_generics, ty_generics, where_clause) = opts.generics.split_for_impl();
let fields = &opts.fields;
let mut errors = Vec::new();
let mut field_reads = Vec::with_capacity(fields.len());
let mut field_values = Vec::with_capacity(fields.len());
let mut field_docs = Vec::with_capacity(fields.len());
for field in fields {
let fname = &field.ident;
let fname_str = fname.to_string();
let var_seg = field
.name
.clone()
.unwrap_or_else(|| screaming_snake(&fname_str));
let fty = &field.ty;
let is_auto_vec = field.auto_vec;
let is_humantime = field.humantime;
if is_auto_vec && is_humantime {
errors.push(syn::Error::new_spanned(
fty,
"`auto_vec` and `humantime` cannot be combined",
));
continue;
}
if is_humantime && !cfg!(feature = "humantime") {
errors.push(syn::Error::new_spanned(
fty,
"`humantime` requires enabling confroid's `humantime` feature",
));
continue;
}
let read_expr = if is_humantime {
if type_is_named(fty, "Duration") {
humantime_read(&var_seg, &fname_str, false)
} else if option_inner(fty).is_some_and(|inner| type_is_named(inner, "Duration")) {
humantime_read(&var_seg, &fname_str, true)
} else {
errors.push(syn::Error::new_spanned(
fty,
"`humantime` requires a `Duration` or `Option<Duration>` field",
));
continue;
}
} else if is_auto_vec {
match vec_inner(fty) {
Some(elem_ty) => {
let delim = field
.auto_vec_delimiter
.clone()
.unwrap_or_else(|| ",".to_string());
auto_vec_read(&var_seg, &fname_str, elem_ty, &delim)
}
None => {
errors.push(syn::Error::new_spanned(
fty,
"`auto_vec` requires a `Vec<T>` field",
));
continue;
}
}
} else {
quote! {
{
let __ctx = ctx.field(#var_seg, #fname_str);
<#fty as ::confroid::FromEnv>::from_env(&__ctx, env)
}
}
};
let is_optional_humantime = is_humantime && option_inner(fty).is_some();
let presence_expr = if is_optional_humantime {
quote! {
{
let __ctx = ctx.field(#var_seg, #fname_str);
env.get(&__ctx.var).is_some_and(|__raw| !__raw.is_empty())
}
}
} else if is_auto_vec || is_humantime {
quote! {
{
let __ctx = ctx.field(#var_seg, #fname_str);
env.get(&__ctx.var).is_some()
}
}
} else {
quote! {
{
let __ctx = ctx.field(#var_seg, #fname_str);
<#fty as ::confroid::FromEnv>::is_present(&__ctx, env)
}
}
};
let result_expr = match &field.default {
None => read_expr,
Some(DefaultKind::Inherit) => quote! {
if #presence_expr {
#read_expr
} else {
::core::result::Result::Ok(
<#fty as ::core::default::Default>::default()
)
}
},
Some(DefaultKind::Explicit(expr)) => {
let default_val = default_value(expr);
quote! {
if #presence_expr {
#read_expr
} else {
::core::result::Result::Ok(#default_val)
}
}
}
};
let result_ident = format_ident!("__confroid_field_{fname}");
field_reads.push(quote! {
let #result_ident = match #result_expr {
::core::result::Result::Ok(__value) =>
::core::option::Option::Some(__value),
::core::result::Result::Err(__error) => {
__confroid_errors.extend(__error.into_errors());
::core::option::Option::None
}
};
});
field_values.push(quote! {
#fname: #result_ident.expect("field errors were checked before construction")
});
let doc_tokens = match extract_doc(&field.attrs) {
Some(doc) => quote! { ::core::option::Option::Some(#doc) },
None => quote! { ::core::option::Option::None },
};
let is_collection = type_is_named(fty, "Vec") || type_is_named(fty, "HashMap");
let is_option = type_is_named(fty, "Option");
let required = field.default.is_none() && !is_option;
let none = quote! { ::core::option::Option::None };
let default_doc = match &field.default {
Some(DefaultKind::Inherit) if !is_collection && !is_option => quote! {
::core::option::Option::Some(
::std::format!("{}", <#fty as ::core::default::Default>::default())
)
},
Some(DefaultKind::Explicit(expr)) if !is_collection && !is_option => quote! {
::core::option::Option::Some(::std::format!("{}", #expr))
},
_ => none.clone(),
};
let example_doc = match &field.example {
Some(expr) if !is_collection => quote! {
::core::option::Option::Some(::std::format!("{}", #expr))
},
_ => none.clone(),
};
field_docs.push(quote! {
::confroid::FieldDoc {
var_seg: #var_seg,
name: #fname_str,
doc: #doc_tokens,
default: #default_doc,
example: #example_doc,
required: #required,
children: <#fty as ::confroid::Documented>::doc_fields(),
}
});
}
let mut errors = errors.into_iter();
if let Some(mut error) = errors.next() {
for additional in errors {
error.combine(additional);
}
return Err(error);
}
let documented_fields = if prefix.is_empty() {
quote! { ::std::vec![#(#field_docs),*] }
} else {
quote! {
::std::vec![::confroid::FieldDoc {
var_seg: #prefix,
name: "",
doc: ::core::option::Option::None,
default: ::core::option::Option::None,
example: ::core::option::Option::None,
required: true,
children: ::core::option::Option::Some(::std::vec![#(#field_docs),*]),
}]
}
};
let documented_impl = if cfg!(feature = "docs") {
let ident_str = ident.to_string();
quote! {
#[automatically_derived]
impl #impl_generics ::confroid::Documented for #ident #ty_generics #where_clause {
fn doc_name() -> &'static str {
#ident_str
}
fn doc_fields() -> ::core::option::Option<::std::vec::Vec<::confroid::FieldDoc>> {
::core::option::Option::Some(#documented_fields)
}
}
}
} else {
quote! {}
};
Ok(quote! {
#[automatically_derived]
impl #impl_generics ::confroid::FromEnv for #ident #ty_generics #where_clause {
fn from_env(
ctx: &::confroid::Ctx,
env: &::confroid::Env,
) -> ::confroid::Result<Self> {
let __confroid_ctx = ctx.prefix(#prefix);
let ctx = &__confroid_ctx;
let mut __confroid_errors = ::std::vec::Vec::new();
#(#field_reads)*
if !__confroid_errors.is_empty() {
return ::core::result::Result::Err(
::confroid::ConfroidError::from_errors(__confroid_errors)
);
}
::core::result::Result::Ok(Self {
#(#field_values),*
})
}
fn is_present(ctx: &::confroid::Ctx, env: &::confroid::Env) -> bool {
let __confroid_ctx = ctx.prefix(#prefix);
!env.immediate_children(&__confroid_ctx.var).is_empty()
}
}
#documented_impl
})
}
fn humantime_read(var_seg: &str, fname_str: &str, optional: bool) -> proc_macro2::TokenStream {
if optional {
quote! {
{
let __ctx = ctx.field(#var_seg, #fname_str);
if env.get(&__ctx.var).is_some_and(|__raw| !__raw.is_empty()) {
::confroid::__parse_humantime_duration(&__ctx, env)
.map(::core::option::Option::Some)
} else {
::core::result::Result::Ok(::core::option::Option::None)
}
}
}
} else {
quote! {
{
let __ctx = ctx.field(#var_seg, #fname_str);
::confroid::__parse_humantime_duration(&__ctx, env)
}
}
}
}
fn auto_vec_read(
var_seg: &str,
fname_str: &str,
elem_ty: &syn::Type,
delim: &str,
) -> proc_macro2::TokenStream {
quote! {
{
let __ctx = ctx.field(#var_seg, #fname_str);
match env.get(&__ctx.var) {
::core::option::Option::None => ::core::result::Result::Err(
::confroid::ConfroidError::EnvVarNotFound {
var_name: __ctx.var.clone(),
field: __ctx.path.clone(),
}
),
::core::option::Option::Some(__raw) if __raw.is_empty() =>
::core::result::Result::Ok(::std::vec::Vec::new()),
::core::option::Option::Some(__raw) => {
let mut __out: ::std::vec::Vec<#elem_ty> = ::std::vec::Vec::new();
let mut __errors = ::std::vec::Vec::new();
for __part in __raw.split(#delim) {
match __part.parse::<#elem_ty>() {
::core::result::Result::Ok(__v) => __out.push(__v),
::core::result::Result::Err(__e) => {
__errors.push(::confroid::ConfroidError::EnvVarInvalid {
var_name: __ctx.var.clone(),
field: __ctx.path.clone(),
value: __part.to_string(),
parser_error: ::std::boxed::Box::new(__e),
});
}
}
}
if __errors.is_empty() {
::core::result::Result::Ok(__out)
} else {
::core::result::Result::Err(
::confroid::ConfroidError::from_errors(__errors)
)
}
}
}
}
}
}
fn default_value(expr: &syn::Expr) -> proc_macro2::TokenStream {
if is_numeric_literal(expr) {
quote! { #expr }
} else {
quote! { ::core::convert::Into::into(#expr) }
}
}
fn is_numeric_literal(expr: &syn::Expr) -> bool {
match expr {
syn::Expr::Lit(syn::ExprLit { lit, .. }) => {
matches!(lit, syn::Lit::Int(_) | syn::Lit::Float(_))
}
syn::Expr::Unary(syn::ExprUnary {
op: syn::UnOp::Neg(_),
expr,
..
}) => is_numeric_literal(expr),
syn::Expr::Group(g) => is_numeric_literal(&g.expr),
syn::Expr::Paren(p) => is_numeric_literal(&p.expr),
_ => false,
}
}
fn type_is_named(ty: &syn::Type, name: &str) -> bool {
matches!(ty, syn::Type::Path(tp) if tp.path.segments.last().is_some_and(|s| s.ident == name))
}
fn vec_inner(ty: &syn::Type) -> Option<&syn::Type> {
generic_inner(ty, "Vec")
}
fn option_inner(ty: &syn::Type) -> Option<&syn::Type> {
generic_inner(ty, "Option")
}
fn generic_inner<'a>(ty: &'a syn::Type, name: &str) -> Option<&'a syn::Type> {
let syn::Type::Path(tp) = ty else {
return None;
};
let seg = tp.path.segments.last()?;
if seg.ident != name {
return None;
}
let syn::PathArguments::AngleBracketed(args) = &seg.arguments else {
return None;
};
args.args.iter().find_map(|arg| match arg {
syn::GenericArgument::Type(inner) => Some(inner),
_ => None,
})
}
fn extract_doc(attrs: &[syn::Attribute]) -> Option<String> {
let mut lines = Vec::new();
for attr in attrs {
if !attr.path().is_ident("doc") {
continue;
}
if let syn::Meta::NameValue(nv) = &attr.meta
&& let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(s),
..
}) = &nv.value
{
lines.push(s.value().trim().to_string());
}
}
if lines.is_empty() {
None
} else {
Some(lines.join(" "))
}
}
fn screaming_snake(s: &str) -> String {
let mut out = String::new();
let mut prev_is_word = false;
for ch in s.chars() {
if ch == '_' {
out.push('_');
prev_is_word = false;
continue;
}
if ch.is_uppercase() && prev_is_word {
out.push('_');
}
out.extend(ch.to_uppercase());
prev_is_word = ch.is_lowercase() || ch.is_ascii_digit();
}
out
}