Skip to main content

cmdkit_macros/
lib.rs

1use proc_macro::TokenStream;
2use quote::{format_ident, quote};
3use syn::{FnArg, ItemFn, PatType, Receiver, ReturnType, Type, parse_macro_input};
4
5#[proc_macro_attribute]
6pub fn cli(attr: TokenStream, item: TokenStream) -> TokenStream {
7    let attr_tokens: proc_macro2::TokenStream = attr.into();
8
9    if !attr_tokens.is_empty() {
10        return syn::Error::new_spanned(attr_tokens, "cli attribute does not take any arguments")
11            .into_compile_error()
12            .into();
13    }
14
15    let input_fn = parse_macro_input!(item as ItemFn);
16
17    if input_fn.sig.asyncness.is_some() {
18        return syn::Error::new_spanned(&input_fn.sig, "async functions are not supported")
19            .into_compile_error()
20            .into();
21    }
22
23    let mut inputs = input_fn.sig.inputs.iter();
24    match inputs.next() {
25        Some(FnArg::Receiver(Receiver {
26            reference: Some(_),
27            mutability: _,
28            attrs,
29            ..
30        })) if attrs.is_empty() => {}
31        Some(FnArg::Receiver(_)) => {
32            return syn::Error::new_spanned(
33                &input_fn.sig,
34                "cli strategy methods must use an attribute-free &self receiver",
35            )
36            .into_compile_error()
37            .into();
38        }
39        _ => {
40            return syn::Error::new_spanned(
41                &input_fn.sig,
42                "cli strategy functions must match CommandStrategy::execute with an &self receiver and options, arguments, and subcommands arguments",
43            )
44            .into_compile_error()
45            .into();
46        }
47    }
48
49    let options_pat = match inputs.next() {
50        Some(FnArg::Typed(PatType { pat, ty, .. })) => {
51            match ty.as_ref() {
52                Type::Path(path)
53                    if path
54                        .path
55                        .segments
56                        .last()
57                        .is_some_and(|segment| segment.ident == "Vec") => {}
58                _ => {
59                    return syn::Error::new_spanned(
60                        ty,
61                        "cli strategy functions must accept a Vec<String> options argument",
62                    )
63                    .into_compile_error()
64                    .into();
65                }
66            }
67
68            pat
69        }
70        _ => {
71            return syn::Error::new_spanned(
72                &input_fn.sig,
73                "cli strategy functions must accept an options Vec<String> argument",
74            )
75            .into_compile_error()
76            .into();
77        }
78    };
79
80    let arguments_pat = match inputs.next() {
81        Some(FnArg::Typed(PatType { pat, ty, .. })) => {
82            match ty.as_ref() {
83                Type::Path(path)
84                    if path
85                        .path
86                        .segments
87                        .last()
88                        .is_some_and(|segment| segment.ident == "HashMap") => {}
89                _ => {
90                    return syn::Error::new_spanned(
91                        ty,
92                        "cli strategy functions must accept a HashMap<String, String> arguments argument",
93                    )
94                    .into_compile_error()
95                    .into();
96                }
97            }
98
99            pat
100        }
101        _ => {
102            return syn::Error::new_spanned(
103                &input_fn.sig,
104                "cli strategy functions must accept an arguments HashMap<String, String> argument",
105            )
106            .into_compile_error()
107            .into();
108        }
109    };
110
111    let subcommands_pat = match inputs.next() {
112        Some(FnArg::Typed(PatType { pat, ty, .. })) => {
113            if inputs.next().is_some() {
114                return syn::Error::new_spanned(
115                    &input_fn.sig,
116                    "cli strategy functions must accept exactly three parsed invocation arguments",
117                )
118                .into_compile_error()
119                .into();
120            }
121
122            match ty.as_ref() {
123                Type::Path(path)
124                    if path
125                        .path
126                        .segments
127                        .last()
128                        .is_some_and(|segment| segment.ident == "Vec") => {}
129                _ => {
130                    return syn::Error::new_spanned(
131                        ty,
132                        "cli strategy functions must accept a Vec<String> subcommands argument",
133                    )
134                    .into_compile_error()
135                    .into();
136                }
137            }
138
139            pat
140        }
141        _ => {
142            return syn::Error::new_spanned(
143                &input_fn.sig,
144                "cli strategy functions must accept a subcommands Vec<String> argument",
145            )
146            .into_compile_error()
147            .into();
148        }
149    };
150
151    match &input_fn.sig.output {
152        ReturnType::Type(_, ty) => match ty.as_ref() {
153            Type::Path(path)
154                if path.path.segments.len() == 1 && path.path.segments[0].ident == "Result" => {}
155            _ => {
156                return syn::Error::new_spanned(
157                    ty,
158                    "cli strategy functions must return Result<(), cmdkit::StrategyError>",
159                )
160                .into_compile_error()
161                .into();
162            }
163        },
164        ReturnType::Default => {
165            return syn::Error::new_spanned(
166                &input_fn.sig,
167                "cli strategy functions must return Result<(), cmdkit::StrategyError>",
168            )
169            .into_compile_error()
170            .into();
171        }
172    }
173
174    let fn_ident = &input_fn.sig.ident;
175    let vis = &input_fn.vis;
176    let strategy_ident = format_ident!("{}", to_pascal(&fn_ident.to_string()));
177    let factory_ident = format_ident!("{}_strategy", fn_ident);
178    let attrs = &input_fn.attrs;
179    let body = &input_fn.block;
180
181    let expanded = quote! {
182        #(#attrs)*
183        #vis struct #strategy_ident;
184
185        impl #strategy_ident {
186            #vis fn new() -> Self {
187                Self
188            }
189        }
190
191        impl ::cmdkit::CommandStrategy for #strategy_ident {
192            fn execute(
193                &self,
194                #options_pat: Vec<String>,
195                #arguments_pat: ::std::collections::HashMap<String, String>,
196                #subcommands_pat: Vec<String>,
197            ) -> Result<(), ::cmdkit::StrategyError> {
198                #body
199            }
200        }
201
202        #vis fn #factory_ident() -> #strategy_ident {
203            #strategy_ident::new()
204        }
205    };
206
207    expanded.into()
208}
209
210fn to_pascal(s: &str) -> String {
211    let mut out = String::new();
212    for part in s.split('_') {
213        if part.is_empty() {
214            continue;
215        }
216        let mut chars = part.chars();
217        if let Some(first) = chars.next() {
218            out.extend(first.to_uppercase());
219            out.push_str(chars.as_str());
220        }
221    }
222    out
223}