use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{
parse::{Parse, ParseStream},
parse_macro_input, Attribute, Data, DeriveInput, Fields, Ident, Lit, LitStr, Meta, Token, Type,
};
#[derive(Debug, Clone)]
enum FieldKind {
Str,
Bool,
Num(proc_macro2::TokenStream), OptStr,
OptBool,
OptNum(proc_macro2::TokenStream),
Other,
}
fn classify(ty: &Type) -> FieldKind {
let Type::Path(tp) = ty else {
return FieldKind::Other;
};
let segs = &tp.path.segments;
if segs.is_empty() {
return FieldKind::Other;
}
let last = segs.last().unwrap();
let name = last.ident.to_string();
match name.as_str() {
"String" => FieldKind::Str,
"bool" => FieldKind::Bool,
n @ ("u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" | "i128"
| "f32" | "f64" | "usize" | "isize") => {
let ident = Ident::new(n, proc_macro2::Span::call_site());
FieldKind::Num(quote! { #ident })
}
"Option" => {
if let syn::PathArguments::AngleBracketed(ab) = &last.arguments {
if let Some(syn::GenericArgument::Type(inner)) = ab.args.first() {
return match classify(inner) {
FieldKind::Str => FieldKind::OptStr,
FieldKind::Bool => FieldKind::OptBool,
FieldKind::Num(t) => FieldKind::OptNum(t),
_ => FieldKind::Other,
};
}
}
FieldKind::Other
}
_ => FieldKind::Other,
}
}
struct EnvAttr {
var_name: LitStr,
default: Option<Lit>,
}
impl Parse for EnvAttr {
fn parse(input: ParseStream) -> syn::Result<Self> {
let var_name: LitStr = input.parse()?;
let default = if input.peek(Token![,]) {
input.parse::<Token![,]>()?;
let key: Ident = input.parse()?;
if key != "default" {
return Err(syn::Error::new(key.span(), "expected `default`"));
}
input.parse::<Token![=]>()?;
Some(input.parse::<Lit>()?)
} else {
None
};
Ok(EnvAttr { var_name, default })
}
}
#[proc_macro_derive(Config, attributes(env))]
pub fn derive_config(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
expand_config(input).unwrap_or_else(|e| e.to_compile_error().into())
}
fn expand_config(input: DeriveInput) -> syn::Result<TokenStream> {
let name = &input.ident;
let Data::Struct(data) = &input.data else {
return Err(syn::Error::new_spanned(
&input.ident,
"#[derive(Config)] only supports structs",
));
};
let Fields::Named(fields) = &data.fields else {
return Err(syn::Error::new_spanned(
&input.ident,
"#[derive(Config)] only supports structs with named fields",
));
};
let mut field_inits: Vec<TokenStream2> = Vec::new();
for field in &fields.named {
let field_ident = field.ident.as_ref().unwrap();
let kind = classify(&field.ty);
let env_attr = field
.attrs
.iter()
.find(|a| a.path().is_ident("env"))
.ok_or_else(|| {
syn::Error::new_spanned(
field_ident,
"each field must have an #[env(\"VAR_NAME\")] attribute",
)
})?;
let parsed: EnvAttr = env_attr.parse_args()?;
let var_name = &parsed.var_name;
let var_str = var_name.value();
let init = match kind {
FieldKind::Str => match &parsed.default {
Some(Lit::Str(default)) => quote! {
#field_ident: ::std::env::var(#var_name).unwrap_or_else(|_| #default.to_string()),
},
None => {
let msg = format!(
"required env var `{var_str}` is not set — add it to .env or the environment"
);
quote! {
#field_ident: ::std::env::var(#var_name).unwrap_or_else(|_| panic!(#msg)),
}
}
Some(other) => {
return Err(syn::Error::new_spanned(
other,
"default for a String field must be a string literal",
))
}
},
FieldKind::Bool => {
let default_val = match &parsed.default {
Some(Lit::Bool(b)) => b.value,
None => false,
Some(other) => {
return Err(syn::Error::new_spanned(
other,
"default for a bool field must be `true` or `false`",
))
}
};
quote! {
#field_ident: ::std::env::var(#var_name)
.ok()
.and_then(|v| match v.to_lowercase().as_str() {
"true" | "1" | "yes" | "on" => ::std::option::Option::Some(true),
"false" | "0" | "no" | "off" => ::std::option::Option::Some(false),
_ => ::std::option::Option::None,
})
.unwrap_or(#default_val),
}
}
FieldKind::Num(ref ty_tokens) => match &parsed.default {
Some(Lit::Int(n)) => quote! {
#field_ident: ::std::env::var(#var_name)
.ok()
.and_then(|v| v.parse::<#ty_tokens>().ok())
.unwrap_or(#n as #ty_tokens),
},
Some(Lit::Float(f)) => quote! {
#field_ident: ::std::env::var(#var_name)
.ok()
.and_then(|v| v.parse::<#ty_tokens>().ok())
.unwrap_or(#f as #ty_tokens),
},
None => {
let msg = format!(
"required env var `{var_str}` is not set — add it to .env or the environment"
);
let bad_msg = format!("env var `{var_str}` must be a valid number");
quote! {
#field_ident: {
let __raw = ::std::env::var(#var_name).unwrap_or_else(|_| panic!(#msg));
__raw.parse::<#ty_tokens>().unwrap_or_else(|_| panic!(#bad_msg))
},
}
}
Some(other) => {
return Err(syn::Error::new_spanned(
other,
"default for a numeric field must be a numeric literal",
))
}
},
FieldKind::OptStr => quote! {
#field_ident: ::std::env::var(#var_name).ok(),
},
FieldKind::OptBool => quote! {
#field_ident: ::std::env::var(#var_name)
.ok()
.and_then(|v| match v.to_lowercase().as_str() {
"true" | "1" | "yes" | "on" => ::std::option::Option::Some(true),
"false" | "0" | "no" | "off" => ::std::option::Option::Some(false),
_ => ::std::option::Option::None,
}),
},
FieldKind::OptNum(ref ty_tokens) => quote! {
#field_ident: ::std::env::var(#var_name)
.ok()
.and_then(|v| v.parse::<#ty_tokens>().ok()),
},
FieldKind::Other => {
return Err(syn::Error::new_spanned(
&field.ty,
"#[derive(Config)] supports String, bool, numeric types, and their Option<T> wrappers",
))
}
};
field_inits.push(init);
}
let expanded = quote! {
impl ::rok_core::config::FromEnv for #name {
fn from_env() -> Self {
Self {
#(#field_inits)*
}
}
}
impl #name {
pub fn load() -> Self {
::rok_core::config::Config::load::<Self>()
}
}
};
Ok(expanded.into())
}
fn extract_prefix(attrs: &[Attribute]) -> syn::Result<String> {
for attr in attrs {
if attr.path().is_ident("config") {
let meta: Meta = attr.parse_args()?;
match &meta {
Meta::NameValue(nv) if nv.path.is_ident("prefix") => {
if let syn::Expr::Lit(expr_lit) = &nv.value {
if let Lit::Str(s) = &expr_lit.lit {
return Ok(s.value());
}
}
}
_ => {
return Err(syn::Error::new_spanned(
&meta,
"expected `#[config(prefix = \"...\")]`",
))
}
}
}
}
Err(syn::Error::new(
proc_macro2::Span::call_site(),
"missing `#[config(prefix = \"app\")]` attribute",
))
}
#[proc_macro_derive(RokConfig, attributes(config, env))]
pub fn derive_rok_config(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
expand_rok_config(input).unwrap_or_else(|e| e.to_compile_error().into())
}
fn expand_rok_config(input: DeriveInput) -> syn::Result<TokenStream> {
let prefix = extract_prefix(&input.attrs)?;
let name = &input.ident;
let prefix_str = prefix.clone();
let Data::Struct(data) = &input.data else {
return Err(syn::Error::new_spanned(
&input.ident,
"#[derive(RokConfig)] only supports structs",
));
};
let Fields::Named(fields) = &data.fields else {
return Err(syn::Error::new_spanned(
&input.ident,
"#[derive(RokConfig)] only supports structs with named fields",
));
};
let mut field_inits: Vec<TokenStream2> = Vec::new();
for field in &fields.named {
let field_ident = field.ident.as_ref().unwrap();
let kind = classify(&field.ty);
let env_attr = field
.attrs
.iter()
.find(|a| a.path().is_ident("env"))
.ok_or_else(|| {
syn::Error::new_spanned(
field_ident,
"each field must have an #[env(\"VAR_NAME\")] attribute",
)
})?;
let parsed: EnvAttr = env_attr.parse_args()?;
let var_name = &parsed.var_name;
let var_str = var_name.value();
let init = match kind {
FieldKind::Str => match &parsed.default {
Some(Lit::Str(default)) => quote! {
#field_ident: ::std::env::var(#var_name).unwrap_or_else(|_| #default.to_string()),
},
None => {
let msg = format!(
"required env var `{var_str}` is not set — add it to .env or the environment"
);
quote! {
#field_ident: ::std::env::var(#var_name).unwrap_or_else(|_| panic!(#msg)),
}
}
Some(other) => {
return Err(syn::Error::new_spanned(
other,
"default for a String field must be a string literal",
))
}
},
FieldKind::Bool => {
let default_val = match &parsed.default {
Some(Lit::Bool(b)) => b.value,
None => false,
Some(other) => {
return Err(syn::Error::new_spanned(
other,
"default for a bool field must be `true` or `false`",
))
}
};
quote! {
#field_ident: ::std::env::var(#var_name)
.ok()
.and_then(|v| match v.to_lowercase().as_str() {
"true" | "1" | "yes" | "on" => ::std::option::Option::Some(true),
"false" | "0" | "no" | "off" => ::std::option::Option::Some(false),
_ => ::std::option::Option::None,
})
.unwrap_or(#default_val),
}
}
FieldKind::Num(ref ty_tokens) => match &parsed.default {
Some(Lit::Int(n)) => quote! {
#field_ident: ::std::env::var(#var_name)
.ok()
.and_then(|v| v.parse::<#ty_tokens>().ok())
.unwrap_or(#n as #ty_tokens),
},
Some(Lit::Float(f)) => quote! {
#field_ident: ::std::env::var(#var_name)
.ok()
.and_then(|v| v.parse::<#ty_tokens>().ok())
.unwrap_or(#f as #ty_tokens),
},
None => {
let msg = format!(
"required env var `{var_str}` is not set — add it to .env or the environment"
);
let bad_msg = format!("env var `{var_str}` must be a valid number");
quote! {
#field_ident: {
let __raw = ::std::env::var(#var_name).unwrap_or_else(|_| panic!(#msg));
__raw.parse::<#ty_tokens>().unwrap_or_else(|_| panic!(#bad_msg))
},
}
}
Some(other) => {
return Err(syn::Error::new_spanned(
other,
"default for a numeric field must be a numeric literal",
))
}
},
FieldKind::OptStr => quote! {
#field_ident: ::std::env::var(#var_name).ok(),
},
FieldKind::OptBool => quote! {
#field_ident: ::std::env::var(#var_name)
.ok()
.and_then(|v| match v.to_lowercase().as_str() {
"true" | "1" | "yes" | "on" => ::std::option::Option::Some(true),
"false" | "0" | "no" | "off" => ::std::option::Option::Some(false),
_ => ::std::option::Option::None,
}),
},
FieldKind::OptNum(ref ty_tokens) => quote! {
#field_ident: ::std::env::var(#var_name)
.ok()
.and_then(|v| v.parse::<#ty_tokens>().ok()),
},
FieldKind::Other => {
return Err(syn::Error::new_spanned(
&field.ty,
"#[derive(RokConfig)] supports String, bool, numeric types, and their Option<T> wrappers",
))
}
};
field_inits.push(init);
}
let expanded = quote! {
impl ::rok_core::config::Configurable for #name {
fn key() -> &'static str {
#prefix_str
}
}
impl ::rok_core::config::FromEnv for #name {
fn from_env() -> Self {
Self {
#(#field_inits)*
}
}
}
impl #name {
pub fn load() -> Self {
::rok_core::config::load_config::<Self>()
.unwrap_or_else(|| ::rok_core::config::Config::load::<Self>())
}
}
};
Ok(expanded.into())
}