cmdkit-macros 0.1.0

Procedural macros for cmdkit command strategy generation.
Documentation
use proc_macro::TokenStream;
use quote::{format_ident, quote};
use syn::{FnArg, ItemFn, PatType, Receiver, ReturnType, Type, parse_macro_input};

#[proc_macro_attribute]
pub fn cli(attr: TokenStream, item: TokenStream) -> TokenStream {
    let attr_tokens: proc_macro2::TokenStream = attr.into();

    if !attr_tokens.is_empty() {
        return syn::Error::new_spanned(attr_tokens, "cli attribute does not take any arguments")
            .into_compile_error()
            .into();
    }

    let input_fn = parse_macro_input!(item as ItemFn);

    if input_fn.sig.asyncness.is_some() {
        return syn::Error::new_spanned(&input_fn.sig, "async functions are not supported")
            .into_compile_error()
            .into();
    }

    let mut inputs = input_fn.sig.inputs.iter();
    match inputs.next() {
        Some(FnArg::Receiver(Receiver {
            reference: Some(_),
            mutability: _,
            attrs,
            ..
        })) if attrs.is_empty() => {}
        Some(FnArg::Receiver(_)) => {
            return syn::Error::new_spanned(
                &input_fn.sig,
                "cli strategy methods must use an attribute-free &self receiver",
            )
            .into_compile_error()
            .into();
        }
        _ => {
            return syn::Error::new_spanned(
                &input_fn.sig,
                "cli strategy functions must match CommandStrategy::execute with an &self receiver and options, arguments, and subcommands arguments",
            )
            .into_compile_error()
            .into();
        }
    }

    let options_pat = match inputs.next() {
        Some(FnArg::Typed(PatType { pat, ty, .. })) => {
            match ty.as_ref() {
                Type::Path(path)
                    if path
                        .path
                        .segments
                        .last()
                        .is_some_and(|segment| segment.ident == "Vec") => {}
                _ => {
                    return syn::Error::new_spanned(
                        ty,
                        "cli strategy functions must accept a Vec<String> options argument",
                    )
                    .into_compile_error()
                    .into();
                }
            }

            pat
        }
        _ => {
            return syn::Error::new_spanned(
                &input_fn.sig,
                "cli strategy functions must accept an options Vec<String> argument",
            )
            .into_compile_error()
            .into();
        }
    };

    let arguments_pat = match inputs.next() {
        Some(FnArg::Typed(PatType { pat, ty, .. })) => {
            match ty.as_ref() {
                Type::Path(path)
                    if path
                        .path
                        .segments
                        .last()
                        .is_some_and(|segment| segment.ident == "HashMap") => {}
                _ => {
                    return syn::Error::new_spanned(
                        ty,
                        "cli strategy functions must accept a HashMap<String, String> arguments argument",
                    )
                    .into_compile_error()
                    .into();
                }
            }

            pat
        }
        _ => {
            return syn::Error::new_spanned(
                &input_fn.sig,
                "cli strategy functions must accept an arguments HashMap<String, String> argument",
            )
            .into_compile_error()
            .into();
        }
    };

    let subcommands_pat = match inputs.next() {
        Some(FnArg::Typed(PatType { pat, ty, .. })) => {
            if inputs.next().is_some() {
                return syn::Error::new_spanned(
                    &input_fn.sig,
                    "cli strategy functions must accept exactly three parsed invocation arguments",
                )
                .into_compile_error()
                .into();
            }

            match ty.as_ref() {
                Type::Path(path)
                    if path
                        .path
                        .segments
                        .last()
                        .is_some_and(|segment| segment.ident == "Vec") => {}
                _ => {
                    return syn::Error::new_spanned(
                        ty,
                        "cli strategy functions must accept a Vec<String> subcommands argument",
                    )
                    .into_compile_error()
                    .into();
                }
            }

            pat
        }
        _ => {
            return syn::Error::new_spanned(
                &input_fn.sig,
                "cli strategy functions must accept a subcommands Vec<String> argument",
            )
            .into_compile_error()
            .into();
        }
    };

    match &input_fn.sig.output {
        ReturnType::Type(_, ty) => match ty.as_ref() {
            Type::Path(path)
                if path.path.segments.len() == 1 && path.path.segments[0].ident == "Result" => {}
            _ => {
                return syn::Error::new_spanned(
                    ty,
                    "cli strategy functions must return Result<(), cmdkit::StrategyError>",
                )
                .into_compile_error()
                .into();
            }
        },
        ReturnType::Default => {
            return syn::Error::new_spanned(
                &input_fn.sig,
                "cli strategy functions must return Result<(), cmdkit::StrategyError>",
            )
            .into_compile_error()
            .into();
        }
    }

    let fn_ident = &input_fn.sig.ident;
    let vis = &input_fn.vis;
    let strategy_ident = format_ident!("{}", to_pascal(&fn_ident.to_string()));
    let factory_ident = format_ident!("{}_strategy", fn_ident);
    let attrs = &input_fn.attrs;
    let body = &input_fn.block;

    let expanded = quote! {
        #(#attrs)*
        #vis struct #strategy_ident;

        impl #strategy_ident {
            #vis fn new() -> Self {
                Self
            }
        }

        impl ::cmdkit::CommandStrategy for #strategy_ident {
            fn execute(
                &self,
                #options_pat: Vec<String>,
                #arguments_pat: ::std::collections::HashMap<String, String>,
                #subcommands_pat: Vec<String>,
            ) -> Result<(), ::cmdkit::StrategyError> {
                #body
            }
        }

        #vis fn #factory_ident() -> #strategy_ident {
            #strategy_ident::new()
        }
    };

    expanded.into()
}

fn to_pascal(s: &str) -> String {
    let mut out = String::new();
    for part in s.split('_') {
        if part.is_empty() {
            continue;
        }
        let mut chars = part.chars();
        if let Some(first) = chars.next() {
            out.extend(first.to_uppercase());
            out.push_str(chars.as_str());
        }
    }
    out
}