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
synandquotecrates form the foundation of attribute macro development—synparses Rust syntax into manipulable ASTs whilequotegenerates 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.