Skip to main content

Attribute Macros: Annotate Functions & Structs

Attribute macros annotate items (functions, structs, enum variants, methods) with #[attr] and transform their definition. Unlike derive macros which implement traits, attribute macros wrap, modify, or replace the item entirely. They are the Rust equivalent of Python decorators or Java annotations. Common examples include #[tokio::main] (transforms an async fn into a runtime initializer), #[test] (marks functions as test entry points), and custom attributes like #[profile] (measures execution time) or #[log] (adds logging). Attribute macros require a separate proc-macro crate and use syn and quote! to parse and generate code.

Setup: Defining an Attribute Macro

Attribute macros are registered with #[proc_macro_attribute] and receive two token streams: the attribute's arguments and the item being annotated.

// my_attr/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};

#[proc_macro_attribute]
pub fn my_attr(_attr: TokenStream, item: TokenStream) -> TokenStream {
// Parse the item (could be a function, struct, etc.)
let item = parse_macro_input!(item as ItemFn);

let name = &item.sig.ident;
let body = &item.block;
let sig = &item.sig;

// Transform the function: wrap it with logging
let expanded = quote! {
#sig {
println!("Entering function: {}", stringify!(#name));
#body
}
};

TokenStream::from(expanded)
}

In your main crate, use the attribute:

use my_attr::my_attr;

#[my_attr]
fn hello(name: &str) {
println!("Hello, {}", name);
}

fn main() {
hello("Alice");
// Output:
// Entering function: hello
// Hello, Alice
}

Parsing Function Signatures

Attribute macros frequently target functions. The syn::ItemFn type gives you access to the signature (sig), body (block), visibility (vis), and attributes (attrs).

Extracting Function Metadata

use syn::{ItemFn, FnArg, Pat, Type};

let item = parse_macro_input!(item as ItemFn);

let func_name = &item.sig.ident;
let func_vis = &item.sig.vis;
let func_return_type = &item.sig.output;

// Extract function parameters
for arg in &item.sig.inputs {
if let FnArg::Typed(pat_type) = arg {
if let Pat::Ident(pat_ident) = &*pat_type.pat {
let param_name = &pat_ident.ident;
let param_type = &pat_type.ty;
println!("Parameter: {} : {}", param_name, quote!(#param_type));
}
}
}

// Check if async
if item.sig.asyncness.is_some() {
println!("Function is async");
}

Building a Timing Attribute

Here is a practical example that measures function execution time:

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

#[proc_macro_attribute]
pub fn profile(_attr: TokenStream, item: TokenStream) -> TokenStream {
let func = parse_macro_input!(item as ItemFn);

let name = &func.sig.ident;
let vis = &func.vis;
let sig = &func.sig;
let body = &func.block;
let is_async = func.sig.asyncness.is_some();

let profiled_body = if is_async {
quote! {
let start = std::time::Instant::now();
let result = async {
#body
}.await;
let elapsed = start.elapsed();
eprintln!("[PROFILE] {}: {:.3}ms", stringify!(#name), elapsed.as_secs_f64() * 1000.0);
result
}
} else {
quote! {
let start = std::time::Instant::now();
#body
let elapsed = start.elapsed();
eprintln!("[PROFILE] {}: {:.3}ms", stringify!(#name), elapsed.as_secs_f64() * 1000.0);
}
};

let expanded = quote! {
#vis #sig {
#profiled_body
}
};

TokenStream::from(expanded)
}

Usage:

#[profile]
fn slow_function() {
std::thread::sleep(std::time::Duration::from_millis(100));
println!("Done");
}

#[profile]
async fn async_slow_function() {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
println!("Async done");
}

// Running slow_function prints:
// Done
// [PROFILE] slow_function: 100.234ms

Attribute Macros on Structs and Enums

Attribute macros are not limited to functions. You can apply them to structs, enums, and other items. The pattern is similar: parse the item, inspect it, and generate a transformed version.

Struct Transformation Example

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemStruct, Data, Fields};

#[proc_macro_attribute]
pub fn json_schema(_attr: TokenStream, item: TokenStream) -> TokenStream {
let item_struct = parse_macro_input!(item as ItemStruct);
let name = &item_struct.ident;

// Generate JSON schema from struct fields
let fields_schema = match &item_struct.fields {
Fields::Named(fields) => {
let field_names: Vec<_> = fields.named.iter()
.map(|f| f.ident.as_ref().unwrap().to_string())
.collect();

quote! {
let mut schema = serde_json::json!({
"type": "object",
"properties": {}
});

#(
if let Some(props) = schema.get_mut("properties") {
props[#field_names] = serde_json::json!({"type": "string"});
}
)*

schema
}
}
_ => quote! { serde_json::json!({}) },
};

let expanded = quote! {
#item_struct

impl #name {
pub fn json_schema() -> serde_json::Value {
#fields_schema
}
}
};

TokenStream::from(expanded)
}

Parsing Attribute Arguments

Attribute macros receive two arguments: the attribute's arguments (the stuff inside the parentheses) and the item. You parse both separately.

Simple Arguments

use syn::LitStr;

#[proc_macro_attribute]
pub fn my_attr(attr: TokenStream, item: TokenStream) -> TokenStream {
// Parse attribute arguments
let attr_args = parse_macro_input!(attr as syn::LitStr);
let message = attr_args.value();

let item = parse_macro_input!(item as ItemFn);
let name = &item.sig.ident;
let body = &item.block;

let expanded = quote! {
fn #name() {
println!("Message: {}", #message);
#body
}
};

TokenStream::from(expanded)
}

Usage:

#[my_attr("Custom message")]
fn my_function() {
println!("Function body");
}

Complex Arguments with darling

For more complex attribute syntax, use the darling crate to parse structured arguments:

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

#[derive(FromMeta)]
struct AttrsOptions {
#[darling(default)]
log: bool,

#[darling(default)]
timeout_ms: Option<u64>,
}

#[proc_macro_attribute]
pub fn configurable(attr: TokenStream, item: TokenStream) -> TokenStream {
let attr_args = parse_macro_input!(attr as AttributeArgs);
let opts = AttrsOptions::from_list(&attr_args).unwrap_or_default();

let item = parse_macro_input!(item as ItemFn);
let name = &item.sig.ident;
let body = &item.block;

let logging = if opts.log {
quote! { println!("Running {}", stringify!(#name)); }
} else {
quote! {}
};

let expanded = quote! {
fn #name() {
#logging
#body
}
};

TokenStream::from(expanded)
}

Usage:

#[configurable(log = true, timeout_ms = 5000)]
fn my_function() {
println!("Executing");
}

Real-World Example: tokio::main

The #[tokio::main] attribute transforms an async fn into a main function that initializes the Tokio runtime. Simplified:

#[proc_macro_attribute]
pub fn tokio_main(_attr: TokenStream, item: TokenStream) -> TokenStream {
let func = parse_macro_input!(item as ItemFn);

// Ensure the function is async
if func.sig.asyncness.is_none() {
return syn::Error::new_spanned(&func.sig, "tokio::main requires an async function")
.to_compile_error()
.into();
}

let name = &func.sig.ident;
let body = &func.block;

let expanded = quote! {
fn main() {
let runtime = tokio::runtime::Runtime::new().expect("Failed to create runtime");
runtime.block_on(async {
#body
})
}
};

TokenStream::from(expanded)
}

Usage:

#[tokio::main]
async fn main() {
println!("Hello from async main");
}

Key Takeaways

  • Attribute macros are registered with #[proc_macro_attribute] and transform annotated items
  • Parse the item using syn type like ItemFn, ItemStruct, or ItemEnum
  • Attribute macros receive both the attribute arguments and the annotated item as token streams
  • Use darling for ergonomic parsing of complex attribute arguments
  • Attribute macros can wrap functions, add methods to structs, modify signatures, and generate wrapper code
  • They are the foundation of runtime features like #[tokio::main], #[test], and custom decorators

Frequently Asked Questions

Can an attribute macro modify the item in place or must it replace it?

An attribute macro receives a token stream and returns a new token stream. You can include the original item in the output (effectively "wrapping" it) or replace it entirely. Typically, you preserve the original and add new code.

What is the difference between an attribute macro and a derive macro?

A derive macro (used with #[derive(Trait)]) auto-implements a trait based on type structure. An attribute macro (used as #[attr]) transforms or wraps an item. Derive macros are simpler; attribute macros are more flexible and powerful.

Can I apply multiple attribute macros to the same item?

Yes. Macros are applied from the innermost outward, so #[outer] #[inner] fn foo() {} applies inner first, then outer.

How do I return an error from an attribute macro?

Use syn::Error::new_spanned() to create an error at the relevant token, then call .to_compile_error() and return it wrapped in TokenStream::from().

Can an attribute macro be applied to method definitions inside impl blocks?

Yes, but parsing is more complex. You would need to parse an ImplItem rather than an ItemFn to access the method within its impl context.

Further Reading