arcature-macros 0.1.0

Proc-macro crate for Arcature: #[model], #[request], #[controller], #[derive(Job)], #[derive(Event)].
Documentation
//! `#[command("name")]` -- annotates a function as a typed application
//! command.
//!
//! Validates the function signature (`pub async fn` with a return type) and
//! emits three things: the function unchanged, a
//! `pub const <FN>_COMMAND: ::arcature::CommandBinding` for the Unified
//! Application Graph, and a zero-sized **command type** carrying
//! `impl ::arcature::Command<S>` so the function is invokable through
//! `CommandRegistry::run`.
//!
//! ## Syntax
//!
//! ```ignore
//! #[command("users:prune")]
//! pub async fn prune_users(users: UserService) -> Result<()> {
//!     users.prune_inactive().await
//! }
//! ```
//!
//! The attribute argument is the command name (a string literal). A missing,
//! empty, or non-string name produces `error[ARC-M009]`; a bad signature
//! produces `error[ARC-M011]`; a non-function item produces `error[ARC-M001]`.
//!
//! ## The generated command type
//!
//! An attribute macro on a function has nothing to hang a trait impl on:
//! a function item type cannot be named in Rust. So the macro generates one
//! -- `prune_users` gets `PruneUsersCommand`, a unit struct whose `run`
//! resolves every parameter through `Resolve<S>`, awaits the function, and
//! maps its error into `CommandError::Failed`:
//!
//! ```ignore
//! CommandRegistry::<AppState>::new()
//!     .register_command::<PruneUsersCommand>()
//! ```
//!
//! Registration stays explicit -- the macro registers nothing, and
//! `module!`'s `commands:` section is still inspection metadata only. What
//! changed is that "explicit" now costs one line instead of a hand-written
//! closure that repeats the dependency wiring the signature already states.
//!
//! ## Why the signature is restricted
//!
//! The generated `run` must produce a `'static` future from a `&S` borrow,
//! so every parameter is resolved eagerly and moved in. That rules out
//! reference parameters, `self` receivers, and generic parameters (there
//! would be nothing to instantiate them with at the registry call site);
//! each is reported as `error[ARC-M011]` rather than surfacing as an
//! error inside generated code.

use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::spanned::Spanned;

use crate::diagnostic::{MacroError, MacroErrorCode, MacroResult};
use crate::signature::validate_public_async_fn;
use crate::util::to_pascal_case;

/// The implementation of `#[command("name")]`. Called by the thin `lib.rs`
/// entrypoint. Returns a [`MacroError`] (converted to `compile_error!` by
/// the entrypoint) on failure -- never panics.
pub fn command(attr: TokenStream, item: TokenStream) -> MacroResult {
    let name = parse_name(attr)?;
    let item_fn: syn::ItemFn =
        syn::parse2(item).map_err(|e| MacroError::from_syn(MacroErrorCode::ArcM001, e))?;

    validate_public_async_fn(&item_fn, MacroErrorCode::ArcM011, "#[command(...)]")?;

    let fn_ident = &item_fn.sig.ident;
    let fn_name = fn_ident.to_string();
    let const_ident = syn::Ident::new(
        &format!("{}_COMMAND", fn_name.to_uppercase()),
        fn_ident.span(),
    );
    let command_ident = syn::Ident::new(
        &format!("{}Command", to_pascal_case(&fn_name)),
        fn_ident.span(),
    );

    let dependency_types = dependency_types(&item_fn)?;
    let dependency_idents: Vec<syn::Ident> = (0..dependency_types.len())
        .map(|i| format_ident!("__arc_dependency_{i}"))
        .collect();

    let doc = format!(
        "The command type for [`{fn_name}`], generated by \
         `#[command(\"{name}\")]`.\n\n\
         Hand it to the registry with \
         `CommandRegistry::register_command::<{command_ident}>()`; the \
         registry then answers to `\"{name}\"`."
    );

    Ok(quote! {
        #item_fn

        #[allow(non_upper_case_globals)]
        pub const #const_ident: ::arcature::CommandBinding =
            ::arcature::CommandBinding { name: #name, function: #fn_name };

        #[doc = #doc]
        #[derive(Debug, Clone, Copy)]
        pub struct #command_ident;

        impl<S> ::arcature::Command<S> for #command_ident
        where
            S: ::core::marker::Send + ::core::marker::Sync + 'static,
            #(#dependency_types: ::arcature::Resolve<S>,)*
        {
            const NAME: &'static str = #name;

            fn run(state: &S) -> ::arcature::dx::CommandFuture {
                // Resolve before the future exists: the registry keeps the
                // handler forever, so the future must not borrow `state`.
                #(
                    let #dependency_idents =
                        <#dependency_types as ::arcature::Resolve<S>>::resolve(state);
                )*
                ::std::boxed::Box::pin(async move {
                    let __arc_outcome: ::std::result::Result<(), _> =
                        #fn_ident(#(#dependency_idents),*).await;
                    __arc_outcome.map_err(|__arc_error| {
                        ::arcature::CommandError::Failed(
                            ::std::string::ToString::to_string(&__arc_error),
                        )
                    })
                })
            }
        }
    })
}

/// Collects the parameter types the generated `run` must resolve from
/// application state, rejecting the signatures that cannot be resolved.
fn dependency_types(item_fn: &syn::ItemFn) -> Result<Vec<&syn::Type>, MacroError> {
    let sig = &item_fn.sig;

    if !sig.generics.params.is_empty() {
        return Err(MacroError::new(
            MacroErrorCode::ArcM011,
            sig.ident.span(),
            "#[command(...)] functions must not be generic -- the registry \
             stores one handler per name and has nothing to instantiate a \
             type parameter with.",
        ));
    }

    sig.inputs
        .iter()
        .map(|input| match input {
            syn::FnArg::Typed(pat_type) => match &*pat_type.ty {
                syn::Type::Reference(reference) => Err(MacroError::new(
                    MacroErrorCode::ArcM011,
                    reference.span(),
                    "#[command(...)] parameters must be owned values: each is \
                     resolved from application state via `Resolve<S>` and moved \
                     into a future the registry outlives.",
                )),
                ty => Ok(ty),
            },
            syn::FnArg::Receiver(receiver) => Err(MacroError::new(
                MacroErrorCode::ArcM011,
                receiver.span(),
                "#[command(...)] applies to a free function, not a method -- \
                 there is no receiver for the registry to supply.",
            )),
        })
        .collect()
}

/// Parses the attribute argument: a single non-empty string literal.
fn parse_name(attr: TokenStream) -> Result<String, MacroError> {
    let lit: syn::LitStr =
        syn::parse2(attr).map_err(|e| MacroError::from_syn(MacroErrorCode::ArcM009, e))?;
    let name = lit.value();
    if name.is_empty() {
        return Err(MacroError::new(
            MacroErrorCode::ArcM009,
            lit.span(),
            "#[command(\"...\")] name must not be empty",
        ));
    }
    Ok(name)
}

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

    fn expand(attr: TokenStream, item: TokenStream) -> String {
        command(attr, item).unwrap().to_string()
    }

    #[test]
    fn generates_command_binding_const() {
        let s = expand(
            quote! { "users:prune" },
            quote! { pub async fn prune_users() -> Result<()> { Ok(()) } },
        );
        assert!(s.contains("\"users:prune\""), "got: {s}");
        assert!(s.contains("PRUNE_USERS_COMMAND"), "got: {s}");
        assert!(s.contains("CommandBinding"), "got: {s}");
    }

    #[test]
    fn generates_a_command_type_named_after_the_function() {
        let s = expand(
            quote! { "users:prune" },
            quote! { pub async fn prune_users() -> Result<()> { Ok(()) } },
        );
        assert!(s.contains("pub struct PruneUsersCommand"), "got: {s}");
        assert!(
            s.contains("impl < S > :: arcature :: Command < S > for PruneUsersCommand"),
            "got: {s}"
        );
        assert!(
            s.contains("const NAME : & 'static str = \"users:prune\""),
            "got: {s}"
        );
    }

    #[test]
    fn resolves_each_parameter_from_application_state() {
        let s = expand(
            quote! { "users:prune" },
            quote! {
                pub async fn prune_users(users: UserService, db: Db) -> Result<()> { Ok(()) }
            },
        );
        assert!(
            s.contains("UserService : :: arcature :: Resolve < S >"),
            "got: {s}"
        );
        assert!(s.contains("Db : :: arcature :: Resolve < S >"), "got: {s}");
        assert!(
            s.contains("< UserService as :: arcature :: Resolve < S >> :: resolve (state)"),
            "got: {s}"
        );
        assert!(
            s.contains("prune_users (__arc_dependency_0 , __arc_dependency_1)"),
            "got: {s}"
        );
    }

    #[test]
    fn maps_the_functions_error_onto_command_error() {
        let s = expand(
            quote! { "users:prune" },
            quote! { pub async fn prune_users() -> Result<()> { Ok(()) } },
        );
        assert!(
            s.contains(":: arcature :: CommandError :: Failed"),
            "got: {s}"
        );
    }

    #[test]
    fn emits_the_function_unchanged() {
        let s = expand(
            quote! { "users:prune" },
            quote! { pub async fn prune_users() -> Result<()> { Ok(()) } },
        );
        assert!(
            s.contains("pub async fn prune_users () -> Result < () >"),
            "got: {s}"
        );
    }

    #[test]
    fn rejects_non_fn_item() {
        let err = command(quote! { "test" }, quote! { pub struct Foo {} }).unwrap_err();
        assert_eq!(err.code(), MacroErrorCode::ArcM001);
    }

    #[test]
    fn rejects_empty_name() {
        let err = command(
            quote! { "" },
            quote! { pub async fn handle() -> Result<()> { Ok(()) } },
        )
        .unwrap_err();
        assert_eq!(err.code(), MacroErrorCode::ArcM009);
    }

    #[test]
    fn rejects_missing_name() {
        let err = command(
            quote! {},
            quote! { pub async fn handle() -> Result<()> { Ok(()) } },
        )
        .unwrap_err();
        assert_eq!(err.code(), MacroErrorCode::ArcM009);
    }

    #[test]
    fn rejects_non_string_name() {
        let err = command(
            quote! { 42 },
            quote! { pub async fn handle() -> Result<()> { Ok(()) } },
        )
        .unwrap_err();
        assert_eq!(err.code(), MacroErrorCode::ArcM009);
    }

    #[test]
    fn rejects_bad_signature() {
        let err = command(
            quote! { "test" },
            quote! { pub fn handle() -> Result<()> { Ok(()) } },
        )
        .unwrap_err();
        assert_eq!(err.code(), MacroErrorCode::ArcM011);
    }

    #[test]
    fn rejects_a_generic_function() {
        let err = command(
            quote! { "test" },
            quote! { pub async fn handle<T>(dep: T) -> Result<()> { Ok(()) } },
        )
        .unwrap_err();
        assert_eq!(err.code(), MacroErrorCode::ArcM011);
        assert!(err.to_compile_error().to_string().contains("generic"));
    }

    #[test]
    fn rejects_a_reference_parameter() {
        let err = command(
            quote! { "test" },
            quote! { pub async fn handle(db: &Db) -> Result<()> { Ok(()) } },
        )
        .unwrap_err();
        assert_eq!(err.code(), MacroErrorCode::ArcM011);
        assert!(err.to_compile_error().to_string().contains("owned values"));
    }
}