use std::str::FromStr;
use quote::{quote, ToTokens};
use syn::{parse_macro_input, spanned::Spanned};
type AttrMap = std::collections::HashMap<String, proc_macro2::TokenStream>;
struct Field<'a> {
attrs: AttrMap, vis: &'a syn::Visibility, ident: &'a syn::Ident, ty: &'a syn::Type, opt: bool, }
struct SettingStruct<'a> {
s: &'a syn::ItemStruct, attrs: AttrMap, fields: Vec<Field<'a>>, }
impl<'a> SettingStruct<'a> {
fn build(s: &'a syn::ItemStruct) -> Result<Self, syn::Error> {
let mut ss = Self {
s,
attrs: AttrMap::default(),
fields: vec![],
};
let syn::Fields::Named(fields) = &s.fields else {
return Err(syn::Error::new(
s.span(),
"only named structs are supported",
));
};
ss.attrs = Self::classify_attributes(&s.attrs)?;
ss.fields.reserve_exact(fields.named.len());
for field in &fields.named {
let mut f = Field {
attrs: Self::classify_attributes(&field.attrs)?,
vis: &field.vis,
ident: field.ident.as_ref().ok_or_else(|| {
syn::Error::new(field.span(), "only named fields are supported")
})?,
ty: &field.ty,
opt: false,
};
f.opt = !f.attrs.contains_key("cli_settings_mandatory");
ss.fields.push(f);
}
Ok(ss)
}
fn classify_attributes(attrs: &'a Vec<syn::Attribute>) -> Result<AttrMap, syn::Error> {
let mut res = AttrMap::default();
for attr in attrs {
#[allow(clippy::match_wildcard_for_single_variants)]
let (path, value) = match &attr.meta {
syn::Meta::Path(p) => (Some(p), None),
syn::Meta::NameValue(v) => (Some(&v.path), Some(&v.value)),
_ => (None, None),
};
let mut handled_attr = false;
if let Some(p) = path {
if let Some(path_ident) = p.get_ident() {
let path_ident_str = path_ident.to_string();
if path_ident_str == "doc" {
handled_attr = true;
res.entry("doc".to_string())
.or_default()
.extend(attr.to_token_stream());
} else if path_ident_str.starts_with("cli_settings_") {
handled_attr = true;
if value.is_none() {
res.entry(path_ident_str).or_default();
} else if let Some(syn::Expr::Lit(syn::ExprLit {
attrs: _,
lit: syn::Lit::Str(l),
})) = value
{
res.entry(path_ident_str)
.or_default()
.extend(proc_macro2::TokenStream::from_str(&l.value())?);
} else {
return Err(syn::Error::new(attr.span(), "invalid attribute format"));
}
}
}
}
if !handled_attr {
res.entry("_".to_string())
.or_default()
.extend(attr.to_token_stream());
}
}
Ok(res)
}
fn output_struct(
&self,
prefix: &str,
field_filter: Option<&str>,
attr_keys: &[&str],
) -> proc_macro2::TokenStream {
let empty = proc_macro2::TokenStream::new();
let attrs = attr_keys
.iter()
.map(|k| self.attrs.get(*k).unwrap_or(&empty))
.collect::<Vec<_>>();
let vis = if prefix.is_empty() {
self.s.vis.to_token_stream()
} else {
empty.clone()
};
let struct_token = &self.s.struct_token;
let name = format!("{}{}", prefix, self.s.ident);
let ident = syn::Ident::new(&name, self.s.ident.span());
let fields = self
.fields
.iter()
.filter(|f| {
if let Some(k) = field_filter {
f.attrs.contains_key(k)
} else {
true
}
})
.map(|f| {
let field_attrs = attr_keys
.iter()
.map(|k| f.attrs.get(*k).unwrap_or(&empty))
.collect::<Vec<_>>();
let field_vis = f.vis;
let field_ident = f.ident;
let field_ty = f.ty;
let (field_ty_start, field_ty_end) = if prefix.is_empty() || !f.opt {
(empty.clone(), empty.clone())
} else {
(
proc_macro2::TokenStream::from_str("Option<").unwrap(),
proc_macro2::TokenStream::from_str(">").unwrap(),
)
};
quote! {
#(#field_attrs)* #field_vis #field_ident: #field_ty_start #field_ty #field_ty_end
}
})
.collect::<Vec<_>>();
quote! {
#(#attrs)* #vis #struct_token #ident
{
#(#fields),*
}
}
}
fn output_main_struct(&self) -> proc_macro2::TokenStream {
self.output_struct("", None, &["_", "doc"])
}
fn output_file_struct(&self) -> proc_macro2::TokenStream {
self.output_struct("File", Some("cli_settings_file"), &["cli_settings_file"])
}
fn output_clap_struct(&self) -> proc_macro2::TokenStream {
self.output_struct(
"Clap",
Some("cli_settings_clap"),
&["doc", "cli_settings_clap"],
)
}
fn output_main_struct_default(&self) -> proc_macro2::TokenStream {
let default = proc_macro2::TokenStream::from_str("Default::default()").unwrap();
let ident = &self.s.ident;
let fields = self
.fields
.iter()
.map(|f| {
let field_ident = f.ident;
let field_default = f.attrs.get("cli_settings_default").unwrap_or(&default);
quote! {
#field_ident: #field_default
}
})
.collect::<Vec<_>>();
quote! {
impl Default for #ident {
fn default() -> Self {
Self{
#(#fields),*
}
}
}
}
}
fn output_main_struct_build(&self) -> proc_macro2::TokenStream {
let ident = &self.s.ident;
quote! {
impl #ident {
pub fn build<F, I, T>(cfg_files: F, args: I) -> anyhow::Result<Self>
where
F: IntoIterator<Item = std::path::PathBuf>,
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
let mut cfg = Self::default();
for file in cfg_files {
_cli_settings_derive::load_file(&file, &mut cfg)?;
}
_cli_settings_derive::parse_cli_args(args, &mut cfg)?;
Ok(cfg)
}
}
}
}
fn output_struct_update(&self, prefix: &str, field_filter: &str) -> proc_macro2::TokenStream {
let main_ident = &self.s.ident;
let name = format!("{}{}", prefix, self.s.ident);
let ident = syn::Ident::new(&name, self.s.ident.span());
let fields = self
.fields
.iter()
.filter(|f| f.attrs.contains_key(field_filter))
.map(|f| {
let field_ident = f.ident;
if f.opt {
quote! {
if let Some(param) = self.#field_ident {
cfg.#field_ident = param;
}
}
} else {
quote! {
cfg.#field_ident = self.#field_ident;
}
}
})
.collect::<Vec<_>>();
quote! {
impl #ident {
fn update(self, cfg: &mut super::#main_ident) {
#(#fields)*
}
}
}
}
fn output_file_struct_update(&self) -> proc_macro2::TokenStream {
self.output_struct_update("File", "cli_settings_file")
}
fn output_clap_struct_update(&self) -> proc_macro2::TokenStream {
self.output_struct_update("Clap", "cli_settings_clap")
}
fn output_load_file(&self) -> proc_macro2::TokenStream {
let main_ident = &self.s.ident;
let name = format!("File{}", self.s.ident);
let ident = syn::Ident::new(&name, self.s.ident.span());
quote! {
pub fn load_file(path: &std::path::Path, cfg: &mut super::#main_ident) -> anyhow::Result<()> {
let file = std::fs::File::open(path);
if let Err(err) = file {
if err.kind() == std::io::ErrorKind::NotFound {
return Ok(());
}
return Err(err).context(format!(
"Failed to open the configuration file '{}'",
path.display()
));
}
let file = file.unwrap();
let file_config: #ident = serde_yaml::from_reader(file).with_context(|| {
format!(
"Failed to parse the configuration file '{}'",
path.display()
)
})?;
file_config.update(cfg);
Ok(())
}
}
}
fn output_parse_cli_args(&self) -> proc_macro2::TokenStream {
let main_ident = &self.s.ident;
let name = format!("Clap{}", self.s.ident);
let ident = syn::Ident::new(&name, self.s.ident.span());
quote! {
pub fn parse_cli_args<I, T>(args: I, cfg: &mut super::#main_ident) -> anyhow::Result<()>
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
let cli_args = #ident ::parse_from(args);
cli_args.update(cfg);
Ok(())
}
}
}
fn output_clap_test(&self) -> proc_macro2::TokenStream {
let name = format!("Clap{}", self.s.ident);
let ident = syn::Ident::new(&name, self.s.ident.span());
quote! {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verify_cli() {
use clap::CommandFactory;
#ident ::command().debug_assert()
}
}
}
}
}
#[proc_macro_attribute]
pub fn cli_settings(
_attr: proc_macro::TokenStream,
item: proc_macro::TokenStream,
) -> proc_macro::TokenStream {
let syn_struct = parse_macro_input!(item as syn::ItemStruct);
let ss = match SettingStruct::build(&syn_struct) {
Ok(ss) => ss,
Err(e) => return e.to_compile_error().into(),
};
let main_struct = ss.output_main_struct();
let main_struct_default = ss.output_main_struct_default();
let main_struct_build = ss.output_main_struct_build();
let file_struct = ss.output_file_struct();
let file_struct_update = ss.output_file_struct_update();
let load_file = ss.output_load_file();
let clap_struct = ss.output_clap_struct();
let clap_struct_update = ss.output_clap_struct_update();
let parse_cli_args = ss.output_parse_cli_args();
let clap_test = ss.output_clap_test();
quote! {
#main_struct
#main_struct_default
#main_struct_build
mod _cli_settings_derive {
use anyhow::Context;
use clap::Parser;
use super::*;
#file_struct
#file_struct_update
#load_file
#clap_struct
#clap_struct_update
#parse_cli_args
#clap_test
}
}
.into()
}