Rust Attribute Macros: Code Transformation

Rust's macro system operates at three levels: declarative macros (`macro_rules!`), derive macros, and procedural macros. Attribute macros belong to the procedural category, sitting alongside...

Key Insights

  • Attribute macros transform Rust code at compile time by consuming and replacing items, making them ideal for cross-cutting concerns like validation, caching, or code generation
  • The syn and quote crates form the foundation of attribute macro development—syn parses Rust syntax into manipulable ASTs while quote generates new code with proper hygiene
  • Well-designed attribute macros preserve span information and provide clear error messages, making compile-time failures as helpful as runtime errors

Introduction to Attribute Macros

Rust’s macro system operates at three levels: declarative macros (macro_rules!), derive macros, and procedural macros. Attribute macros belong to the procedural category, sitting alongside function-like and derive macros. Unlike derive macros that only work with structs and enums, attribute macros can transform any Rust item—functions, modules, structs, or even entire impl blocks.

The syntax is straightforward: #[your_macro_name] placed above the item you want to transform. What makes attribute macros powerful is their ability to consume the entire item, inspect it, and output completely different code. This is fundamentally different from declarative macros, which perform pattern matching and substitution.

Use attribute macros when you need to add behavior to existing code without modifying its structure directly. Common use cases include adding logging, implementing caching, generating boilerplate, or enforcing compile-time contracts.

Here’s a simple before/after comparison:

// What you write
#[timed]
fn expensive_calculation(n: u64) -> u64 {
    (0..n).sum()
}

// What the compiler sees after macro expansion
fn expensive_calculation(n: u64) -> u64 {
    let start = std::time::Instant::now();
    let result = { (0..n).sum() };
    println!("expensive_calculation took {:?}", start.elapsed());
    result
}

Anatomy of an Attribute Macro

Attribute macros live in separate crates with proc-macro = true in their Cargo.toml. This isolation is necessary because procedural macros run during compilation, requiring a different build pipeline than regular code.

Your Cargo.toml needs:

[lib]
proc-macro = true

[dependencies]
syn = { version = "2.0", features = ["full"] }
quote = "1.0"
proc-macro2 = "1.0"

The minimal skeleton looks like this:

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};

#[proc_macro_attribute]
pub fn timed(_attr: TokenStream, item: TokenStream) -> TokenStream {
    // Parse the input as a function
    let input_fn = parse_macro_input!(item as ItemFn);
    
    // Extract function components
    let fn_name = &input_fn.sig.ident;
    let fn_block = &input_fn.block;
    let fn_sig = &input_fn.sig;
    let fn_vis = &input_fn.vis;
    
    // Generate the transformed function
    let expanded = quote! {
        #fn_vis #fn_sig {
            let start = std::time::Instant::now();
            let result = #fn_block;
            println!("{} took {:?}", stringify!(#fn_name), start.elapsed());
            result
        }
    };
    
    TokenStream::from(expanded)
}

The function signature is crucial: _attr contains tokens from the attribute itself (like arguments), while item contains the item being decorated. Both are TokenStream types that must be parsed into structured data.

Building a Practical Attribute Macro

Let’s build a #[cached] macro that memoizes function results. This demonstrates parsing, state generation, and function wrapping—all common patterns in attribute macros.

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn, ReturnType};
use std::collections::HashMap;

#[proc_macro_attribute]
pub fn cached(_attr: TokenStream, item: TokenStream) -> TokenStream {
    let input_fn = parse_macro_input!(item as ItemFn);
    
    let fn_name = &input_fn.sig.ident;
    let fn_vis = &input_fn.vis;
    let fn_sig = &input_fn.sig;
    let fn_block = &input_fn.block;
    let fn_inputs = &input_fn.sig.inputs;
    
    // Extract parameter names for cache key
    let param_names: Vec<_> = fn_inputs.iter().filter_map(|arg| {
        if let syn::FnArg::Typed(pat_type) = arg {
            if let syn::Pat::Ident(pat_ident) = &*pat_type.pat {
                return Some(&pat_ident.ident);
            }
        }
        None
    }).collect();
    
    // Generate cache key tuple
    let cache_key = quote! { (#(#param_names),*) };
    
    // Get return type
    let return_type = match &fn_sig.output {
        ReturnType::Default => quote! { () },
        ReturnType::Type(_, ty) => quote! { #ty },
    };
    
    let cache_name = syn::Ident::new(
        &format!("{}_CACHE", fn_name.to_string().to_uppercase()),
        fn_name.span()
    );
    
    let expanded = quote! {
        #fn_vis #fn_sig {
            use std::sync::Mutex;
            use std::collections::HashMap;
            
            static #cache_name: Mutex<Option<HashMap<_, #return_type>>> = 
                Mutex::new(None);
            
            let key = #cache_key;
            let mut cache = #cache_name.lock().unwrap();
            
            if cache.is_none() {
                *cache = Some(HashMap::new());
            }
            
            if let Some(cached_value) = cache.as_ref().unwrap().get(&key) {
                return cached_value.clone();
            }
            
            let result = #fn_block;
            cache.as_mut().unwrap().insert(key, result.clone());
            result
        }
    };
    
    TokenStream::from(expanded)
}

Usage is clean:

#[cached]
fn fibonacci(n: u64) -> u64 {
    if n <= 1 { n } else { fibonacci(n - 1) + fibonacci(n - 2) }
}

#[cached]
fn expensive_string_op(s: String, count: usize) -> String {
    s.repeat(count)
}

Working with syn and quote

The syn crate provides a complete Rust parser. Its types mirror Rust’s grammar: ItemFn for functions, ItemStruct for structs, Expr for expressions. The parse_macro_input! macro handles error reporting automatically.

The quote! macro generates code with proper hygiene—variables you reference inside quote! blocks are interpolated, while new variables are scoped correctly.

Here’s a builder pattern generator:

#[proc_macro_attribute]
pub fn builder(_attr: TokenStream, item: TokenStream) -> TokenStream {
    let input = parse_macro_input!(item as ItemStruct);
    let struct_name = &input.ident;
    let builder_name = syn::Ident::new(
        &format!("{}Builder", struct_name), 
        struct_name.span()
    );
    
    let fields = if let syn::Fields::Named(ref fields) = input.fields {
        &fields.named
    } else {
        panic!("Builder only works with named fields");
    };
    
    let builder_fields = fields.iter().map(|f| {
        let name = &f.ident;
        let ty = &f.ty;
        quote! { #name: Option<#ty> }
    });
    
    let builder_methods = fields.iter().map(|f| {
        let name = &f.ident;
        let ty = &f.ty;
        quote! {
            pub fn #name(mut self, #name: #ty) -> Self {
                self.#name = Some(#name);
                self
            }
        }
    });
    
    let build_fields = fields.iter().map(|f| {
        let name = &f.ident;
        quote! {
            #name: self.#name.ok_or(concat!("Field ", stringify!(#name), " not set"))?
        }
    });
    
    let expanded = quote! {
        #input
        
        pub struct #builder_name {
            #(#builder_fields,)*
        }
        
        impl #builder_name {
            #(#builder_methods)*
            
            pub fn build(self) -> Result<#struct_name, &'static str> {
                Ok(#struct_name {
                    #(#build_fields,)*
                })
            }
        }
        
        impl #struct_name {
            pub fn builder() -> #builder_name {
                #builder_name {
                    #(#fields: None,)*
                }
            }
        }
    };
    
    TokenStream::from(expanded)
}

Advanced Patterns and Attributes with Arguments

Attributes can accept arguments: #[route(GET, "/users")]. Parse these using custom syntax or syn’s built-in parsers.

use syn::{parse::Parse, Token, LitStr};

struct ValidateArgs {
    min: Option<usize>,
    max: Option<usize>,
}

impl Parse for ValidateArgs {
    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
        let mut min = None;
        let mut max = None;
        
        while !input.is_empty() {
            let ident: syn::Ident = input.parse()?;
            input.parse::<Token![=]>()?;
            
            match ident.to_string().as_str() {
                "min" => min = Some(input.parse::<syn::LitInt>()?.base10_parse()?),
                "max" => max = Some(input.parse::<syn::LitInt>()?.base10_parse()?),
                _ => return Err(syn::Error::new(ident.span(), "Unknown argument")),
            }
            
            if input.peek(Token![,]) {
                input.parse::<Token![,]>()?;
            }
        }
        
        Ok(ValidateArgs { min, max })
    }
}

#[proc_macro_attribute]
pub fn validate(attr: TokenStream, item: TokenStream) -> TokenStream {
    let args = parse_macro_input!(attr as ValidateArgs);
    let input_fn = parse_macro_input!(item as ItemFn);
    
    // Generate validation code based on args.min and args.max
    // ...
}

Testing and Debugging

Use cargo-expand to see macro output:

cargo install cargo-expand
cargo expand --lib my_module

For systematic testing, trybuild tests compile-time failures:

#[test]
fn test_macro_expansion() {
    let t = trybuild::TestCases::new();
    t.pass("tests/pass/*.rs");
    t.compile_fail("tests/fail/*.rs");
}

Create test files:

// tests/pass/basic.rs
use my_macro::cached;

#[cached]
fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn main() {}

Best Practices and Common Pitfalls

Preserve spans for accurate error messages:

// Bad: generic error
return syn::Error::new(
    proc_macro2::Span::call_site(),
    "Invalid input"
).to_compile_error().into();

// Good: specific location
return syn::Error::new(
    input_fn.sig.ident.span(),
    "Function must return a value for caching"
).to_compile_error().into();

Handle errors gracefully:

let fields = match input.fields {
    syn::Fields::Named(ref fields) => &fields.named,
    _ => {
        return syn::Error::new_spanned(
            &input,
            "Builder requires named fields (struct with { })"
        ).to_compile_error().into();
    }
};

Avoid overuse: Attribute macros add compile-time complexity. Don’t use them when traits, generic functions, or simple declarative macros suffice. Reserve them for cases where you genuinely need code transformation or cross-cutting concerns.

Document generated code: Users can’t see what your macro produces. Provide clear documentation and consider using cargo-expand examples in your README.

Attribute macros are powerful tools for eliminating boilerplate and enforcing patterns at compile time. Master syn and quote, design clear error messages, and you’ll write macros that feel like native language features.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.