Declarative and Procedural Macros in Depth

Attribute Macro Architecture

An attribute procedural macro—declared with #[proc_macro_attribute]—is invoked as an outer attribute #[my_attr] or #[my_attr(args...)] on any Rust item (fn, struct, enum, impl, trait, or mod). Unlike a derive macro, which only appends code after a type definition, an attribute macro receives two TokenStream arguments and completely replaces the annotated item with whatever TokenStream it returns.

The Two Input Streams of #[proc_macro_attribute]

#[proc_macro_attribute]
pub fn trace_calls(attr: TokenStream, item: TokenStream) -> TokenStream
Invocation on a Function:

  #[trace_calls(level = "debug")]
                ^^^^^^^^^^^^^^^
                1st parameter: `attr: TokenStream` (contents inside parentheses, or empty)

  pub fn compute(x: i32) -> i32 { x * 2 }
  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  2nd parameter: `item: TokenStream` (the entire annotated item, excluding `#[trace_calls]`)

How Attribute Macros Wrap or Transform Functions

Frameworks like #[tokio::main], #[tracing::instrument], and #[actix_web::get("/users")] parse item as a syn::ItemFn, inspect its signature (sig.ident, sig.inputs, sig.output, sig.asyncness, vis, attrs), and wrap its block inside timing, logging, or runtime-initialization code:

// Architecture of `#[proc_macro_attribute] pub fn timed(...)`:
/*
use proc_macro::TokenStream;
use quote::quote;
use syn::{ItemFn, parse_macro_input};

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

    quote! {
        #vis #sig {
            let __start = ::std::time::Instant::now();
            let __result = (|| #body)();
            println!("[timed] {} finished in {:?}", #fn_name, __start.elapsed());
            __result
        }
    }
    .into()
}
*/

// Runnable demonstration of the exact transformation performed by `#[timed]` above:
fn compute_fibonacci(n: u32) -> u64 {
    let __start = std::time::Instant::now();
    // Wrapping the original body in an immediately invoked closure `(|| #body)()`
    // ensures early `return` statements inside `#body` still execute the timing log!
    let __result = (|| {
        if n <= 1 {
            return u64::from(n);
        }
        let (mut a, mut b) = (0_u64, 1_u64);
        for _ in 2..=n {
            (a, b) = (b, a + b);
        }
        b
    })();
    println!(
        "[timed] compute_fibonacci finished (elapsed >= 0ns: {})",
        __start.elapsed().as_nanos() >= 0
    );
    __result
}

fn main() {
    println!("fib(20) = {}", compute_fibonacci(20));
}
[timed] compute_fibonacci finished (elapsed >= 0ns: true)
fib(20) = 6765

Notice the (|| #body)() closure idiom in the generated function body: if the original function body contains an early return expr;, executing #body directly inline would jump out of the function and skip the post-execution logging statement! Wrapping #body inside (|| #body)() (or an async move #body block for async fn) captures early return statements cleanly so post-call instrumentation always runs.

Preserving IDE Completion When an Attribute Macro Fails to Parse

If the user is midway through typing a function annotated with #[my_attr], returning only err.to_compile_error() completely erases the function from the AST, causing rust-analyzer and rustc to report cascading "unresolved function" errors at every call site!

Instead, when an attribute macro encounters a validation error while inspecting an otherwise syntactically valid item, emit both the original #item (or a stub with matching signature) and #compile_error so downstream call sites continue to type-check cleanly.