The Contract: Two Token Streams In, One Replacement Out
A declarative macro pattern-matches anonymous tokens. An attribute macro gets something better: the annotated item handed to it as real tokens, and the job of returning whatever should take its place. The function you export has exactly this shape (the proc-macro crate setup is the next topic's subject — read this one for the contract):
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};
#[proc_macro_attribute]
pub fn time_it(_args: TokenStream, input: TokenStream) -> TokenStream {
let func = parse_macro_input!(input as ItemFn);
let attrs = &func.attrs;
let vis = &func.vis;
let ident = &func.sig.ident;
let inputs = &func.sig.inputs;
let output = &func.sig.output;
let block = &func.block;
quote! {
#(#attrs)*
#vis fn #ident(#inputs) #output {
struct Timer(::std::time::Instant, &'static str);
impl Drop for Timer {
fn drop(&mut self) {
::std::eprintln!("{} took {:?}", self.1, self.0.elapsed());
}
}
let __timer = Timer(::std::time::Instant::now(), stringify!(#ident));
#block
}
}
}
Used as #[time_it] fn load(path: &Path) -> io::Result<Config> { ... }, this rebuilds load with an identical signature, injects a timer, and makes the original body the function's tail expression. The contract in full:
_argsis the token stream between the attribute's parentheses — empty for#[time_it],GET, "/users"for#[route(GET, "/users")]. (The attribute is written without parens only when it takes none.)inputis the entire annotated item: its other attributes (#[inline],#[allow(...)], doc comments), visibility, generics, signature, and body.syn::parseturns those tokens into a typedItemFn— or reports a parse error pointing at the item.- The return value replaces the attribute and the item. The
#[time_it]token is consumed by the invocation; whatever you emit is what the rest of the crate sees. Return the input unchanged and you have written an identity macro — the fastest way to smoke-test a new proc-macro crate.
Two habits separate working attribute macros from subtly broken ones.
Forward the attributes you did not consume. The example re-emits #(#attrs)* because #[inline], #[allow(dead_code)], and /// doc comments arrived inside func.attrs. Drop that line and every other attribute on the function silently disappears at compile time — no error, just mysteriously un-inlined code and undocumented functions. The only attributes you should not re-emit are ones you are deliberately implementing.
Design the body wrap for early returns. The naive timing wrap calls the body inside a closure, (|| #block)(); an early ? then returns from the closure, so the log line after it never runs — and with generics or borrows, the closure fails to compile entirely. The Drop guard above sidesteps both: __timer is dropped when the function returns by any path — tail expression, ?, or return statement — so timing is reported even for failing calls. std::mem::forget is the one way to skip it, which is exactly the trade-off a guard makes explicit.
What attribute macros cannot do matters too: they attach to items — functions, impl blocks, types, modules, statics — not to arbitrary expressions or statements (that needs unstable syntax), and they must live in their own crate (the macro is compiled for the host and loaded by rustc, so it cannot be used in the crate that defines it). Everything here — syn parsing, quote! templates — is unpacked in How Procedural Macros Work, after you have seen the two most-read attribute macros in the ecosystem do their jobs. Preceded by Repetition, Hygiene, and Macro Patterns, where generated tokens could not see the item they belonged to — an attribute macro can see everything.
How #[tokio::main] Turns an async fn Into a Blocking Entry Point
#[tokio::main] is the attribute macro every async Rust programmer has typed, and its expansion explains why this job belongs to code generation and nothing else.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let socket = TcpListener::bind("127.0.0.1:9000").await?;
run(socket).await?;
Ok(())
}
expands, approximately, to:
fn main() -> Result<(), Box<dyn std::error::Error>> {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
let socket = TcpListener::bind("127.0.0.1:9000").await?;
run(socket).await?;
Ok(())
})
}
Read the transformation as three moves: the async keyword is removed from the signature, the original body is wrapped in an async block, and that block is handed to a runtime's block_on. The OS entry point stays a normal synchronous main, because the operating system will never poll a future for you — something has to own the reactor, the worker threads, and the park/unpark cycle, and block_on is that something. A plain function cannot do this job: main must be the item the compiler treats as the entry, and only a macro can rewrite the item while leaving its name in place. This is the same contract as the previous chapter — annotated item in, rewritten item out — applied to the one function in the crate whose shape is fixed by the language.
Attribute arguments then make the one macro serve several designs:
#[tokio::main(flavor = "current_thread")] // single-threaded runtime
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
The macro receives those tokens as its first argument, parses flavor = "..." pairs, and chooses which builder methods to emit. No runtime cost, no configuration file: the choices are made during compilation, and the expansion contains exactly the builder calls you selected. That is the general pattern — attribute arguments select which tokens get generated, and the cost of the branch is paid once, at build time.
The second family worth knowing is instrumentation. tracing::instrument attached to a function parses the same ItemFn and emits, for the synchronous case, roughly:
fn handle(req: Request, user: &User) -> Response {
let __span = tracing::debug_span!("handle", user = %user.id);
let __guard = __span.enter();
/* original body */
}
It can do this because the macro sees the signature: every parameter name becomes a span field automatically, which is exactly what you would otherwise write by hand in the first and last lines of the body. For async functions the generated code wraps the body in an async block instead, so the guard is held across awaits by the generated future rather than by a stack guard that would be a data race. The macro also rejects inputs it cannot handle — applying it somewhere the expansion would be unsound produces an error pointing at the item — because an attribute macro is free to refuse; the item simply does not compile, with your message.
One ordering rule ties the two chapters together: attributes expand outside-in, topmost first. When you write #[route(GET, "/users")] #[instrument] fn get_user(...), the route macro runs first and receives #[instrument] still attached inside the item's attribute list; it must forward it for instrument to run afterwards on the rebuilt function. Reorder the attributes and the instrumentation lands around a different shape of function. That visibility into sibling attributes — and the arguments the attribute carries itself — is the subject of the final chapter: using arguments to configure generated code.
Attribute Arguments as Configuration: Building a #[route] Macro
An attribute's arguments are ordinary tokens, and reading them turns one macro into a whole family. Consider the web-framework shape:
#[route(GET, "/users/:id")]
fn get_user(path: &str) -> String { /* ... */ }
The macro's first parameter receives GET, "/users/:id" as its TokenStream. Parsing it properly — not string-splitting — means teaching syn a tiny grammar with a Parse implementation:
use syn::parse::{Parse, ParseStream};
use syn::{LitStr, Token};
struct RouteArgs {
method: String, // "GET" | "POST" | ...
path: LitStr,
}
impl Parse for RouteArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let method: syn::Ident = input.parse()?;
input.parse::<Token![,]>()?;
let path: LitStr = input.parse()?;
if !input.is_empty() {
return Err(input.error("route! takes exactly METHOD, \"path\""));
}
Ok(RouteArgs { method: method.to_string(), path })
}
}
input.parse::<LitStr>() failing on GET /users reports "expected string literal" against the attribute arguments themselves — the diagnostic quality of a typed parser, at the exact span the caller got wrong. The attribute function then assembles output from the parsed values:
#[proc_macro_attribute]
pub fn route(args: TokenStream, input: TokenStream) -> TokenStream {
let RouteArgs { method, path } = parse_macro_input!(args as RouteArgs);
let func = parse_macro_input!(input as ItemFn);
let vis = &func.vis;
let ident = &func.sig.ident;
let inputs = &func.sig.inputs;
let output = &func.sig.output;
let block = &func.block;
let method_lit = method.as_str();
quote! {
#vis fn #ident(#inputs) #output #block
::inventory::submit! {
Route { method: #method_lit, path: #path_lit, handler: #ident }
}
}
}
Every route definition now registers itself in a global table at program start (the inventory crate collects submit! entries), and #path_lit splices the caller's string literal back in with its span intact. Change the argument grammar — accept #[route(GET, "/x", name = "get_x")] — and the same macro grows a feature without touching a single call site.
Three mechanical rules govern how arguments and sibling attributes behave:
- The attribute's arguments are untyped tokens.
#[time_it]passes an empty stream;#[time_it(some_thing)]passessome_thing— the macro never declared what it accepts, so validation is entirely your job. A robust macro starts with anif !input.is_empty()check or aParseimpl that rejects trailing tokens, as above. - Attributes are not a free namespace. Helper attributes like
#[serde(rename = "...")]exist because#[derive(Serialize)]declaresattributes(serde)— only derive macros may declare and consume attached helper attributes. An attribute macro instead sees every sibling attribute as unprocessed tokens initem.attrsand must forward what it does not implement itself (previous chapter's#(#attrs)*line). Consume without forwarding and the attribute vanishes silently. - Expansion is outside-in. The topmost attribute macro on an item fires first and receives the rest still attached; what it returns is what the next macro in line receives.
#[route(...)] #[instrument]and#[instrument] #[route(...)]are therefore two different programs — one instruments the raw function, the other instruments the version that also registers a route. Document the intended order, or normalize it by always emitting a canonical attribute order from your macro.
Interviewers probe this area lightly but pointedly: "how would you implement your own #[route]?" is a question about span-aware parsing, forwarding discipline, and generation of registration side effects — all three chapters of this topic in one answer. With the contract and the ecosystem examples in hand, the next step is building one for real: the crate layout, syn's typed syntax tree, and quote!'s interpolation are covered in How Procedural Macros Work, which leads directly to writing your own Building a Custom derive.
Quiz
1. An attribute macro returns a TokenStream. What does the compiler do with it?
2. Your macro parses the item as ItemFn and re-emits only `fn #ident(#inputs) #output #block`. The user wrote `#[time_it] #[inline] fn f() {}`. What happens to `#[inline]`?
3. What does the first parameter of `#[proc_macro_attribute]` contain for `#[time_it]` versus `#[route(GET, "/users")]`?
4. You write `#[route(GET, "/x")] #[instrument] fn handler(...)`. Which macro runs first and what does it see?
Flashcards
Question
What are the two parameters of an attribute proc macro, and what does its return value replace?
Click to reveal answer
Answer
`args: TokenStream` — the tokens inside the attribute's parentheses (empty for a bare attribute) — and `item: TokenStream` — the annotated item with its attributes, visibility, generics, signature, and body. The returned tokens replace both the attribute and the item.
Question
Expand `#[tokio::main] async fn main()` by hand.
Click to reveal answer
Answer
A synchronous `fn main()` whose body builds a runtime — `tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap()` — and calls `.block_on(async { /* original body */ })`. The async keyword moves from the function to the wrapped block; the OS entry point stays synchronous because something must own the reactor and park the thread.
Question
Why wrap a body with a Drop guard instead of a closure when instrumenting?
Click to reveal answer
Answer
A closure wrap `(|| #block)()` makes early `?` return from the closure — the trailing log never runs — and breaks on borrows and generics. A `struct Timer(Instant, name)` with a `Drop` impl that prints is dropped on every return path (tail expression, `?`, `return`), so timing is reported even for failing calls.
Question
How do attribute arguments configure generated code, and who validates them?
Click to reveal answer
Answer
The arguments arrive as raw tokens (`flavor = "current_thread"` for tokio, `GET, "/users"` for a route macro); the macro parses them and chooses which tokens to emit, so the cost of the branch is paid once at compile time. Nothing validates them automatically — the macro must parse with `syn` (e.g. a `Parse` impl) and reject bad input with a span-accurate `compile_error!`.
Question
How do attribute macros handle sibling attributes, and how is that different from derive helper attributes?
Click to reveal answer
Answer
An attribute macro receives sibling attributes as ordinary tokens in `item.attrs` and must forward what it does not implement (`#(#attrs)*`) or they are silently lost. Only derive macros may *declare* helper attributes via `proc_macro_derive(Name, attributes(helper))`, which makes the compiler accept `#[helper(...)]` on the item without it being a real attribute.
Revision Notes
Key Takeaways
- 1. An attribute macro receives (args tokens, item tokens) and returns a replacement for both — re-emit the item's attributes or they vanish silently
- 2. `#[tokio::main]` deletes `async` from the signature and wraps the body in `block_on(async { ... })`: the OS entry stays synchronous and only codegen can rewrite the entry item in place
- 3. Instrumentation is signature-aware code generation — tracing turns parameter names into span fields automatically; wrap bodies with a Drop guard so early `?` paths still run the trailing code
- 4. Attribute arguments are untyped tokens that select which code gets emitted at compile time; parse them with a typed `Parse` impl and reject bad input with span-accurate errors
- 5. Attributes expand outside-in — the topmost macro sees the rest attached and must forward them, so attribute order is semantically meaningful
Interview Tips
- • "What does an attribute macro receive and return?" — two TokenStreams (attribute args, annotated item) and a replacement for both; add that items arrive with sibling attributes and docs, which must be forwarded
- • "How does #[tokio::main] work?" — walk the desugaring: remove `async`, wrap the body in an async block, build a runtime from attribute-chosen flavors and block_on it; note the OS entry point must stay synchronous
- • "How would you implement #[route(GET, "/x")]?" — a `Parse` impl over the args TokenStream, the original function re-emitted unchanged, plus a registration side effect (inventory submit or a static table entry); mention trailing-token validation
- • "Why is attribute order important?" — outside-in expansion: the topmost macro runs first and sees the rest in item.attrs; if it does not forward them, they never run; swapping #[route] and #[instrument] changes what is instrumented
Cheat Sheet
Attribute Macros
Contract:
#[proc_macro_attribute]
pub fn my_attr(args: TokenStream, item: TokenStream) -> TokenStream {
let func = parse_macro_input!(item as ItemFn);
let attrs = &func.attrs; // siblings + docs: FORWARD THEM
let vis = &func.vis;
let ident = &func.sig.ident;
let inputs = &func.sig.inputs;
let output = &func.sig.output;
let block = &func.block;
quote! { #(#attrs)* #vis fn #ident(#inputs) #output { /* wrap */ #block } }
}
- Returned tokens replace the attribute AND the item; returning input unchanged = identity macro (good smoke test).
- Attaches to items only (fn, impl, type, mod, static); must live in its own proc-macro crate.
Ecosystem desugarings:
| Attribute | Expands to (roughly) |
|---|---|
#[tokio::main] |
sync fn main -> Builder::new_multi_thread().enable_all().build().unwrap().block_on(async { body }) |
#[tokio::main(flavor = "current_thread")] |
same, builder branch chosen from attribute args |
#[tracing::instrument] |
let __span = debug_span!("fn", fields...); let __guard = __span.enter(); around the body (async: wrapped in an async block) |
Instrumentation patterns: Drop guard (reports on every return path) > closure IIFE (early ? skips post-code; borrow/generic breakage).
Arguments & ordering:
#[route(GET, "/users")] // args tokens: GET , "/users" -> parse with syn::Parse impl
- Validate trailing tokens; errors via
input.error(...)/syn::Error::new_spanned(...).to_compile_error()point at the args. - Expansion is outside-in: topmost attribute fires first, sees the rest in
item.attrs— order changes semantics. - Helper attributes (
#[serde(...)]) are declared only by derives:proc_macro_derive(Serialize, attributes(serde)).