Skip to main content

rnme_macros/
lib.rs

1mod cmd_macro;
2
3use proc_macro::TokenStream;
4use quote::quote;
5use syn::{
6    Expr, ExprLit, FnArg, GenericArgument, ItemFn, Lit, Meta, MetaNameValue, Pat, PathArguments,
7    ReturnType, Type, TypePath, parse_macro_input,
8};
9
10/// Known primitive type names for the detection heuristic.
11/// If a single remaining parameter has one of these types, it's Form 2 (simple args).
12/// If it has a non-primitive type, it's Form 3 (parser struct).
13const KNOWN_PRIMITIVES: &[&str] = &[
14    "String", "bool", "u8", "u16", "u32", "u64", "u128", "i8", "i16", "i32", "i64", "i128", "f32",
15    "f64", "usize", "isize",
16];
17
18/// Describes which argument form a task function uses.
19enum ArgForm {
20    /// Zero extra params after ctx: async fn build(ctx: &TaskContext) -> TaskResult
21    ZeroArgs,
22    /// Simple params: async fn deploy(ctx: &TaskContext, env: String, port: u16) -> TaskResult
23    SimpleArgs(Vec<SimpleParam>),
24    /// Parser struct: async fn deploy(ctx: &TaskContext, args: DeployArgs) -> TaskResult
25    ParserStruct {
26        #[allow(dead_code)]
27        param_name: syn::Ident,
28        param_type: Box<syn::Type>,
29    },
30}
31
32/// A simple parameter extracted from the function signature.
33struct SimpleParam {
34    name: syn::Ident,
35    ty: syn::Type,
36    kind: SimpleParamKind,
37}
38
39/// Classification of a simple parameter for clap arg generation.
40enum SimpleParamKind {
41    /// bool -> --flag (no value, presence = true)
42    Bool,
43    /// String, numeric types -> --name <value> (required)
44    Required,
45    /// Option<T> -> --name <value> (optional)
46    Optional(syn::Type),
47    /// Vec<T> -> --name <value> (repeatable)
48    Repeatable(syn::Type),
49}
50
51/// Check if a type path's last segment matches a given name (ignoring generics).
52fn type_ident_is(ty: &Type, name: &str) -> bool {
53    if let Type::Path(TypePath { path, .. }) = ty
54        && let Some(seg) = path.segments.last()
55    {
56        return seg.ident == name;
57    }
58    false
59}
60
61/// Extract the inner type from Option<T> or Vec<T>.
62fn extract_generic_inner(ty: &Type) -> Option<syn::Type> {
63    if let Type::Path(TypePath { path, .. }) = ty
64        && let Some(seg) = path.segments.last()
65        && let PathArguments::AngleBracketed(ref args) = seg.arguments
66        && let Some(GenericArgument::Type(inner)) = args.args.first()
67    {
68        return Some(inner.clone());
69    }
70    None
71}
72
73/// Check if a type is a known primitive (not Option/Vec wrapper).
74fn is_known_primitive(ty: &Type) -> bool {
75    if let Type::Path(TypePath { path, .. }) = ty
76        && let Some(seg) = path.segments.last()
77    {
78        let name = seg.ident.to_string();
79        if KNOWN_PRIMITIVES.contains(&name.as_str()) {
80            return true;
81        }
82        // Option<T> and Vec<T> are considered "primitive wrappers"
83        if (name == "Option" || name == "Vec") && extract_generic_inner(ty).is_some() {
84            return true;
85        }
86    }
87    false
88}
89
90/// Classify a simple parameter for clap arg generation.
91fn classify_param(name: syn::Ident, ty: syn::Type) -> SimpleParam {
92    let kind = if type_ident_is(&ty, "bool") {
93        SimpleParamKind::Bool
94    } else if type_ident_is(&ty, "Option") {
95        let inner = extract_generic_inner(&ty).unwrap();
96        SimpleParamKind::Optional(inner)
97    } else if type_ident_is(&ty, "Vec") {
98        let inner = extract_generic_inner(&ty).unwrap();
99        SimpleParamKind::Repeatable(inner)
100    } else {
101        SimpleParamKind::Required
102    };
103    SimpleParam { name, ty, kind }
104}
105
106/// Detect the argument form from function parameters.
107fn detect_arg_form(input_fn: &ItemFn) -> Result<ArgForm, syn::Error> {
108    // Collect params after the first (ctx: &TaskContext)
109    let params: Vec<_> = input_fn
110        .sig
111        .inputs
112        .iter()
113        .skip(1) // skip ctx
114        .collect();
115
116    if params.is_empty() {
117        return Ok(ArgForm::ZeroArgs);
118    }
119
120    if params.len() > 1 {
121        // Multiple params -> Form 2 (simple args)
122        let mut simple_params = Vec::new();
123        for param in params {
124            let (name, ty) = extract_typed_param(param)?;
125            simple_params.push(classify_param(name, ty));
126        }
127        return Ok(ArgForm::SimpleArgs(simple_params));
128    }
129
130    // Exactly one param. Check if it's a known primitive -> Form 2, else -> Form 3.
131    let (name, ty) = extract_typed_param(params[0])?;
132    if is_known_primitive(&ty) {
133        let simple = classify_param(name, ty);
134        return Ok(ArgForm::SimpleArgs(vec![simple]));
135    }
136
137    // Non-primitive single param -> Form 3 (parser struct)
138    Ok(ArgForm::ParserStruct {
139        param_name: name,
140        param_type: Box::new(ty),
141    })
142}
143
144/// Extract the name and type from a function parameter.
145fn extract_typed_param(arg: &FnArg) -> Result<(syn::Ident, syn::Type), syn::Error> {
146    match arg {
147        FnArg::Typed(pat_type) => {
148            let name = match pat_type.pat.as_ref() {
149                Pat::Ident(pat_ident) => pat_ident.ident.clone(),
150                other => {
151                    return Err(syn::Error::new_spanned(
152                        other,
153                        "expected a simple identifier pattern for task parameter",
154                    ));
155                }
156            };
157            Ok((name, (*pat_type.ty).clone()))
158        }
159        FnArg::Receiver(r) => Err(syn::Error::new_spanned(
160            r,
161            "task functions cannot have a `self` parameter",
162        )),
163    }
164}
165
166/// Attribute macro for defining a rnme task.
167///
168/// Supports both sync and async task functions. The function is wrapped
169/// to produce the `TaskFn` signature: `fn(&TaskContext, &[String]) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>`.
170///
171/// The generated `TaskDef` includes `group: __RNME_GROUP` and
172/// `dir: __RNME_DIR`. Both constants are injected by the code generator at
173/// compile time. For standalone usage (tests, examples), define
174/// `const __RNME_GROUP: &str = "";` and `const __RNME_DIR: &str = "";`
175/// manually — the empty `__RNME_DIR` makes `ctx.spawn` inherit the
176/// process cwd as before.
177///
178/// If `__RNME_GROUP` / `__RNME_DIR` aren't in scope at the expansion site,
179/// the macro fails to compile with `cannot find value `__RNME_GROUP` in
180/// this scope`. To declare reusable tasks in a regular library crate
181/// (one without a RUNME.rs), use [`macro@task_template`] instead and have
182/// the consumer site stamp it with [`macro@import_task`].
183///
184/// The task description is taken from the function's `///` doc comments.
185///
186/// # Three argument forms
187///
188/// **Form 1: Zero args**
189/// ```ignore
190/// /// Build the project
191/// #[rnme::task]
192/// async fn build(ctx: &TaskContext) -> TaskResult {
193///     ctx.exec("cargo build").await?.ok()?;
194///     Ok(())
195/// }
196/// ```
197///
198/// **Form 2: Simple args** (auto-generates clap from params)
199/// ```ignore
200/// /// Deploy to environment
201/// #[rnme::task]
202/// async fn deploy(ctx: &TaskContext, env: String, port: u16, verbose: bool) -> TaskResult {
203///     // env -> --env <value>, port -> --port <value>, verbose -> --verbose (flag)
204///     Ok(())
205/// }
206/// ```
207///
208/// **Form 3: Parser struct** (single non-primitive param, uses clap derive)
209/// ```ignore
210/// #[derive(clap::Parser)]
211/// struct DeployArgs {
212///     #[arg(long)]
213///     env: String,
214/// }
215///
216/// /// Deploy to environment
217/// #[rnme::task]
218/// async fn deploy(ctx: &TaskContext, args: DeployArgs) -> TaskResult {
219///     Ok(())
220/// }
221/// ```
222#[proc_macro_attribute]
223pub fn task(attr: TokenStream, item: TokenStream) -> TokenStream {
224    let mut input_fn = parse_macro_input!(item as ItemFn);
225    let fn_name = input_fn.sig.ident.clone();
226    let fn_name_str = fn_name.to_string();
227    let is_async = input_fn.sig.asyncness.is_some();
228
229    // Renamed body symbol. The user's fn keeps its full signature and
230    // body but is emitted under this private name. The user-facing
231    // identifier is taken over by the typed shim (below) that returns a
232    // `TaskBuilder`. Both the shim and the string-args wrapper call
233    // this symbol.
234    let body_name = syn::Ident::new(&format!("__rnme_body_{}", fn_name), fn_name.span());
235
236    let TaskFnMeta {
237        desc_tokens,
238        ui_hint_tokens,
239        arg_form,
240    } = match parse_task_attrs_and_meta(attr, &input_fn) {
241        Ok(m) => m,
242        Err(e) => return e.to_compile_error().into(),
243    };
244
245    // Inject start_task() as the first statement in the function body
246    {
247        let task_name_str = &fn_name_str;
248        // Extract the actual context parameter name (first param) instead of hardcoding "ctx"
249        let ctx_ident = match input_fn.sig.inputs.first() {
250            Some(FnArg::Typed(pat_type)) => match pat_type.pat.as_ref() {
251                Pat::Ident(pat_ident) => pat_ident.ident.clone(),
252                _ => syn::Ident::new("ctx", proc_macro2::Span::call_site()),
253            },
254            _ => syn::Ident::new("ctx", proc_macro2::Span::call_site()),
255        };
256        let start_task_stmt: syn::Stmt = syn::parse_quote! {
257            let _task = #ctx_ident.start_task(#task_name_str);
258        };
259        input_fn.block.stmts.insert(0, start_task_stmt);
260    }
261
262    // Generate a wrapper function name for the TaskFn registration
263    let wrapper_name = syn::Ident::new(&format!("__runme_taskfn_{}", fn_name), fn_name.span());
264
265    // Generate the arg_metadata function name
266    let arg_metadata_name =
267        syn::Ident::new(&format!("__runme_argmeta_{}", fn_name), fn_name.span());
268
269    // Named static holding the TaskDef. Both inventory and the typed shim
270    // (Phase 2) reference this single instance.
271    let taskdef_static_name =
272        syn::Ident::new(&format!("__RNME_TASKDEF_{}", fn_name), fn_name.span());
273
274    // Detect whether the function has an explicit return type (Result) or returns ()
275    let has_return_type = !matches!(input_fn.sig.output, ReturnType::Default);
276
277    // Capture the typed parameter list (after the `ctx: &TaskContext`
278    // first param) for the public shim. We need both the original `name:
279    // ty` pattern (for the shim signature) and bare idents (for
280    // forwarding into the closure's call to `body_name`). Lifted before
281    // we rename the input fn so they remain in sync with the body's
282    // signature.
283    let typed_params: Vec<(syn::Ident, syn::Type)> = input_fn
284        .sig
285        .inputs
286        .iter()
287        .skip(1)
288        .filter_map(|arg| match arg {
289            FnArg::Typed(pat_type) => match pat_type.pat.as_ref() {
290                Pat::Ident(pat_ident) => Some((pat_ident.ident.clone(), (*pat_type.ty).clone())),
291                _ => None,
292            },
293            FnArg::Receiver(_) => None,
294        })
295        .collect();
296    let shim_param_decls: Vec<proc_macro2::TokenStream> = typed_params
297        .iter()
298        .map(|(name, ty)| quote! { #name: #ty })
299        .collect();
300    let shim_param_idents: Vec<syn::Ident> =
301        typed_params.iter().map(|(name, _)| name.clone()).collect();
302
303    // Rename the user's fn to the private body symbol. The shim emitted
304    // below takes over the public ident. The string-args wrapper and the
305    // shim closure both call `body_name` to dispatch to the actual user
306    // code. All other metadata (doc-comment description, mode/ui_hint)
307    // continues to live on the `TaskDef` named static, not the shim.
308    input_fn.sig.ident = body_name.clone();
309    // Strip `pub`/visibility from the renamed body — it's private to the
310    // module. The shim below carries the original visibility (always
311    // `pub fn` so descendant crates can reference it via `subtasks::...`).
312    input_fn.vis = syn::Visibility::Inherited;
313
314    // Generate the parse block, function call expression, and arg_metadata function
315    // based on argument form.
316    //
317    // For forms with arguments:
318    // - The parse_block uses `match`/`Err` (not `?`) to return parse errors as futures,
319    //   since the wrapper function returns `Pin<Box<Future<Result>>>`, not `Result`.
320    // - The parse_block runs synchronously (before `Box::pin`) so `__args` doesn't need
321    //   to outlive the function body.
322    // - Parsed values are moved into the async block for async tasks.
323    let (parse_block, fn_call, arg_metadata_tokens) = match &arg_form {
324        ArgForm::ZeroArgs => {
325            let parse = quote! {};
326            let call = quote! { #body_name(ctx) };
327            let metadata = quote! {
328                fn #arg_metadata_name() -> Option<::rnme::clap::Command> {
329                    None
330                }
331            };
332            (parse, call, metadata)
333        }
334        ArgForm::SimpleArgs(params) => {
335            let (parse_stmts, call_args, cmd_build) =
336                generate_simple_args(fn_name_str.clone(), params);
337            let parse = parse_stmts;
338            let call = quote! { #body_name(ctx, #(#call_args),*) };
339            let metadata = quote! {
340                fn #arg_metadata_name() -> Option<::rnme::clap::Command> {
341                    Some({ #cmd_build })
342                }
343            };
344            (parse, call, metadata)
345        }
346        ArgForm::ParserStruct {
347            param_name: _,
348            param_type,
349        } => {
350            let parse = quote! {
351                let __parsed = match <#param_type as ::rnme::clap::Parser>::try_parse_from(
352                    ::std::iter::once(::std::string::String::from(#fn_name_str))
353                        .chain(__args.iter().cloned())
354                ) {
355                    Ok(v) => v,
356                    Err(e) => return ::std::boxed::Box::pin(::std::future::ready(
357                        Err(::rnme::error::TaskError::from_display(e))
358                    )),
359                };
360            };
361            let call = quote! { #body_name(ctx, __parsed) };
362            let metadata = quote! {
363                fn #arg_metadata_name() -> Option<::rnme::clap::Command> {
364                    Some(<#param_type as ::rnme::clap::CommandFactory>::command())
365                }
366            };
367            (parse, call, metadata)
368        }
369    };
370
371    // Build the wrapper function. The wrapper adapts the user's function
372    // (sync/async, void/Result, with/without args) to TaskFn:
373    //   for<'a> fn(&'a TaskContext, &[String]) -> Pin<Box<dyn Future<...> + Send + 'a>>
374    //
375    // The parse_block runs synchronously (before `Box::pin`), so that `__args`
376    // doesn't need to live into the async future. The parsed values are moved
377    // into the async block.
378    let wrapper = match (is_async, has_return_type) {
379        (true, true) => {
380            // async fn(...) -> Result<(), TaskError>
381            quote! {
382                fn #wrapper_name<'__runme_lt>(ctx: &'__runme_lt ::rnme::task::TaskContext, __args: &[String]) -> ::std::pin::Pin<::std::boxed::Box<dyn ::std::future::Future<Output = ::std::result::Result<(), ::rnme::error::TaskError>> + Send + '__runme_lt>> {
383                    #parse_block
384                    ::std::boxed::Box::pin(async move { #fn_call .await })
385                }
386            }
387        }
388        (true, false) => {
389            // async fn(...) — no return type, wrap with Ok(())
390            quote! {
391                fn #wrapper_name<'__runme_lt>(ctx: &'__runme_lt ::rnme::task::TaskContext, __args: &[String]) -> ::std::pin::Pin<::std::boxed::Box<dyn ::std::future::Future<Output = ::std::result::Result<(), ::rnme::error::TaskError>> + Send + '__runme_lt>> {
392                    #parse_block
393                    ::std::boxed::Box::pin(async move {
394                        #fn_call .await;
395                        Ok(())
396                    })
397                }
398            }
399        }
400        (false, true) => {
401            // fn(...) -> Result<(), TaskError>
402            quote! {
403                fn #wrapper_name<'__runme_lt>(ctx: &'__runme_lt ::rnme::task::TaskContext, __args: &[String]) -> ::std::pin::Pin<::std::boxed::Box<dyn ::std::future::Future<Output = ::std::result::Result<(), ::rnme::error::TaskError>> + Send + '__runme_lt>> {
404                    #parse_block
405                    let result = #fn_call;
406                    ::std::boxed::Box::pin(::std::future::ready(result))
407                }
408            }
409        }
410        (false, false) => {
411            // fn(...) — no return type, wrap with Ok(())
412            quote! {
413                fn #wrapper_name<'__runme_lt>(ctx: &'__runme_lt ::rnme::task::TaskContext, __args: &[String]) -> ::std::pin::Pin<::std::boxed::Box<dyn ::std::future::Future<Output = ::std::result::Result<(), ::rnme::error::TaskError>> + Send + '__runme_lt>> {
414                    #parse_block
415                    #fn_call;
416                    ::std::boxed::Box::pin(::std::future::ready(Ok(())))
417                }
418            }
419        }
420    };
421
422    // Body expression inside the shim's async block, matching the
423    // (is_async, has_return_type) matrix of the user's fn. The async
424    // block lives inside `Box::pin(async move { ... })`. The async
425    // block captures `body_ctx: &TaskContext` (the closure param) and
426    // the typed args (by `move`), then calls the renamed body symbol.
427    let shim_body_expr = match (is_async, has_return_type) {
428        (true, true) => quote! {
429            #body_name(body_ctx, #(#shim_param_idents),*).await
430        },
431        (true, false) => quote! {
432            #body_name(body_ctx, #(#shim_param_idents),*).await;
433            ::std::result::Result::Ok(())
434        },
435        (false, true) => quote! {
436            #body_name(body_ctx, #(#shim_param_idents),*)
437        },
438        (false, false) => quote! {
439            #body_name(body_ctx, #(#shim_param_idents),*);
440            ::std::result::Result::Ok(())
441        },
442    };
443
444    // Public typed shim at the original fn name. Returns a `TaskBuilder`
445    // configured with `Invocation::Factory` so the engine dispatches to
446    // the renamed body symbol with typed args, bypassing the
447    // string-args parser entirely. `#[must_use]` triggers a warning when
448    // a caller writes `build_wasm(ctx, true, false);` without `.await?`
449    // or `.spawn()?`.
450    let shim = quote! {
451        #[must_use = "task builders do nothing until `.await` or `.spawn()` — \
452                      a bare call constructs the builder and drops it"]
453        pub fn #fn_name(
454            ctx: &::rnme::task::TaskContext,
455            #(#shim_param_decls,)*
456        ) -> ::rnme::execution::builder::TaskBuilder {
457            ::rnme::execution::builder::TaskBuilder::from_factory(
458                ctx,
459                &#taskdef_static_name,
460                ::std::boxed::Box::new(move |body_ctx: &::rnme::task::TaskContext| {
461                    ::std::boxed::Box::pin(async move {
462                        #shim_body_expr
463                    })
464                }),
465            )
466        }
467    };
468
469    // No explicit hardening probe is needed: the emitted `TaskDef` static
470    // below references `__RNME_GROUP` / `__RNME_DIR` as bare idents, so
471    // the macro already fails to compile when they're not in scope
472    // (rustc E0425 pointing at the `#[rnme::task]` attribute). The
473    // user-facing mitigation is the doc-comment on `#[rnme::task]`
474    // pointing at `#[rnme::task_template]` for library crates.
475    let expanded = quote! {
476        #input_fn
477
478        #wrapper
479
480        #arg_metadata_tokens
481
482        #[allow(non_upper_case_globals)]
483        pub static #taskdef_static_name: ::rnme::task::TaskDef = ::rnme::task::TaskDef {
484            name: #fn_name_str,
485            description: #desc_tokens,
486            group: __RNME_GROUP,
487            dir: __RNME_DIR,
488            func: ::rnme::task::TaskFnKind::Static(#wrapper_name),
489            arg_metadata: #arg_metadata_name,
490            ui_hint: #ui_hint_tokens,
491        };
492
493        ::rnme::inventory::submit! {
494            ::rnme::task::TaskDefRef(&#taskdef_static_name)
495        }
496
497        #shim
498    };
499
500    expanded.into()
501}
502
503/// Attribute macro for declaring a reusable task **template** in a regular Rust crate.
504///
505/// Distinct from `#[rnme::task]`: a template is *not* a self-registering task. It
506/// produces the building blocks (renamed body, string-args wrapper, arg-metadata
507/// fn, and a per-task `macro_rules!` helper) that a *consumer* RUNME.rs can
508/// re-stamp into a fully-local typed task registration via `rnme::import_task!`.
509///
510/// The library site emits **no** `TaskDef` static, **no** `inventory::submit!`, and
511/// **no** typed shim. All three are stamped at the consumer site by the per-task
512/// helper macro, using the consumer's `__RNME_GROUP` and `__RNME_DIR` constants.
513///
514/// Accepts the same three argument forms as `#[rnme::task]`. The captured signature,
515/// description (from doc comments), and `ui_hint` are baked into the helper macro
516/// at proc-macro time.
517///
518/// See `docs/task_templates.md` for the design.
519#[proc_macro_attribute]
520pub fn task_template(attr: TokenStream, item: TokenStream) -> TokenStream {
521    let mut input_fn = parse_macro_input!(item as ItemFn);
522    let fn_name = input_fn.sig.ident.clone();
523    let fn_name_str = fn_name.to_string();
524    let is_async = input_fn.sig.asyncness.is_some();
525
526    // Per-task symbol names. The body, string-args wrapper, and argmeta fn
527    // live in the library crate and are reached from the consumer site via
528    // `$crate::...` inside the stamp macro_rules expansion.
529    let body_name = syn::Ident::new(&format!("__rnme_body_{}", fn_name), fn_name.span());
530    let wrapper_name = syn::Ident::new(&format!("__runme_taskfn_{}", fn_name), fn_name.span());
531    let arg_metadata_name =
532        syn::Ident::new(&format!("__runme_argmeta_{}", fn_name), fn_name.span());
533    let stamp_macro_name =
534        syn::Ident::new(&format!("__rnme_stamp_{}", fn_name), fn_name.span());
535
536    let TaskFnMeta {
537        desc_tokens,
538        ui_hint_tokens,
539        arg_form,
540    } = match parse_task_attrs_and_meta(attr, &input_fn) {
541        Ok(m) => m,
542        Err(e) => return e.to_compile_error().into(),
543    };
544
545    let has_return_type = !matches!(input_fn.sig.output, ReturnType::Default);
546
547    // Capture the typed parameter list (after `ctx: &TaskContext`) for the
548    // stamped-out typed shim. We embed the original `name: ty` token shapes
549    // verbatim into the macro_rules arm — the consumer is responsible for
550    // having the types in scope (e.g. via `use rnme_cargo::BuildOpts;`).
551    let typed_params: Vec<(syn::Ident, syn::Type)> = input_fn
552        .sig
553        .inputs
554        .iter()
555        .skip(1)
556        .filter_map(|arg| match arg {
557            FnArg::Typed(pat_type) => match pat_type.pat.as_ref() {
558                Pat::Ident(pat_ident) => Some((pat_ident.ident.clone(), (*pat_type.ty).clone())),
559                _ => None,
560            },
561            FnArg::Receiver(_) => None,
562        })
563        .collect();
564    let shim_param_decls: Vec<proc_macro2::TokenStream> = typed_params
565        .iter()
566        .map(|(name, ty)| quote! { #name: #ty })
567        .collect();
568    let shim_param_idents: Vec<syn::Ident> =
569        typed_params.iter().map(|(name, _)| name.clone()).collect();
570
571    // Rename the user's fn to the private body symbol and make it `pub` so
572    // the stamp expansion can reach it as `$crate::__rnme_body_<name>`.
573    //
574    // **No `start_task` injection here.** The runtime tracing span is opened
575    // at the consumer site (with the consumer-stamped name) by the stamp
576    // expansion below.
577    input_fn.sig.ident = body_name.clone();
578    input_fn.vis = syn::Visibility::Public(syn::Token![pub](fn_name.span()));
579
580    // Build the string-args wrapper for the library site. Same shape as
581    // `#[rnme::task]` emits, but `pub` so the consumer-stamped wrapper can
582    // delegate to it via `$crate::__runme_taskfn_<name>`. The wrapper does
583    // not open a tracing span — that happens at the consumer site.
584    let (parse_block, fn_call, arg_metadata_tokens) = match &arg_form {
585        ArgForm::ZeroArgs => {
586            let parse = quote! {};
587            let call = quote! { #body_name(ctx) };
588            let metadata = quote! {
589                pub fn #arg_metadata_name() -> Option<::rnme::clap::Command> {
590                    None
591                }
592            };
593            (parse, call, metadata)
594        }
595        ArgForm::SimpleArgs(params) => {
596            let (parse_stmts, call_args, cmd_build) =
597                generate_simple_args(fn_name_str.clone(), params);
598            let parse = parse_stmts;
599            let call = quote! { #body_name(ctx, #(#call_args),*) };
600            let metadata = quote! {
601                pub fn #arg_metadata_name() -> Option<::rnme::clap::Command> {
602                    Some({ #cmd_build })
603                }
604            };
605            (parse, call, metadata)
606        }
607        ArgForm::ParserStruct {
608            param_name: _,
609            param_type,
610        } => {
611            let parse = quote! {
612                let __parsed = match <#param_type as ::rnme::clap::Parser>::try_parse_from(
613                    ::std::iter::once(::std::string::String::from(#fn_name_str))
614                        .chain(__args.iter().cloned())
615                ) {
616                    Ok(v) => v,
617                    Err(e) => return ::std::boxed::Box::pin(::std::future::ready(
618                        Err(::rnme::error::TaskError::from_display(e))
619                    )),
620                };
621            };
622            let call = quote! { #body_name(ctx, __parsed) };
623            let metadata = quote! {
624                pub fn #arg_metadata_name() -> Option<::rnme::clap::Command> {
625                    Some(<#param_type as ::rnme::clap::CommandFactory>::command())
626                }
627            };
628            (parse, call, metadata)
629        }
630    };
631
632    let wrapper = match (is_async, has_return_type) {
633        (true, true) => quote! {
634            pub fn #wrapper_name<'__runme_lt>(ctx: &'__runme_lt ::rnme::task::TaskContext, __args: &[String]) -> ::std::pin::Pin<::std::boxed::Box<dyn ::std::future::Future<Output = ::std::result::Result<(), ::rnme::error::TaskError>> + Send + '__runme_lt>> {
635                #parse_block
636                ::std::boxed::Box::pin(async move { #fn_call .await })
637            }
638        },
639        (true, false) => quote! {
640            pub fn #wrapper_name<'__runme_lt>(ctx: &'__runme_lt ::rnme::task::TaskContext, __args: &[String]) -> ::std::pin::Pin<::std::boxed::Box<dyn ::std::future::Future<Output = ::std::result::Result<(), ::rnme::error::TaskError>> + Send + '__runme_lt>> {
641                #parse_block
642                ::std::boxed::Box::pin(async move {
643                    #fn_call .await;
644                    Ok(())
645                })
646            }
647        },
648        (false, true) => quote! {
649            pub fn #wrapper_name<'__runme_lt>(ctx: &'__runme_lt ::rnme::task::TaskContext, __args: &[String]) -> ::std::pin::Pin<::std::boxed::Box<dyn ::std::future::Future<Output = ::std::result::Result<(), ::rnme::error::TaskError>> + Send + '__runme_lt>> {
650                #parse_block
651                let result = #fn_call;
652                ::std::boxed::Box::pin(::std::future::ready(result))
653            }
654        },
655        (false, false) => quote! {
656            pub fn #wrapper_name<'__runme_lt>(ctx: &'__runme_lt ::rnme::task::TaskContext, __args: &[String]) -> ::std::pin::Pin<::std::boxed::Box<dyn ::std::future::Future<Output = ::std::result::Result<(), ::rnme::error::TaskError>> + Send + '__runme_lt>> {
657                #parse_block
658                #fn_call;
659                ::std::boxed::Box::pin(::std::future::ready(Ok(())))
660            }
661        },
662    };
663
664    // Shim body expression — what the factory closure does at the consumer
665    // site to dispatch to the library body. Matches the (is_async,
666    // has_return_type) matrix of the user's fn.
667    let shim_body_expr = match (is_async, has_return_type) {
668        (true, true) => quote! {
669            $crate::#body_name(body_ctx, #(#shim_param_idents),*).await
670        },
671        (true, false) => quote! {
672            $crate::#body_name(body_ctx, #(#shim_param_idents),*).await;
673            ::std::result::Result::Ok(())
674        },
675        (false, true) => quote! {
676            $crate::#body_name(body_ctx, #(#shim_param_idents),*)
677        },
678        (false, false) => quote! {
679            $crate::#body_name(body_ctx, #(#shim_param_idents),*);
680            ::std::result::Result::Ok(())
681        },
682    };
683
684    // Per-task stamp helper. `#[macro_export]` makes it reachable as
685    // `<library_crate>::__rnme_stamp_<name>!` from the consumer site.
686    //
687    // The arm:
688    //
689    // - Reads `__RNME_GROUP` / `__RNME_DIR` as bare identifiers — they bind
690    //   to the consumer's `const __RNME_GROUP: &str = ...;` /
691    //   `const __RNME_DIR: &str = ...;` (call-site scope in macro_rules).
692    //
693    // - Refers to library fns via `$crate::...` so they resolve back to the
694    //   defining crate regardless of how the consumer imports it.
695    //
696    // - Emits a consumer-local string-args wrapper that opens the tracing
697    //   span with the consumer's stamped name, then delegates to the library
698    //   wrapper. This way `start_task` fires for both the typed path (via
699    //   the factory closure) and the string-args path (via this wrapper)
700    //   with the consumer-visible name.
701    //
702    // - Emits `pub static __RNME_TASKDEF_<name>`, the `inventory::submit!`,
703    //   and the `pub fn <name>(...) -> TaskBuilder` typed shim.
704    let stamp_wrapper_name =
705        syn::Ident::new(&format!("__runme_taskfn_{}", fn_name), fn_name.span());
706    let stamp_taskdef_name =
707        syn::Ident::new(&format!("__RNME_TASKDEF_{}", fn_name), fn_name.span());
708
709    let stamp_macro = quote! {
710        #[macro_export]
711        #[doc(hidden)]
712        macro_rules! #stamp_macro_name {
713            () => {
714                // Consumer-local string-args wrapper that opens the tracing
715                // span with the consumer-stamped name (here baked in as the
716                // template's fn name — `rnme::import_task!` does not allow
717                // renaming today).
718                #[allow(non_snake_case)]
719                fn #stamp_wrapper_name<'__runme_lt>(
720                    ctx: &'__runme_lt ::rnme::task::TaskContext,
721                    __args: &[::std::string::String],
722                ) -> ::std::pin::Pin<::std::boxed::Box<
723                    dyn ::std::future::Future<
724                        Output = ::std::result::Result<(), ::rnme::error::TaskError>,
725                    > + ::std::marker::Send + '__runme_lt,
726                >> {
727                    let __inner = $crate::#wrapper_name(ctx, __args);
728                    ::std::boxed::Box::pin(async move {
729                        let _task = ctx.start_task(#fn_name_str);
730                        __inner.await
731                    })
732                }
733
734                #[allow(non_upper_case_globals)]
735                pub static #stamp_taskdef_name: ::rnme::task::TaskDef = ::rnme::task::TaskDef {
736                    name: #fn_name_str,
737                    description: #desc_tokens,
738                    group: __RNME_GROUP,
739                    dir: __RNME_DIR,
740                    func: ::rnme::task::TaskFnKind::Static(#stamp_wrapper_name),
741                    arg_metadata: $crate::#arg_metadata_name,
742                    ui_hint: #ui_hint_tokens,
743                };
744
745                ::rnme::inventory::submit! {
746                    ::rnme::task::TaskDefRef(&#stamp_taskdef_name)
747                }
748
749                #[must_use = "task builders do nothing until `.await` or `.spawn()` — \
750                              a bare call constructs the builder and drops it"]
751                pub fn #fn_name(
752                    ctx: &::rnme::task::TaskContext,
753                    #(#shim_param_decls,)*
754                ) -> ::rnme::execution::builder::TaskBuilder {
755                    ::rnme::execution::builder::TaskBuilder::from_factory(
756                        ctx,
757                        &#stamp_taskdef_name,
758                        ::std::boxed::Box::new(move |body_ctx: &::rnme::task::TaskContext| {
759                            ::std::boxed::Box::pin(async move {
760                                let _task = body_ctx.start_task(#fn_name_str);
761                                #shim_body_expr
762                            })
763                        }),
764                    )
765                }
766            };
767        }
768    };
769
770    let expanded = quote! {
771        #input_fn
772
773        #wrapper
774
775        #arg_metadata_tokens
776
777        #stamp_macro
778    };
779
780    expanded.into()
781}
782
783/// Shared front-matter parser for `#[rnme::task]` and `#[rnme::task_template]`.
784///
785/// Pulls the doc-comment description, the optional `mode = cli|tui`
786/// attribute, and detects the argument form from the function signature.
787/// Returns the assembled token bits the two macros both need.
788struct TaskFnMeta {
789    desc_tokens: proc_macro2::TokenStream,
790    ui_hint_tokens: proc_macro2::TokenStream,
791    arg_form: ArgForm,
792}
793
794fn parse_task_attrs_and_meta(
795    attr: TokenStream,
796    input_fn: &ItemFn,
797) -> Result<TaskFnMeta, syn::Error> {
798    // Parse the attribute as a comma-separated list of name = value pairs.
799    let attr_parser = syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated;
800    let parsed_attrs = syn::parse::Parser::parse(attr_parser, attr)?;
801
802    let mut ui_hint: Option<proc_macro2::TokenStream> = None;
803    for meta in parsed_attrs {
804        match meta {
805            Meta::NameValue(MetaNameValue { path, value, .. }) => {
806                let key = path.get_ident().map(|i| i.to_string()).unwrap_or_default();
807                match key.as_str() {
808                    "mode" => {
809                        let mode_str = match &value {
810                            Expr::Path(p) => match p.path.get_ident() {
811                                Some(i) => i.to_string(),
812                                None => {
813                                    return Err(syn::Error::new_spanned(
814                                        value,
815                                        "expected `cli` or `tui`",
816                                    ));
817                                }
818                            },
819                            Expr::Lit(ExprLit {
820                                lit: Lit::Str(s), ..
821                            }) => s.value(),
822                            _ => {
823                                return Err(syn::Error::new_spanned(
824                                    value,
825                                    "expected `cli` or `tui` (bare ident or string literal)",
826                                ));
827                            }
828                        };
829                        ui_hint = Some(match mode_str.as_str() {
830                            "cli" | "Cli" | "CLI" => {
831                                quote! { Some(::rnme::task::UiHint::Cli) }
832                            }
833                            "tui" | "Tui" | "TUI" => {
834                                quote! { Some(::rnme::task::UiHint::Tui) }
835                            }
836                            other => {
837                                return Err(syn::Error::new_spanned(
838                                    value,
839                                    format!("unknown mode `{}` — expected `cli` or `tui`", other),
840                                ));
841                            }
842                        });
843                    }
844                    "desc" | "description" => {
845                        return Err(syn::Error::new_spanned(
846                            path,
847                            "task descriptions come from `///` doc comments — \
848                             remove this attribute and write a `///` line above the fn",
849                        ));
850                    }
851                    other => {
852                        return Err(syn::Error::new_spanned(
853                            path,
854                            format!("unknown attribute: {}", other),
855                        ));
856                    }
857                }
858            }
859            other => {
860                return Err(syn::Error::new_spanned(other, "expected `key = value` format"));
861            }
862        }
863    }
864
865    let ui_hint_tokens = ui_hint.unwrap_or_else(|| quote! { None });
866
867    // Description from `///` doc comments.
868    let doc_lines: Vec<String> = input_fn
869        .attrs
870        .iter()
871        .filter_map(|attr| {
872            if attr.path().is_ident("doc")
873                && let Meta::NameValue(MetaNameValue {
874                    value:
875                        Expr::Lit(ExprLit {
876                            lit: Lit::Str(s), ..
877                        }),
878                    ..
879                }) = &attr.meta
880            {
881                return Some(s.value().trim().to_string());
882            }
883            None
884        })
885        .collect();
886    let desc_tokens = if doc_lines.is_empty() {
887        quote! { None }
888    } else {
889        let joined = doc_lines.join(" ");
890        quote! { Some(#joined) }
891    };
892
893    let arg_form = detect_arg_form(input_fn)?;
894
895    Ok(TaskFnMeta {
896        desc_tokens,
897        ui_hint_tokens,
898        arg_form,
899    })
900}
901
902/// Generate the parsing block, call arguments, and clap::Command builder
903/// for Form 2 (simple args).
904fn generate_simple_args(
905    task_name: String,
906    params: &[SimpleParam],
907) -> (
908    proc_macro2::TokenStream,
909    Vec<proc_macro2::TokenStream>,
910    proc_macro2::TokenStream,
911) {
912    // Build the clap::Command with args
913    let mut arg_builders = Vec::new();
914    for param in params {
915        let name_str = param.name.to_string();
916        let long_name = name_str.replace('_', "-");
917        let arg_build = match &param.kind {
918            SimpleParamKind::Bool => {
919                quote! {
920                    ::rnme::clap::Arg::new(#name_str)
921                        .long(#long_name)
922                        .action(::rnme::clap::ArgAction::SetTrue)
923                }
924            }
925            SimpleParamKind::Required => {
926                quote! {
927                    ::rnme::clap::Arg::new(#name_str)
928                        .long(#long_name)
929                        .required(true)
930                        .action(::rnme::clap::ArgAction::Set)
931                }
932            }
933            SimpleParamKind::Optional(_) => {
934                quote! {
935                    ::rnme::clap::Arg::new(#name_str)
936                        .long(#long_name)
937                        .required(false)
938                        .action(::rnme::clap::ArgAction::Set)
939                }
940            }
941            SimpleParamKind::Repeatable(_) => {
942                quote! {
943                    ::rnme::clap::Arg::new(#name_str)
944                        .long(#long_name)
945                        .action(::rnme::clap::ArgAction::Append)
946                }
947            }
948        };
949        arg_builders.push(arg_build);
950    }
951
952    // Build the Command construction code (shared between wrapper and metadata)
953    let cmd_build = quote! {
954        ::rnme::clap::Command::new(#task_name)
955            #(.arg(#arg_builders))*
956    };
957
958    // Generate the parsing code for the wrapper
959    let mut parse_stmts = Vec::new();
960    let mut call_args = Vec::new();
961
962    // Parse the args with clap. Uses match + early return instead of `?`
963    // because the wrapper function returns Pin<Box<Future<Result>>>, not Result.
964    let parse_match = quote! {
965        let __clap_matches = match ({
966            #cmd_build
967        }).try_get_matches_from(
968            ::std::iter::once(::std::string::String::from(#task_name))
969                .chain(__args.iter().cloned())
970        ) {
971            Ok(m) => m,
972            Err(e) => return ::std::boxed::Box::pin(::std::future::ready(
973                Err(::rnme::error::TaskError::from_display(e))
974            )),
975        };
976    };
977    parse_stmts.push(parse_match);
978
979    // Extract each parameter. Uses match + early return for parse errors.
980    for param in params {
981        let param_name = &param.name;
982        let name_str = param.name.to_string();
983        let ty = &param.ty;
984
985        let extract = match &param.kind {
986            SimpleParamKind::Bool => {
987                quote! {
988                    let #param_name: #ty = __clap_matches.get_flag(#name_str);
989                }
990            }
991            SimpleParamKind::Required => {
992                quote! {
993                    let #param_name: #ty = match __clap_matches.get_one::<String>(#name_str) {
994                        Some(v) => match v.parse::<#ty>() {
995                            Ok(parsed) => parsed,
996                            Err(e) => return ::std::boxed::Box::pin(::std::future::ready(
997                                Err(::rnme::error::TaskError::from_display(
998                                    format!("invalid value for --{}: {}", #name_str, e)
999                                ))
1000                            )),
1001                        },
1002                        None => return ::std::boxed::Box::pin(::std::future::ready(
1003                            Err(::rnme::error::TaskError::from_display(
1004                                format!("missing required argument: --{}", #name_str)
1005                            ))
1006                        )),
1007                    };
1008                }
1009            }
1010            SimpleParamKind::Optional(inner) => {
1011                quote! {
1012                    let #param_name: #ty = match __clap_matches.get_one::<String>(#name_str)
1013                        .map(|v| v.parse::<#inner>())
1014                        .transpose()
1015                    {
1016                        Ok(v) => v,
1017                        Err(e) => return ::std::boxed::Box::pin(::std::future::ready(
1018                            Err(::rnme::error::TaskError::from_display(
1019                                format!("invalid value for --{}: {}", #name_str, e)
1020                            ))
1021                        )),
1022                    };
1023                }
1024            }
1025            SimpleParamKind::Repeatable(inner) => {
1026                quote! {
1027                    let #param_name: #ty = match __clap_matches.get_many::<String>(#name_str)
1028                        .map(|vals| vals.map(|v| v.parse::<#inner>()).collect::<Result<Vec<_>, _>>())
1029                        .transpose()
1030                    {
1031                        Ok(v) => v.unwrap_or_default(),
1032                        Err(e) => return ::std::boxed::Box::pin(::std::future::ready(
1033                            Err(::rnme::error::TaskError::from_display(
1034                                format!("invalid value for --{}: {}", #name_str, e)
1035                            ))
1036                        )),
1037                    };
1038                }
1039            }
1040        };
1041        parse_stmts.push(extract);
1042        call_args.push(quote! { #param_name });
1043    }
1044
1045    let parse_block = quote! { #(#parse_stmts)* };
1046    (parse_block, call_args, cmd_build)
1047}
1048
1049/// Import a task template from a library crate into the current scope.
1050///
1051/// `rnme::import_task!(lib_crate::task_name);` expands to
1052/// `lib_crate::__rnme_stamp_task_name!();`, invoking the per-task stamp
1053/// helper that `#[rnme::task_template]` generated at the library site.
1054/// The expansion produces a fully-local typed task registration at the
1055/// consumer site — `pub static __RNME_TASKDEF_<name>`, an
1056/// `inventory::submit!`, and a `#[must_use] pub fn <name>(...) -> TaskBuilder`
1057/// shim — using the consumer's `__RNME_GROUP` / `__RNME_DIR`.
1058///
1059/// A function-like proc macro (not `macro_rules!`) because synthesizing
1060/// the identifier `__rnme_stamp_<task>` from a captured `$task:ident`
1061/// requires token pasting, which declarative macros can't do.
1062///
1063/// ```ignore
1064/// // In a RUNME.rs:
1065/// rnme::import_task!(rnme_test_task_templates::build);
1066/// ```
1067///
1068/// A typo in the task name produces a compile error pointing at the
1069/// library path (the missing `__rnme_stamp_<typo>!` macro).
1070#[proc_macro]
1071pub fn import_task(input: TokenStream) -> TokenStream {
1072    let path: syn::Path = match syn::parse(input) {
1073        Ok(p) => p,
1074        Err(e) => return e.to_compile_error().into(),
1075    };
1076
1077    if path.segments.is_empty() {
1078        return syn::Error::new_spanned(&path, "expected a path like `lib_crate::task_name`")
1079            .to_compile_error()
1080            .into();
1081    }
1082
1083    let mut lib_path = path.clone();
1084    // Pop the final segment — that's the task ident. Everything before is
1085    // the library path used to reach the stamp macro.
1086    let task_seg = lib_path
1087        .segments
1088        .pop()
1089        .expect("at least one segment, checked above")
1090        .into_value();
1091
1092    if !task_seg.arguments.is_empty() {
1093        return syn::Error::new_spanned(
1094            &task_seg.arguments,
1095            "task name must not carry generic arguments",
1096        )
1097        .to_compile_error()
1098        .into();
1099    }
1100
1101    if lib_path.segments.is_empty() {
1102        return syn::Error::new_spanned(
1103            &path,
1104            "expected `lib_crate::task_name` — a library path followed by the task name",
1105        )
1106        .to_compile_error()
1107        .into();
1108    }
1109    // Drop the trailing `::` separator left over from popping the final segment.
1110    lib_path.segments.pop_punct();
1111
1112    let task_ident = &task_seg.ident;
1113    let stamp_ident = syn::Ident::new(
1114        &format!("__rnme_stamp_{}", task_ident),
1115        task_ident.span(),
1116    );
1117
1118    let expanded = quote! {
1119        #lib_path :: #stamp_ident !();
1120    };
1121    expanded.into()
1122}
1123
1124/// Attribute macro for per-file initialization hooks.
1125///
1126/// Registers an `InitDef` via `inventory`. The function can accept either
1127/// `&mut InitContext` or no arguments.
1128///
1129/// The generated `InitDef` includes `group: __RNME_GROUP` and
1130/// `dir: __RNME_DIR`. Both constants are injected by the code generator at
1131/// compile time. For standalone usage (tests, examples), define
1132/// `const __RNME_GROUP: &str = "";` and `const __RNME_DIR: &str = "";`
1133/// manually.
1134///
1135/// Usage:
1136/// ```ignore
1137/// #[rnme::init]
1138/// fn setup(ctx: &mut InitContext) {
1139///     ctx.set_group_name("Auth Service");
1140/// }
1141/// ```
1142///
1143/// Or without arguments:
1144/// ```ignore
1145/// #[rnme::init]
1146/// fn setup() {
1147///     // one-time setup
1148/// }
1149/// ```
1150#[proc_macro_attribute]
1151pub fn init(_attr: TokenStream, item: TokenStream) -> TokenStream {
1152    let input_fn = parse_macro_input!(item as ItemFn);
1153    let fn_name = &input_fn.sig.ident;
1154
1155    // Determine whether the function takes an InitContext argument
1156    let has_ctx_arg = !input_fn.sig.inputs.is_empty();
1157
1158    // Generate a wrapper function name
1159    let wrapper_name = syn::Ident::new(&format!("__runme_initfn_{}", fn_name), fn_name.span());
1160
1161    // The wrapper adapts the user's function to fn(&mut InitContext)
1162    let wrapper = if has_ctx_arg {
1163        // fn(ctx: &mut InitContext) — pass through directly
1164        quote! {
1165            fn #wrapper_name(ctx: &mut ::rnme::init::InitContext) {
1166                #fn_name(ctx)
1167            }
1168        }
1169    } else {
1170        // fn() — ignore the context argument
1171        quote! {
1172            fn #wrapper_name(_ctx: &mut ::rnme::init::InitContext) {
1173                #fn_name()
1174            }
1175        }
1176    };
1177
1178    let expanded = quote! {
1179        #input_fn
1180
1181        #wrapper
1182
1183        ::rnme::inventory::submit! {
1184            ::rnme::init::InitDef {
1185                group: __RNME_GROUP,
1186                dir: __RNME_DIR,
1187                func: #wrapper_name,
1188            }
1189        }
1190    };
1191
1192    expanded.into()
1193}
1194
1195/// Build a structured `Cmd` from shell-like syntax.
1196///
1197/// Whitespace separates arguments. `{expr}` interpolates a Rust expression
1198/// as a single argument. `{expr...}` splats an `IntoIterator` as zero or
1199/// more arguments — useful for `Option<T>` (0/1 args), `Vec<T>`, slices, etc.
1200/// Quoted strings (`"..."`) are single arguments. Adjacent tokens (no
1201/// whitespace) merge into one argument.
1202///
1203/// ```ignore
1204/// // These are equivalent:
1205/// cmd!(curl -X POST {&url} -H "Content-Type: application/json")
1206/// Cmd::new("curl").arg("-X").arg("POST").arg(&url).arg("-H").arg("Content-Type: application/json")
1207///
1208/// // Splat for optional flags and lists:
1209/// let verbose = debug.then_some("--verbose");
1210/// cmd!(cargo test {verbose...} {test_names...})
1211/// ```
1212#[proc_macro]
1213pub fn cmd(input: TokenStream) -> TokenStream {
1214    match cmd_macro::expand_cmd(input.into()) {
1215        Ok(tokens) => tokens.into(),
1216        Err(e) => e.to_compile_error().into(),
1217    }
1218}