autocli_macros 0.1.0

Rust port of the 'argumint' Python cli library.
Documentation
// mod <module>;
// pub use <module>::*;
use proc_macro::TokenStream;
use quote::{format_ident, quote};
use syn::{parse_macro_input, ItemFn};

#[proc_macro_attribute]
pub fn auto_cli(_attr: TokenStream, item: TokenStream) -> TokenStream {
    let input = parse_macro_input!(item as ItemFn);
    let vis = &input.vis;
    let sig = &input.sig;
    let fn_name = &sig.ident;

    // doc comments: #[doc = "..."]
    let doc_attrs: Vec<String> = input
    .attrs
    .iter()
    .filter_map(|a| {
        if !a.path().is_ident("doc") {
            return None;
        }
        match &a.meta {
            syn::Meta::NameValue(nv) => {
                if let syn::Expr::Lit(expr_lit) = &nv.value {
                    if let syn::Lit::Str(s) = &expr_lit.lit {
                        return Some(s.value());
                    }
                }
                None
            }
            _ => None,
        }
    })
    .collect();
    let doc_comment = doc_attrs.join("\n");

    // struct name: <fn>Args (simple)
    let struct_name = format_ident!("{}Args", fn_name);

    let mut fields = Vec::new();
    let mut call_args = Vec::new();

    for arg in sig.inputs.iter() {
        let syn::FnArg::Typed(pat_type) = arg else {
            return syn::Error::new_spanned(arg, "methods with self not supported")
            .to_compile_error()
            .into();
        };

        let ident = match &*pat_type.pat {
            syn::Pat::Ident(p) => &p.ident,
            _ => {
                return syn::Error::new_spanned(&pat_type.pat, "only ident patterns supported")
                .to_compile_error()
                .into();
            }
        };
        let ty = &*pat_type.ty;

        // parse #[arg(default = "...")]
        let mut default_lit: Option<String> = None;
        for attr in &pat_type.attrs {
            if !attr.path().is_ident("arg") {
                continue;
            }
            let _ = attr.parse_nested_meta(|meta| {
                if meta.path.is_ident("default") {
                    let value = meta.value()?;
                    let lit: syn::LitStr = value.parse()?;
                    default_lit = Some(lit.value());
                }
                Ok(())
            });
        }

        let clap_attr = if let Some(d) = default_lit {
            quote! { #[arg(long, default_value = #d)] }
        } else {
            quote! { #[arg(long)] }
        };

        fields.push(quote! {
            #clap_attr
            pub #ident: #ty
        });

        call_args.push(quote! { args.#ident });
    }

    let fn_name = &sig.ident;
    let wrapper_name = format_ident!("{}_cli", fn_name);

    let expanded = quote! {
        #input

        #[derive(Debug, clap::Parser)]
        #[command(about = #doc_comment)]
        #vis struct #struct_name {
        #(#fields,)*
        }

        #vis fn #wrapper_name() {
        use clap::Parser;
        let args = #struct_name::parse();
        let out = #fn_name(#(#call_args),*);
        println!("{:?}", out);
        }
    };

    expanded.into()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = 4;
        assert_eq!(result, 4);
    }
}