Welcome
Welcome to our Rust procedural macros course!
You’ll learn how to extend the Rust compiler by writing your own procedural macros.
Procedural macros allow you to run code at compile time that operates over Rust syntax, both consuming and producing Rust syntax. You can sort of think of procedural macros as functions from an AST to another AST.
I’ll take you on a journey through the Rust macro system, exploring derive macros, attribute macros,
and function-like macros, learning how to parse and generate Rust code using the syn and quote crates.
Methodology
This course is based on the “learn by doing” principle.
It has been designed to be interactive and hands-on.
Mainmatter developed this course
to be delivered in a classroom setting, in a single day: each attendee advances
through the lessons at their own pace, with an experienced instructor providing
guidance, answering questions and diving deeper into the topics as needed.
If you’re interested in attending one of our training sessions, or if you’d like to
bring this course to your company, please get in touch.
You can also follow the course on your own, but we recommend you find a friend or
a mentor to help you along the way should you get stuck. You can
also find solutions to all exercises in the
solutions branch of the GitHub repository.
Structure
On the left side of the screen, you can see that the course is divided into sections.
Each section introduces a new concept or feature.
To verify your understanding, each section is paired with an exercise that you need to solve.
You can find the exercises in the
companion GitHub repository.
Before starting the course, make sure to clone the repository to your local machine:
# If you have an SSH key set up with GitHub
git clone git@github.com:mainmatter/rust-proc-macro-workshop.git
# Otherwise, use the HTTPS URL:
#
# git clone https://github.com/mainmatter/rust-proc-macro-workshop.git
We also recommend you work on a branch, so you can easily track your progress and pull in updates from the main repository, if needed:
cd rust-proc-macro-workshop
git checkout -b my-solutions
All exercises are located in the exercises folder.
Each exercise is a Rust package, and most are split into two crates: a thin consumer crate and a
procedural-macro sub-crate (named after the exercise, e.g. struct-fields-exercise-macros) that
holds the macro you’ll be working on.
The instructions on what to do, along with a test suite to automatically verify your solution, live
next to the code you need to edit: usually in the macro sub-crate’s src/lib.rs, or in src/lib.rs
for the simpler single-crate exercises.
wr, the workshop runner
To verify your solutions, we’ve also provided a tool to guide you through the course: the wr CLI, short for “workshop runner”.
Install wr by following the instructions on its website.
Once you have wr installed, open a new terminal and navigate to the top-level folder of the repository.
Run the wr command to start the course:
wr
wr will verify the solution to the current exercise.
Don’t move on to the next section until you’ve solved the exercise for the current one.
You can also check a specific exercise without advancing through the course: navigate into its
folder and run wr check.
cd exercises/01_intro/00_welcome/
wr check
We recommend committing your solutions to Git as you progress through the course, so you can easily track your progress and “restart” from a known point if needed.
A note on AI assistants
We strongly recommend that you disable AI coding assistants (agents, copilots, autocomplete suggestions)
while working through the exercises.
The goal of this course is to learn by doing: struggling with the code, reading the documentation, and
building your own mental model of how procedural macros work. Letting an AI fill in the blanks
for you would short-circuit the learning process.
Enjoy the course!
Author
Authored by Guillaume Desmottes for Mainmatter.
Guillaume is part of the Mainmatter Rust team. He maintains various crates, such as
system-deps, and is the creator of
Karapulse, a Rust karaoke application.
License
Copyright © 2024- Mainmatter GmbH (https://mainmatter.com), released under the PolyForm-Noncommercial-1.0.0.
Exercise
The exercise for this section is located in 01_intro/00_welcome
Introduction
Rust has three kinds of procedural macros. You’ve almost certainly used all three, even if you didn’t realize it at the time.
Procedural macros allow you to run code at compile time that operates over Rust syntax, both consuming and producing Rust syntax.
We met that definition in the Welcome section. If you skimmed the setup, it’s worth keeping the Rust Reference’s chapter on procedural macros open as the authoritative reference while you work through the course.
A word on “tokens”
We’ll use the word token a lot, so let’s pin it down. A token is the smallest meaningful
piece of Rust source code: an identifier (Point), a keyword (fn), a literal (42), a
piece of punctuation (+, ,, ::), or a delimited group ({ ... }, ( ... ), [ ... ]).
Before the compiler can make sense of your code, it first splits the raw source text into a
stream of these tokens.
Procedural macros operate on exactly that stream — they receive tokens as input and return tokens as output. For now, “the words and symbols your code is made of” is a good enough mental model; we’ll make it precise in Token and TokenStream.
Derive macros
Derive macros generate additional code for a struct or enum. They don’t modify the original item — they extend it.
#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
struct Point {
x: f64,
y: f64,
}
}
Debug and Clone are derive macros. They look at the fields of Point and generate
implementations of the Debug and Clone traits, respectively. The Clone derive, for
instance, expands to roughly:
#![allow(unused)]
fn main() {
// the original struct is left untouched, and this is added alongside it:
impl Clone for Point {
fn clone(&self) -> Self {
Point {
x: self.x.clone(),
y: self.y.clone(),
}
}
}
}
DebugandCloneare actually built into the compiler, not implemented as procedural macros — but they use the same#[derive(...)]syntax. Third-party derive macros likeserde::Serializeorthiserror::Errorare procedural macros, and they work exactly the same way from a user’s perspective.
Derive macros are the most common kind of procedural macro. They are also the simplest to write, which is why we’ll start with them in this workshop.
Function-like macros
Function-like macros look like function calls, but they operate on tokens rather than values.
The ! suffix is a syntactic marker that distinguishes macro invocations from regular
function calls.
#![allow(unused)]
fn main() {
let query = sqlx::query!("SELECT * FROM users WHERE id = $1", user_id);
}
sqlx::query! is a procedural
function-like macro. It parses a SQL string at compile time, verifies it against your database
schema, and generates type-safe Rust code. The macro call is replaced in place by the code it
generates — conceptually:
#![allow(unused)]
fn main() {
// expands to roughly (simplified):
{
// a struct generated to hold the typed result of *this* query,
// with one field per selected column:
struct Record { id: i64, name: String /* ... */ }
sqlx::query_with::<_, _>("SELECT * FROM users WHERE id = $1", /* bound: */ (user_id,))
// ...adapted to yield `Record` values
}
}
The real expansion is larger and not meant to be read by humans, but the shape is the point: a function-like macro substitutes its call site with freshly generated tokens.
You may be more familiar with
vec!orprintln!— those are also function-like macros, but they are declarative macros (macro_rules!), not procedural ones. We’ll cover the difference in a later section.
Attribute macros
Attribute macros are attached to an item (a function, a struct, a module, etc.) and can transform it — replacing, wrapping, or augmenting the original code. They can also emit additional items alongside the original.
#[tokio::main]
async fn main() {
// ...
}
#[tracing::instrument]
fn process(input: &str) -> Result<(), Error> {
// ...
}
#[tokio::main] and
#[tracing::instrument] are
attribute macros. They receive the annotated item as input and produce a modified version of it
as output. #[tokio::main], for example, turns your async fn main into a synchronous one that
starts a runtime and drives the original body to completion:
// expands to roughly:
fn main() {
tokio::runtime::Runtime::new()
.unwrap()
.block_on(async {
// ...the original body of your `async fn main` goes here...
})
}
Notice that, unlike a derive macro, the original async fn main is not preserved — it has
been replaced.
What they have in common
All three types share the same fundamental mechanism: they are Rust functions that receive tokens as input and produce tokens as output. They run at compile time, and the tokens they produce are spliced into the program as if you had written them by hand.
The difference lies in how they are invoked and what they receive as input.
By the end of this workshop, you’ll have built all three kinds from scratch — starting with derive macros, then moving on to function-like macros and attribute macros.
Exercise
The exercise for this section is located in 02_introduction/00_overview
Exploring macros with cargo expand
Macros generate code, but that code is invisible by default — the compiler sees it, you don’t. This can make macros feel like black boxes.
cargo expand is a tool that shows you the code
a macro generates. It’s invaluable for understanding what existing macros do, and later for
debugging the macros you write yourself.
Installation
The fastest way is via cargo-binstall, which
downloads a prebuilt binary instead of compiling from source:
# Install cargo-binstall first if you don't have it already.
cargo install cargo-binstall
# Then use it to fetch a prebuilt cargo-expand.
cargo binstall cargo-expand
Alternatively, compile cargo-expand from source directly. This is slower, but needs no extra
tooling:
cargo install cargo-expand
Editor integration
You don’t strictly need the command-line tool: most editors backed by
rust-analyzer can expand macros for you. In
Zed, place your cursor over a macro invocation and run editor: expand macro recursively from the command palette. VS Code (with the rust-analyzer extension) offers the same
under rust-analyzer: Expand macro recursively, and JetBrains IDEs have an equivalent
Expand macro action. These are handy for a quick look, while cargo expand is better when you
want the whole crate or to pipe the output elsewhere.
Usage
Given this code:
#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Point {
x: f64,
y: f64,
}
}
Running cargo expand will show the full Debug implementation that the derive macro generates:
#![allow(unused)]
fn main() {
struct Point {
x: f64,
y: f64,
}
impl ::core::fmt::Debug for Point {
fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
::core::fmt::Formatter::debug_struct_field2_finish(
f, "Point", "x", &self.x, "y", &&self.y,
)
}
}
}
The exact output may vary between Rust versions — the compiler is free to change the internal helpers it uses. The important points remain the same, though:
- The original struct is preserved unchanged.
- All paths are fully qualified (
::core::fmt::Debuginstead ofDebug). This is a deliberate choice: generated code must not break if the user has a different item namedDebugin scope. - The generated code is valid Rust — you could have written it by hand.
Tips
You can expand a specific item instead of the entire crate by passing its name:
cargo expand --package cargo-expand-exercise --lib -- Rgb
This is useful when a crate contains many items and you only care about one.
Exercise
The exercise for this section is located in 02_introduction/01_cargo_expand
Run cargo expand --package cargo-expand-exercise --lib to see what the Clone derive generates
for a simple struct. Then write the same implementation by hand for a second struct.
thiserror: a derive macro in the wild
Let’s look at a real-world derive macro: thiserror.
thiserror provides a #[derive(Error)] macro that generates implementations of
std::fmt::Display and std::error::Error for your error types. Without it, you’d have to
write those implementations by hand — repetitive boilerplate.
Before thiserror
#![allow(unused)]
fn main() {
use std::fmt;
#[derive(Debug)]
enum ApiError {
NotFound,
Unauthorized(String),
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ApiError::NotFound => write!(f, "resource not found"),
ApiError::Unauthorized(msg) => write!(f, "unauthorized: {msg}"),
}
}
}
impl std::error::Error for ApiError {}
}
After thiserror
#![allow(unused)]
fn main() {
use thiserror::Error;
#[derive(Debug, Error)]
enum ApiError {
#[error("resource not found")]
NotFound,
#[error("unauthorized: {0}")]
Unauthorized(String),
}
}
The macro reads the #[error("...")] attributes on each variant and generates the Display
and Error implementations for you.
Attributes
Notice how thiserror uses helper attributes to customize the generated code:
#[error("...")]controls theDisplaymessage for each variant.#[from]can be added to a variant field to automatically generate aFromimplementation. It also implies#[source], so you don’t need both on the same field.#[source]marks a field as the error source for theError::source()method.
These are derive macro helper attributes — attributes that only have meaning in the context of the derive macro they belong to. They are not standalone attribute macros.
This pattern — a derive macro combined with helper attributes — is extremely common in the Rust
ecosystem. serde, clap, sqlx, and many other crates use it.
Later in this workshop, you’ll learn how to define your own derive macros with helper attributes just like these.
Under the hood
Since we just learned about cargo expand, let’s see what thiserror generates.
Given the ApiError enum from the “After” example above, running cargo expand would show
(among other things) a Display implementation like this:
#![allow(unused)]
fn main() {
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
ApiError::NotFound => f.write_str("resource not found"),
ApiError::Unauthorized(v) => {
write!(f, "unauthorized: {}", v)
}
}
}
}
impl std::error::Error for ApiError {}
}
The same code you would have written by hand.
Exercise
The exercise for this section is located in 02_introduction/02_thiserror
Use thiserror to define an error type with multiple variants. Pay attention to the attributes.
Macros vs functions
Why do macros exist when Rust already has functions? Functions are simpler, easier to understand, and don’t require any special syntax. Why would you reach for a macro instead?
What functions can’t do
Functions operate on values at runtime. Macros operate on tokens at compile time. This gives macros capabilities that functions fundamentally cannot have.
Variable number of arguments
#![allow(unused)]
fn main() {
// This works — println! accepts any number of arguments
println!("{} + {} = {}", a, b, a + b);
}
Rust functions have a fixed number of parameters. There’s no variadic function syntax.
println! is a macro precisely because it needs to accept an arbitrary number of arguments
and type-check each one against the format string — all at compile time.
Code generation
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
struct Config {
host: String,
port: u16,
}
}
A function can’t look at a struct definition and generate trait implementations for it. That requires inspecting the structure of the code itself — its fields, their types, their attributes — and producing new code based on what it finds.1
New syntax
#![allow(unused)]
fn main() {
let query = sqlx::query!("SELECT * FROM users WHERE id = $1", user_id);
}
sqlx::query! parses a SQL string at compile time, verifies it against your database schema,
and generates type-safe Rust code. No function can invent new syntax or perform compile-time
verification of a string literal.
When functions are enough
If you can do it with a function, do it with a function. Macros are harder to write, harder to debug, and harder for users to understand when something goes wrong.
Use a macro only when you need something a function cannot provide: code generation, compile-time inspection of types, variadic arguments, or new syntax.
Exercise
The exercise for this section is located in 02_introduction/03_macros_vs_functions
Use standard library macros (format!, env!, include_str!, concat!) to accomplish tasks
that would be impossible with regular functions: formatting with a variable number of arguments,
reading environment variables at compile time, embedding file contents, and concatenating
string literals.
-
This boundary may soften in the future. The Rust project is working on compile-time reflection, a
const fn-based system that would let ordinary functions inspect a type’s fields at compile time — taking over part of what derive macros do today, though derives are expected to stay around for performance-critical cases. ↩
Declarative vs procedural macros
Rust has two macro systems: declarative macros (also called “macros by example”) and procedural macros. Both generate code at compile time, but they work very differently.
Declarative macros (macro_rules!)
Declarative macros use pattern matching on token trees. You define patterns and their corresponding expansions:
#![allow(unused)]
fn main() {
macro_rules! max {
($a:expr, $b:expr) => {
if $a > $b { $a } else { $b }
};
}
let m = max!(3, 7); // expands to: if 3 > 7 { 3 } else { 7 }
}
Declarative macros are:
- Defined inline in your crate, no separate crate needed.
- Limited to pattern matching on tokens — you can’t run Rust code during expansion.
- Good for simple syntactic transformations and reducing repetition.
Procedural macros
Procedural macros are Rust functions that receive a token stream and return a token stream. They can run arbitrary Rust code to decide what to generate:
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
#[proc_macro_derive(MyDerive)]
pub fn my_derive(input: TokenStream) -> TokenStream {
// Parse the input, inspect it, generate code
// — full power of Rust available here
todo!()
}
}
Procedural macros are:
- Defined in a dedicated crate with
proc-macro = truein itsCargo.toml. - Able to run arbitrary Rust code: parse syntax trees, read attributes, use external libraries.
- Required for derive macros, attribute macros, and complex function-like macros.
When to use which
| Declarative | Procedural | |
|---|---|---|
| Simple token substitution | Yes | Overkill |
| Variadic arguments | Yes | Yes |
| Inspecting struct fields | No | Yes |
| Custom attributes | No | Yes |
| Derive implementations | No | Yes |
| Needs a separate crate | No | Yes |
A good rule of thumb: start with macro_rules!. If you hit its limits — you need to parse
Rust syntax, inspect types, or use attributes — switch to a procedural macro.1
Exercise
The exercise for this section is located in 02_introduction/04_declarative_vs_procedural
Write a hashmap! macro using macro_rules! that creates a HashMap from key-value pairs.
-
Some of these limits are being lifted. The long-running “declarative macros 2.0” effort modernizes
macro_rules!, and on top of it RFC 3698, “Declarativemacro_rules!derive macros” lets you write aderiveinmacro_rules!— inspecting a type’s fields and generating animplwithout a separate proc-macro crate. The aim is to cover the simple derive cases declaratively; the heavy cases will still want procedural macros. ↩
When to use procedural macros
Procedural macros are powerful, but they come with costs. Before reaching for one, it’s worth asking: is a proc macro the right tool here?
Good reasons to use a proc macro
Eliminating boilerplate
The strongest use case. If you find yourself writing the same trait implementation for dozens of types, with only the field names changing, a derive macro can generate all of it from the struct definition alone.
Examples: serde::Serialize, thiserror::Error, clap::Parser.
Enforcing patterns at compile time
A proc macro can reject invalid usage with a clear error message before the program ever runs.
For example, a #[derive(Builder)] macro can check that all required fields are set, or a
routing macro can verify that path parameters match function arguments.
Providing ergonomic APIs
When the “natural” way to express something in Rust is verbose or awkward, a macro can provide
a cleaner interface. sqlx::query! lets you write SQL directly and get type-checked results.
tokio::main lets you write async fn main() without the runtime setup boilerplate.
Bad reasons to use a proc macro
“It’s clever”
If a function, a trait, a generic, or a macro_rules! macro can do the job, prefer those.
They are easier to write, easier to maintain, and easier for users to understand.
Hiding complexity
Macros that do too much behind the scenes become a maintenance burden. Users can’t step through generated code in a debugger. Error messages point to the macro invocation, not the generated code. IDE support is limited.
Small scale
If you only have two or three types that need the same implementation, writing it by hand is fine. The setup cost of a proc macro crate (separate crate, parsing, code generation) isn’t worth it for a handful of types.
The cost of proc macros
- Compile time: a proc macro must be compiled as a standalone binary that the compiler runs during your build, which adds linking and code-generation overhead you don’t pay for a “normal” library dependency.
- Debuggability: generated code is harder to debug than hand-written code.
- Error messages: getting good error messages from macros takes significant effort.
- Maintenance: macro code is harder to read and maintain than regular Rust code.
Exercise
The exercise for this section is located in 02_introduction/05_when_to_use
Given a set of scenarios, identify which ones would benefit from a procedural macro and which ones are better solved with other tools.
The proc-macro toolkit
It’s time to get your hands dirty. In this chapter, you’ll build your first procedural macros from scratch — starting with the bare minimum and gradually introducing the tools that make macro development practical.
You’ll learn:
- How proc-macro crates are structured and wired up
- What a derive macro actually returns
- How tokens and token streams work under the hood
- How
proc-macro2makes your macro code testable - How
synparses Rust code into a structured representation - How
quotelets you generate code that looks like regular Rust - The re-export pattern used by
serde,thiserror, and most real-world macro crates
By the end of this chapter, you’ll be comfortable with the full derive-macro workflow: parse
the input with syn, generate code with quote, and return it as a token stream.
Exercise
The exercise for this section is located in 03_basics/00_overview
A quick warm-up before diving in: use derive_more’s Display
derive to implement Display for an enum. derive_more brings derive macros to many standard
library traits — Display, From, Add, Deref, and more — saving you the hand-written
boilerplate. It’s a good reminder of what derive macros look like from the user’s perspective —
you’ll be building your own from the next section onwards.
Crate structure
A procedural macro must live in its own crate, separate from the code that uses it.
Why a separate crate?
Procedural macros run at compile time. The compiler doesn’t just read your macro — it runs it. To make that possible, a procedural macro must be compiled into an executable that the compiler can invoke while compiling any crate that uses the macro. That executable has to exist before those crates are built, so the macro needs its own compilation unit — a separate, distinct crate.
Creating a proc-macro crate
A proc-macro crate looks like a regular library crate with one key difference in Cargo.toml:
[package]
name = "my-macros"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true
The proc-macro = true flag tells the compiler that this crate exports procedural macros.
Without it, attempting to use #[proc_macro_derive] or any other proc-macro attribute will
produce a compilation error.
The proc_macro crate
When you set proc-macro = true, your crate automatically gets access to the
proc_macro crate from the standard library.
You don’t need to add it to [dependencies] — it’s provided by the compiler.
This crate gives you the core type you’ll work with:
TokenStream. Every procedural
macro is a function that takes a TokenStream as input and returns a TokenStream as output.
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
#[proc_macro_derive(MyMacro)]
pub fn my_macro(_input: TokenStream) -> TokenStream {
// Return nothing — the derive macro adds no extra code for now.
TokenStream::new()
}
}
Wiring it up
To use your proc-macro crate from another crate, add it as a dependency:
[dependencies]
my-macros = { path = "../my-macros" }
Then you can use the derive macro:
#![allow(unused)]
fn main() {
use my_macros::MyMacro;
#[derive(MyMacro)]
struct Foo;
}
Workspace layout
In a workspace, proc-macro crates are just regular members. This workshop uses a workspace with exercises as members, and some exercises will include their own proc-macro crate as a path dependency.
Exercise
The exercise for this section is located in 03_basics/01_crate_structure
Create a proc-macro crate from scratch and wire it up. You’ll need to create the Cargo.toml
(with proc-macro = true), write a derive macro that returns an empty token stream, and add
it as a dependency so the provided code compiles.
What a proc macro returns
A derive macro receives the annotated item as a TokenStream and returns a TokenStream.
But what exactly should that returned stream contain?
Derive macros add code
Unlike attribute macros, a derive macro does not replace the original item. The struct or
enum you annotate with #[derive(...)] is preserved as-is. The token stream you return is
appended after the original item.
This means:
- If you return an empty token stream, the item is unchanged. This is valid — the macro simply has no effect.
- If you return an
implblock, that implementation is added alongside the original item. - If you return malformed tokens, compilation fails.
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
#[proc_macro_derive(NoOp)]
pub fn no_op(_input: TokenStream) -> TokenStream {
// Return nothing — the item is preserved, no code is added.
TokenStream::new()
}
}
The returned tokens must be valid Rust
The compiler will try to parse and compile whatever tokens you return. If they aren’t valid
Rust, you’ll get a compilation error — but the error will point at the #[derive(...)]
invocation, not at the generated code. This can be confusing.
cargo expand is your best friend here. When the
generated code doesn’t compile, expand it to see exactly what was produced.
A working example
Here’s a derive macro that generates a simple method:
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
#[proc_macro_derive(Hello)]
pub fn hello(_input: TokenStream) -> TokenStream {
// For now, hardcode the struct name.
// We'll learn how to read it from the input later.
"impl Greeting { fn hello() -> &'static str { \"hello!\" } }"
.parse()
.unwrap()
}
}
This works, but it’s fragile — the struct name is hardcoded. In the next exercises, you’ll learn how to parse the input to extract the actual name.
Returning tokens from a string
The simplest way to produce a TokenStream is to parse it from a string using the
str::parse() method, as shown above. This is fine for quick experiments, but it gives you
no compile-time checks on the generated code — any mistake in the string only shows up
when someone uses the macro.
Later, we’ll use the quote! macro, which provides a much better way to construct token
streams. We’ll also see how to report errors properly using compile_error! and syn::Error,
instead of relying on confusing error messages from malformed tokens.
Exercise
The exercise for this section is located in 03_basics/02_what_a_proc_macro_returns
Write a derive macro that generates a method returning the name of the type as a string. For now, you can hardcode the name.
A minimal derive macro
So far we’ve either returned an empty token stream or hardcoded the output. To write a useful derive macro, we need to read the input — at minimum, we need to know the name of the type we’re deriving for.
Parsing the input (the hard way)
The TokenStream you receive is a sequence of tokens. You could convert it to a string
and try to extract the struct name manually:
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
#[proc_macro_derive(Greet)]
pub fn greet(input: TokenStream) -> TokenStream {
let input = input.to_string();
// Crude: find the word after "struct"
let name = input
.split_whitespace()
.skip_while(|t| *t != "struct")
.nth(1)
.expect("expected a struct");
format!(
"impl {name} {{ pub fn greet() -> &'static str {{ \"Hello from {name}!\" }} }}"
)
.parse()
.unwrap()
}
}
This works for struct Foo {}, but it’s fragile. It breaks on generics (struct Foo<T>),
visibility modifiers (pub struct Foo), attributes, and many other valid Rust constructs.
Parsing the input with syn
The syn crate provides a full Rust parser. It can parse a
TokenStream into a structured representation that’s easy to work with:
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(Greet)]
pub fn greet(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
format!(
"impl {name} {{ pub fn greet() -> &'static str {{ \"Hello from {name}!\" }} }}"
)
.parse()
.unwrap()
}
}
DeriveInput is a syn type that
represents the input to a derive macro. It gives you:
ident— the name of the typegenerics— any generic parametersdata— the body (struct fields or enum variants)attrs— attributes on the typevis— the visibility (pub,pub(crate), etc.)
For now, we only need ident. We’ll use the other fields in later chapters.
parse_macro_input!
Let’s look again at the line that does the parsing:
#![allow(unused)]
fn main() {
let input = parse_macro_input!(input as DeriveInput);
}
The parse_macro_input! macro
from syn parses a TokenStream into any type that implements
syn::parse::Parse. If parsing fails,
it automatically emits a compiler error and returns early from the function.
The as DeriveInput part tells parse_macro_input! which type to parse the tokens into.
A TokenStream is just a sequence of tokens — it has no structure. By specifying
as DeriveInput, you’re saying “parse these tokens as a struct/enum/union definition.”
syn provides other target types too — for example, you could parse as syn::ItemFn to parse
a function definition (ItemFn). The as syntax here is specific to parse_macro_input!; it’s not
the same as Rust’s type-casting as operator.
Exercise
The exercise for this section is located in 03_basics/03_minimal_derive
Write a derive macro that reads the type name from the input using syn and generates an
impl block. This time, the generated code must work for any struct name, not just a
hardcoded one.
Token and TokenStream
Now that you’ve used syn to parse input conveniently, let’s look under the hood at the
types that make it all work.
What is a token?
When the Rust compiler reads your source code, the first thing it does is lexing (also called tokenization): breaking the source text into a sequence of tokens. Each token is the smallest meaningful unit of the language.
For example, this Rust code:
#![allow(unused)]
fn main() {
fn add(a: i32) -> i32 { a + 1 }
}
is broken into a tree of tokens:
Ident("fn")
Ident("add")
Group(Parenthesis)
├── Ident("a")
├── Punct(':')
└── Ident("i32")
Punct('-')
Punct('>')
Ident("i32")
Group(Brace)
├── Ident("a")
├── Punct('+')
└── Literal(1)
Notice that this isn’t a flat list — parenthesized and braced portions form nested groups. Each token also has a span (its location in the source file).
TokenTree
The proc_macro crate represents tokens using the
TokenTree enum:
#![allow(unused)]
fn main() {
pub enum TokenTree {
Group(Group), // delimited group: (...), [...], {...}
Ident(Ident), // identifier or keyword: foo, struct, pub
Punct(Punct), // punctuation: +, :, #
Literal(Literal), // literal value: 42, "hello", 3.14
}
}
Notice that delimiters ((, ), [, ], {, }) are not individual tokens.
Instead, everything inside a matching pair of delimiters is grouped into a
Group.
This means the token tree is actually a tree, not a flat list — groups contain other
token trees.
TokenStream
A TokenStream is a sequence
of TokenTree values. It’s the type that every procedural macro receives and returns.
You can iterate over a TokenStream to inspect its tokens:
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
#[proc_macro_derive(Inspect)]
pub fn inspect(input: TokenStream) -> TokenStream {
for tree in input {
eprintln!("{tree:?}");
}
TokenStream::new()
}
}
We use eprintln! rather than
println! here. Both format and print
text, but println! writes to stdout while eprintln! writes to stderr. Both show up
during compilation, so either works for debugging — but by convention proc macros use
eprintln!, keeping debug noise on stderr and separate from anything a build might emit on
stdout. This makes eprintln! a handy debugging tool alongside cargo expand.
Keep in mind that a proc macro only runs when the crate that invokes it is actually
(re)compiled. If a build is cached, the macro doesn’t run and you’ll see no output — touch the
source or run cargo clean to force a rebuild.
Descending into groups
Iterating with for tree in input only visits the top-level tokens. Remember that a
TokenStream is a tree: anything inside (...), [...], or {...} is wrapped in a
Group, and its contents are a
nested TokenStream. For a struct like struct Pair { x: i32 }, the top level is just
Ident("struct"), Ident("Pair"), and a single Group — the fields x and i32 live
inside that group.
To reach them, call Group::stream()
to get the inner stream and recurse. Here we extend the Inspect example above to walk the
whole tree, printing nested tokens with indentation:
#![allow(unused)]
fn main() {
use proc_macro::{TokenStream, TokenTree};
fn print_tree(stream: TokenStream, depth: usize) {
let indent = " ".repeat(depth);
for tree in stream {
match tree {
TokenTree::Group(group) => {
eprintln!("{indent}Group {:?}", group.delimiter());
// `group.stream()` is the nested TokenStream — recurse into it.
print_tree(group.stream(), depth + 1);
}
other => eprintln!("{indent}{other:?}"),
}
}
}
}
Without the Group arm, print_tree would only ever see the top-level tokens; calling
group.stream() is what lets it descend into (...), [...], and {...}.
Building a TokenStream
You can build a TokenStream from individual TokenTree values:
#![allow(unused)]
fn main() {
use proc_macro::{TokenStream, TokenTree, Ident, Span};
let ident = TokenTree::Ident(Ident::new("hello", Span::call_site()));
let stream: TokenStream = [ident].into_iter().collect();
}
Ident represents an identifier or
keyword, and Span::call_site()
gives it the location of the macro invocation.
Or parse it from a string:
#![allow(unused)]
fn main() {
let stream: TokenStream = "fn foo() {}".parse().unwrap();
}
Building token streams by hand is tedious. That’s why we’ll soon introduce quote!, which
makes this much more ergonomic.
Exercise
The exercise for this section is located in 03_basics/04_token_and_tokenstream
Write a derive macro that inspects the token stream it receives and counts the number of
Ident tokens in it — including those nested inside groups, which you’ll need to recurse into
with Group::stream(). The macro should generate a method that returns this count.
proc-macro2
You’ve been using proc_macro::TokenStream so far — the type provided by the compiler.
It works, but it has a significant limitation: its types can only be used inside a
proc-macro crate (one with proc-macro = true in Cargo.toml) during compilation. If you try
to use them in a regular library or in a test binary, the code will fail to compile or panic.
The problem
This limitation makes it hard to:
- Test macro logic in unit tests within the proc-macro crate, since
proc_macrotypes can’t be used in test binaries. - Share code between a proc-macro crate and a regular library.
- Write helper functions in separate modules or crates.
proc-macro2 to the rescue
The proc-macro2 crate provides a drop-in replacement for the
proc_macro types (TokenStream, TokenTree, Ident, Span, etc.) that works everywhere —
in proc-macro crates, regular libraries, and tests.
#![allow(unused)]
fn main() {
use proc_macro2::TokenStream;
use proc_macro2::{Ident, Span};
fn make_ident(name: &str) -> Ident {
Ident::new(name, Span::call_site())
}
}
This function compiles in any context, not just in a proc-macro crate.
Converting between proc_macro and proc-macro2
At the boundary of your proc-macro function, you convert between the two:
#![allow(unused)]
fn main() {
use proc_macro::TokenStream; // The "real" type for the macro signature
#[proc_macro_derive(MyMacro)]
pub fn my_macro(input: TokenStream) -> TokenStream {
// Convert to proc_macro2 for internal use
let input: proc_macro2::TokenStream = input.into();
// Do all the work with proc_macro2 types
let output = generate(input);
// Convert back to proc_macro for the return type
output.into()
}
// This function can be tested!
fn generate(input: proc_macro2::TokenStream) -> proc_macro2::TokenStream {
// ...
input
}
}
syn and quote use proc-macro2
Both syn and quote use proc_macro2 types internally. When syn parses a
proc_macro::TokenStream, it converts it to proc_macro2 first. When quote! produces a
token stream, it returns a proc_macro2::TokenStream.
This is why you almost always need proc-macro2 as a dependency alongside syn and quote.
Exercise
The exercise for this section is located in 03_basics/05_proc_macro2
Refactor a derive macro to move the code generation logic into a separate function that uses
proc_macro2 types, keeping only the thin wrapper in the proc-macro entry point.
Parsing Rust code with syn
We’ve used syn briefly to parse the input of a derive macro. Let’s look more closely at what
syn offers and how to navigate the parsed result.
DeriveInput
When you write a derive macro, syn’s
DeriveInput type gives you a
structured view of the annotated item:
#![allow(unused)]
fn main() {
pub struct DeriveInput {
pub attrs: Vec<Attribute>,
pub vis: Visibility,
pub ident: Ident,
pub generics: Generics,
pub data: Data,
}
}
The data field tells you whether you’re looking at a struct, an enum, or a union:
#![allow(unused)]
fn main() {
pub enum Data {
Struct(DataStruct),
Enum(DataEnum),
Union(DataUnion),
}
}
Struct fields
For structs, DataStruct contains
a fields value which can be one of three kinds:
- Named fields —
struct Foo { x: i32, y: i32 } - Unnamed fields (tuple structs) —
struct Foo(i32, i32) - Unit structs —
struct Foo;
#![allow(unused)]
fn main() {
use syn::{parse_macro_input, DeriveInput, Data, Fields};
#[proc_macro_derive(FieldCount)]
pub fn field_count(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let input = parse_macro_input!(input as DeriveInput);
// Get the struct body — panic if applied to an enum or union.
let Data::Struct(data) = &input.data else {
panic!("FieldCount only supports structs");
};
// Match on `fields` to handle the three possible field layouts.
let count = match &data.fields {
// struct Foo { x: i32, y: i32 }
Fields::Named(fields) => fields.named.len(),
// struct Foo(i32, i32)
Fields::Unnamed(fields) => fields.unnamed.len(),
// struct Foo;
Fields::Unit => 0,
};
let name = &input.ident;
let expanded = format!(
"impl {name} {{ pub fn field_count() -> usize {{ {count} }} }}"
);
expanded.parse().unwrap()
}
}
Inspecting fields
FieldsNamed contains a named list
of Field values. Each Field has an
ident (the field name, as an Option<Ident>) and a ty (the type). For named fields,
ident is always Some — it’s None only for tuple struct fields:
#![allow(unused)]
fn main() {
if let Data::Struct(data) = &input.data {
if let Fields::Named(fields) = &data.fields {
// `fields.named` is a punctuated list of `Field` values.
// Each `Field` has an `ident` and a `ty`.
for field in &fields.named {
// Safe to unwrap: named fields always have an ident.
let name = field.ident.as_ref().unwrap();
// `ty` is a `syn::Type` — the parsed representation of the field's type.
let ty = &field.ty;
eprintln!("field: {name} has type {ty:?}");
}
}
}
}
We’ll use this extensively when building derive macros that need to generate code for each field — which is most of them.
Feature flags
syn is a large crate. To keep compile times down, most of its functionality is behind
feature flags. The most commonly used ones are:
full— enables parsing of all Rust syntax (statements, expressions, patterns, etc.)derive— enables parsing ofDeriveInput(this is on by default)parsing— enables the parsing infrastructureprinting— enables convertingsyntypes back to tokens
For derive macros, the derive feature (on by default) is usually sufficient. We enabled
full in this workshop so you have access to everything.
Exercise
The exercise for this section is located in 03_basics/06_syn
Write a derive macro that reads the fields of a named struct and generates a
field_count() method returning the number of fields.
Generating code with quote
Building token streams from strings is fragile and unreadable. The
quote crate provides a macro that lets you write code generation
that looks almost like regular Rust.
The quote! macro
quote! takes Rust-like syntax and produces a proc_macro2::TokenStream:
#![allow(unused)]
fn main() {
use quote::quote;
let tokens = quote! {
fn hello() -> &'static str {
"hello!"
}
};
}
Interpolation with #
The real power of quote! is interpolation. Use #variable to splice a value into the
generated code:
#![allow(unused)]
fn main() {
use quote::quote;
use syn::Ident;
let name = Ident::new("Foo", proc_macro2::Span::call_site());
let tokens = quote! {
#[automatically_derived]
impl #name {
pub fn type_name() -> &'static str {
stringify!(#name)
}
}
};
}
stringify! is a built-in macro that
turns its argument into a string literal at compile time — stringify!(Foo) becomes "Foo".
It’s useful in generated code because it works with any identifier without needing to build
string literals yourself.
This generates:
#![allow(unused)]
fn main() {
#[automatically_derived]
impl Foo {
pub fn type_name() -> &'static str {
stringify!(Foo) // expands to "Foo"
}
}
}
#[automatically_derived]
Every impl block your derive macro emits should carry #[automatically_derived]. It tells
the Rust toolchain that the implementation was generated, not hand-written, so Clippy and
other tools suppress lints inside it — the user can’t edit generated code, so warning them
about it would be noise. The standard library’s own derive macros (Clone, Debug,
PartialEq, …) all emit it: make it a reflex.
Any type that implements the ToTokens
trait can be interpolated. syn types like Ident, Type, and Path all implement it, so
you can splice parsed values directly into generated code.
Repetition with #( ... )*
When you need to generate code for each item in a collection, quote! supports repetition
syntax similar to macro_rules!:
#![allow(unused)]
fn main() {
use quote::quote;
let field_names = vec!["x", "y", "z"];
let field_idents: Vec<syn::Ident> = field_names
.iter()
.map(|n| syn::Ident::new(n, proc_macro2::Span::call_site()))
.collect();
let tokens = quote! {
fn field_names() -> &'static [&'static str] {
&[#(stringify!(#field_idents)),*]
}
};
}
Let’s break down #(stringify!(#field_idents)),*:
#( ... ),*— the repetition operator. It iterates overfield_idents, producing each element separated by commas (,). The*means “zero or more times.”stringify!(#field_idents)— the body repeated for each element.#field_identsis replaced with the currentIdenton each iteration.
So for field_idents = [x, y, z], this expands to:
#![allow(unused)]
fn main() {
stringify!(x), stringify!(y), stringify!(z)
}
which at compile time becomes "x", "y", "z".
This is the same pattern you’ll use to generate code for each field in a struct.
quote! vs string formatting
Compare these two approaches:
#![allow(unused)]
fn main() {
// String formatting — fragile, no syntax highlighting, easy to get wrong
let code = format!(
"impl {} {{ fn count() -> usize {{ {} }} }}",
name, count
);
let tokens: TokenStream = code.parse().unwrap();
// quote! — looks like Rust, catches many errors at compile time
let tokens = quote! {
impl #name {
fn count() -> usize { #count }
}
};
}
quote! is not just more readable — it catches many mistakes at compile time rather than
producing confusing errors for macro users.
format_ident!
The format_ident! macro lets you
construct new identifiers programmatically, similar to how format! builds strings:
#![allow(unused)]
fn main() {
use quote::format_ident;
let field = "name";
let getter = format_ident!("get_{field}");
// `getter` is the identifier `get_name`
}
This is useful when your macro needs to generate new method or type names based on the input.
quote_spanned!
quote! has a variant, quote_spanned!,
that lets you attach span information to the generated tokens. This controls where error
messages point when the generated code fails to compile. We won’t need it in this workshop —
we report errors with syn::Error instead (chapter 4) — but it’s a useful tool to know about
when you want a compile error in your generated code to point at a specific span in the
user’s source.
parse_quote! — the input counterpart
When writing unit tests for your macros, you need to construct syn types as inputs.
syn::parse_quote! is the idiomatic
way to do that — the mirror image of quote!: where quote! turns Rust syntax into a
TokenStream for output, parse_quote! turns it into a typed syn value for input. Despite
the name, it lives in syn, not quote.
#![allow(unused)]
fn main() {
use syn::{DeriveInput, parse_quote};
let input: DeriveInput = parse_quote! { struct Foo { x: i32 } };
}
This replaces syn::parse_str("...").unwrap(): you write real tokens instead of a string,
so typos are caught at compile time rather than at runtime.
Exercise
The exercise for this section is located in 03_basics/07_quote
The exercise provides a working derive macro that generates a field_names() method using
string formatting. Rewrite it to use quote! instead — the logic stays the same, only the
code generation changes.
The re-export pattern
So far, the code that uses a derive macro has depended directly on the proc-macro crate. This works, but there’s a problem: proc-macro crates are limited in what they can export.
A proc-macro = true crate can only export procedural macros. It cannot export structs,
traits, functions, or any other items. If your macro generates code that references a trait
or helper type, that trait must live somewhere else.
The problem
Say you’re building a Describe derive macro that implements a Describe trait:
#![allow(unused)]
fn main() {
pub trait Describe {
fn describe(&self) -> &'static str;
}
}
The trait definition can’t go in the proc-macro crate. So where does it go?
The facade pattern
The standard approach is a facade crate that re-exports the macro alongside any traits, types, or functions that the macro’s generated code needs:
my-lib/ ← facade crate (what users depend on)
├── Cargo.toml
├── src/lib.rs ← defines the trait, re-exports the macro
│
└── my-lib-macros/ ← proc-macro crate (implementation detail)
├── Cargo.toml
└── src/lib.rs ← #[proc_macro_derive(Describe)]
The facade crate (my-lib) depends on the proc-macro crate and re-exports the macro:
#![allow(unused)]
fn main() {
// my-lib/src/lib.rs
pub use my_lib_macros::Describe;
pub trait Describe {
fn describe(&self) -> &'static str;
}
}
Users only depend on my-lib:
[dependencies]
my-lib = "1.0"
And they get both the trait and the macro from a single import:
#![allow(unused)]
fn main() {
use my_lib::Describe;
#[derive(Describe)]
struct Point { x: f64, y: f64 }
}
Real-world examples
This pattern is everywhere:
serdere-exports macros fromserde_derive.thiserrorre-exports macros fromthiserror-impl.clapre-exports macros fromclap_derive.tokiore-exports macros fromtokio-macros.
If you’ve ever used #[derive(Serialize)] from serde without adding serde_derive to your
dependencies, it’s because serde re-exports it for you.
Exercise
The exercise for this section is located in 03_basics/08_re_export_pattern
The re-export-exercise-macros/ subdirectory already contains a working Describe derive macro
that implements a SelfDescribe trait. Your job is to wire up the facade pattern:
- Add the
re-export-exercise-macroscrate as a path dependency inCargo.toml. - Re-export the
Describederive macro withpub useinsrc/lib.rs, so that downstream code can import both the trait and the macro from a single crate.
Testing with trybuild
In the proc-macro2 section you wrote a unit test that called your
code-generation function directly and asserted on the tokens it produced — no separate crate,
no real #[derive]. That’s the fastest way to check your logic, and you can do the same for
the FieldNames macro (using parse_quote! now that you know it):
#![allow(unused)]
fn main() {
#[test]
fn generates_field_names() {
let input: syn::DeriveInput = syn::parse_quote! { struct Foo { x: i32 } };
let output = field_names_impl(&input).to_string();
assert!(output.contains("field_names"));
}
}
This is useful, but a unit test like this has two blind spots.
Blind spot 1: valid tokens ≠ working code
You can strengthen the test by asserting that the output parses — for example with
syn::parse2. But parsing only proves the
tokens are syntactically well-formed; it does not prove they compile and run. Imagine the
macro had a typo and generated the method name feild_names:
#![allow(unused)]
fn main() {
quote! {
impl #name {
pub fn feild_names() -> &'static [&'static str] { &[ #(#field_strings),* ] }
}
}
}
This still parses as a perfectly valid impl block, so a syn::parse2 assertion passes. But
every caller that writes Foo::field_names() now gets a compile error. A unit test won’t catch
this — only actually compiling the generated code will.
Blind spot 2: you can’t see the error the user sees
When the macro is misused — say, on an enum — field_names_impl panics. A unit test can
confirm that it panics:
#![allow(unused)]
fn main() {
#[test]
#[should_panic]
fn rejects_enums() {
let input: syn::DeriveInput = syn::parse_quote! { enum E { A, B } };
field_names_impl(&input);
}
}
But this tells you nothing about what the user experiences at their #[derive(FieldNames)]
call site: which message they get, and where the compiler points. Right now that message is
just a raw panic — from chapter 4 onward you’ll put real effort into making these diagnostics
helpful, and you’ll need a way to see and lock in exactly what the user gets.
trybuild fills both gaps
trybuild is a test harness that compiles standalone Rust files —
as if a user wrote them — and checks whether they succeed or fail with the expected error
messages. It catches both blind spots: it compiles the generated code for real, and it
snapshots the exact diagnostic a misuse produces.
How it works
A trybuild test is a regular #[test] function that points at a directory of .rs files:
#![allow(unused)]
fn main() {
#[test]
fn tests() {
let t = trybuild::TestCases::new();
t.pass("examples/*.rs");
t.compile_fail("tests/fail/*.rs");
}
}
pass()— each file must compile and run successfully.compile_fail()— each file must fail to compile, and the error output must match a corresponding.stderrfile.
The conventional layout puts passing cases in tests/pass/, but in this workshop they live in
examples/ instead — the next section explains why.
Test file structure
Each test file is a self-contained Rust program. For testing a derive macro, a passing test might look like:
// examples/basic_struct.rs
use my_macros::MyDerive;
#[derive(MyDerive)]
struct Foo {
x: i32,
}
fn main() {
// Verify the generated code works.
assert_eq!(Foo::field_names(), &["x"]);
}
Note the fn main() — each test file is compiled as its own binary.
Inspecting the output with cargo expand
Once a passing case compiles, you’ll often want to see what your macro actually expanded to —
cargo expand is the tool for that. But cargo expand
can only target things Cargo knows how to build: a library, a binary, a test, or an example. A
file under tests/pass/ is none of those — trybuild compiles it itself, in a throwaway crate, so
there’s no Cargo target to point cargo expand at.
That’s why, throughout this workshop, the passing cases live in examples/ rather than the
conventional tests/pass/. trybuild doesn’t care where the files sit — t.pass("examples/*.rs")
globs them just the same — but now each passing case is also a real example target. So you can run:
cargo expand --example basic_struct
to see exactly what your macro generated for that input (and cargo run --example basic_struct
runs it, fn main() and all).
The compile-fail cases stay in tests/fail/: they’re meant not to compile, so they can’t be
examples — and there’s nothing to expand anyway.
Testing compilation failures
For compile_fail tests, trybuild compares the compiler’s error output against a .stderr
file with the same name:
examples/
└── basic_struct.rs # a passing case — also `cargo expand --example basic_struct`
tests/
└── fail/
├── not_a_struct.rs
└── not_a_struct.stderr
On the first run, if no .stderr file exists, trybuild shows the actual compiler output and
fails the test. You can set the environment variable TRYBUILD=overwrite to have it write
the .stderr files for you, then review them with git diff to make sure they match your
expectations.
Why trybuild?
- It closes blind spot 1:
passtests compile and run the generated code, so a typo likefeild_namesfails the test instead of slipping through. - It closes blind spot 2:
compile_failtests snapshot the exact diagnostic the user sees. Once you start improving error messages in chapter 4, the.stderrfile is what proves those messages are actually helpful — and it catches regressions if asyn::Errorspan later changes. - It complements
proc-macro2unit tests rather than replacing them: unit tests verify your logic fast and in isolation,trybuildverifies the end result from the user’s perspective.
We’ll use trybuild throughout the rest of this workshop to verify both successful
compilation and expected error messages.
Exercise
The exercise for this section is located in 03_basics/09_trybuild
Write trybuild tests for the FieldNames derive macro you built in the quote exercise. Add a
passing case in examples/ (a named struct) and a compile-fail case in tests/fail/ (using the
macro on an enum). Once the passing case compiles, run cargo expand --example <name> on it to see
the field_names() method your macro generated.
Derive macros
In the last chapter you built the full derive-macro workflow end to end: parse a DeriveInput
with syn, generate code with quote, return it as a TokenStream, and test it with
trybuild. The macros were deliberately small — they read a type name and a flat list of
fields.
Real derive macros do much more. They generate code for every field and every variant, stay correct no matter what the user’s surrounding code looks like, report misuse with clear compiler errors, and let the user tune the output with attributes. This chapter is where you build those skills, one at a time:
- Reading the input properly — handling all three struct field layouts, and enums.
- Generating robust code — absolute paths and hygienic identifiers, so your output compiles in any context.
- Reporting errors well — turning panics into real compiler diagnostics that point at the
right span, and snapshotting them with
trybuild. - Attributes — reading container and field attributes by hand, then with the
darlingcrate. - Generics — making a derive work for
struct Wrapper<T> { .. }, lifetimes andwhereclauses included.
By the end you’ll combine all of it into a real-world macro: a #[derive(Builder)] that
generates a builder for any struct.
A derive macro in the wild: serde
Before writing your own, it’s worth looking at the most widely used derive macros in the
ecosystem: serde’s Serialize and Deserialize. You already met
thiserror in chapter 2; serde adds something new — attributes.
serde lets you customise the generated code with #[serde(...)] attributes, and they come in
two flavours you’ll be implementing yourself later in this chapter:
-
Container attributes sit on the struct or enum and affect the whole type:
#![allow(unused)] fn main() { #[derive(Serialize)] #[serde(rename_all = "kebab-case")] // every field name -> kebab-case in the output struct Product { unit_price: f64, // serializes as "unit-price" } }rename_allaccepts a whole family of cases —"camelCase","snake_case","kebab-case","SCREAMING_SNAKE_CASE", and more — so you pick the one that matches the shape you need. -
Field attributes sit on a single field and affect only that field:
#![allow(unused)] fn main() { #[derive(Serialize)] struct Product { #[serde(rename = "id")] // this field serializes as "id" sku: String, } }
Not every field attribute renames things — some change behaviour. #[serde(default)], for
example, tells the generated Deserialize code to fall back to the field’s Default value when
it’s missing from the input, instead of failing:
#![allow(unused)]
fn main() {
#[derive(Deserialize)]
struct Product {
#[serde(default)] // missing in the JSON? use `bool::default()` (false)
in_stock: bool,
}
}
The derive macro reads these attributes while it generates code and adjusts its output
accordingly. That’s the core idea behind a configurable derive macro, and it’s what makes
serde flexible enough to match almost any JSON shape.
Exercise
The exercise for this section is located in 04_derive/00_overview
A warm-up from the user’s perspective: add serde container and field attributes — rename_all,
rename, and default — to a struct so it serializes and deserializes the way the tests expect.
You’re using a derive macro here,
not writing one — that starts in the next section.
Handling struct fields
In chapter 3 you wrote macros that read a struct’s fields, but only for named structs —
struct Foo { x: i32 }. Real derive macros have to cope with all three field layouts Rust
allows. Let’s look at each, and at how you generate code that accesses a field once you’ve
found it.
The three field layouts
syn models the body of a struct as a Fields
enum with one variant per layout:
#![allow(unused)]
fn main() {
pub enum Fields {
Named(FieldsNamed), // struct Foo { x: i32, y: i32 }
Unnamed(FieldsUnnamed), // struct Foo(i32, i32)
Unit, // struct Foo;
}
}
- Named —
fields.namedis a list ofFields, each with anident(Some) and aty. - Unnamed (tuple struct) —
fields.unnamedis a list ofFields, each withident == Noneand aty. The fields are addressed by position, not by name. - Unit — no fields at all.
A derive macro that ignores tuple and unit structs will simply panic (or, worse, generate broken code) the first time a user reaches for one. Handling all three is the baseline for a well-behaved macro.
Accessing fields in generated code
Reading the field list is only half the job — your generated code usually needs to access
each field through self. How you write that access depends on the layout:
- For a named field, you use its identifier:
self.x. - For a tuple field, you use its position:
self.0.
For named fields, you already have the Ident, so interpolation is straightforward:
#![allow(unused)]
fn main() {
let ident = field.ident.as_ref().unwrap();
quote! { self.#ident } // -> self.x
}
For tuple fields there is no identifier, so you build the index from the field’s position. Use
syn::Index rather than a plain integer:
#![allow(unused)]
fn main() {
let index = syn::Index::from(i); // i is the field's position
quote! { self.#index } // -> self.0
}
Why syn::Index and not a bare usize? Because quote! renders a usize literal with its
type suffix — 0usize — so self.#index would expand to the invalid self.0usize.
syn::Index is quote-aware and renders as a plain 0. This is a small but classic papercut.
Putting it together
Iterating over the fields and emitting one snippet per field is the bread-and-butter of derive macros. A sketch that builds one expression per field, for both named and tuple structs:
#![allow(unused)]
fn main() {
let accessors: Vec<proc_macro2::TokenStream> = match fields {
Fields::Named(fields) => fields
.named
.iter()
.map(|f| {
let ident = f.ident.as_ref().unwrap();
quote! { self.#ident }
})
.collect(),
Fields::Unnamed(fields) => fields
.unnamed
.iter()
.enumerate()
.map(|(i, _)| {
let index = syn::Index::from(i);
quote! { self.#index }
})
.collect(),
Fields::Unit => Vec::new(),
};
// ... then splice them with repetition:
quote! { vec![ #(#accessors),* ] };
}
This sketch collects the bare accessors (self.x, self.0); in the exercise you’ll wrap each one
in format!("{:?}", &…) instead, but the per-field iteration and the repetition splice are
exactly the same shape.
Exercise
The exercise for this section is located in 04_derive/01_struct_fields
Complete the DebugFields derive macro so it works for named, tuple, and unit structs.
It generates a debug_fields(&self) -> Vec<String> method returning the {:?} representation
of each field. The named-struct case is done for you; fill in the tuple and unit cases.
Handling enums
Structs aren’t the only thing you can derive on. Enums are just as common — serde,
strum, and thiserror all derive heavily on them. Where a struct has fields, an enum has
variants, and each variant can itself carry named, unnamed, or no fields.
DataEnum and variants
When the input is an enum, DeriveInput::data is Data::Enum(DataEnum). The piece you care
about is the list of variants:
#![allow(unused)]
fn main() {
pub struct DataEnum {
pub variants: Punctuated<Variant, Comma>,
// ...
}
}
Each Variant has:
ident— the variant’s name (Circle,Point, …)fields— asyn::Fields, exactly the same enum you met for structsdiscriminant— the= 1part ofenum E { A = 1 }, if present
The key realisation: a variant’s fields is the same Fields type as a struct’s body. A
variant can be a unit (Point), a tuple (Circle(f64)), or a struct variant
(Rectangle { w: f64, h: f64 }) — and you handle them with the same three-way match.
Generating a match arm per variant
Most enum derive macros generate a match self { .. } with one arm per variant. The tricky
part is writing a pattern that binds — or ignores — the variant’s payload. If you only need to
know which variant you’re looking at, you can ignore the payload entirely:
#![allow(unused)]
fn main() {
let name = &input.ident; // the enum's name
let arms = data.variants.iter().map(|variant| {
let vname = &variant.ident;
let vstr = vname.to_string();
// A pattern that matches the variant and ignores any data it carries.
let pattern = match &variant.fields {
Fields::Unit => quote! { #name::#vname },
Fields::Unnamed(_) => quote! { #name::#vname(..) },
Fields::Named(_) => quote! { #name::#vname { .. } },
};
quote! { #pattern => #vstr, }
});
quote! {
impl #name {
pub fn as_str(&self) -> &'static str {
match self {
#(#arms)*
}
}
}
}
}
Note the three pattern shapes — Foo, Foo(..), and Foo { .. } — must match the variant’s
declared shape exactly, or the generated match won’t compile. .. is doing the work of
ignoring the payload in the tuple and struct cases.
If instead you needed to use the payload — say, to forward a field — you’d bind it instead of ignoring it, and reference those bindings in the arm body:
#![allow(unused)]
fn main() {
match &variant.fields {
Fields::Unit => quote! { #name::#vname => 0, },
// Bind the first tuple field as `x` and use it in the body.
Fields::Unnamed(_) => quote! { #name::#vname(x, ..) => x.count(), },
// Bind named fields by name.
Fields::Named(_) => quote! { #name::#vname { w, h, .. } => w * h, },
}
}
The exercises in this workshop all ignore the payload, but binding it is the same Fields
match — you just write Foo(x) / Foo { w, h } instead of Foo(..) / Foo { .. }.
Exercise
The exercise for this section is located in 04_derive/02_enums
The as_str macro above is your worked example. For the exercise you’ll write a sibling macro,
Ordinal, that generates an ordinal(&self) -> usize method returning the variant’s 0-based
position in declaration order (first variant 0, second 1, …).
The outer match/impl scaffolding is provided; you fill in each arm. Two things to work out:
the arm body is now the variant’s index (reach for enumerate), and you still have to build a
payload-ignoring pattern whose shape depends on the variant — apply the same Fields matching the
as_str example uses.
Using absolute paths
A derive macro generates code that gets pasted into someone else’s crate, in a module you’ve never seen. You don’t control what’s in scope there — and that’s a problem, because the code you generate has to compile no matter what names the user has brought into scope.
The trap
Suppose your macro generates code that returns a String:
#![allow(unused)]
fn main() {
quote! {
impl #name {
pub fn type_name(&self) -> String {
String::from(stringify!(#name))
}
}
}
}
This looks fine, and it works in every test you write. Then a user does this:
#![allow(unused)]
fn main() {
type String = MyFancyString;
#[derive(TypeName)]
struct Widget;
}
Now the String in your generated code resolves to MyFancyString, and their build breaks
with a confusing error pointing at their #[derive(TypeName)], not at your macro. Derive
macro output is not path-hygienic: bare names like String, Option, Result, Vec, and
Box resolve at the call site, against whatever the user has in scope.
The fix: absolute paths
Refer to every external item by a fully-qualified, absolute path — one starting with a leading
::. A leading :: anchors the path at a crate root, so it can’t be intercepted by a local
name:
#![allow(unused)]
fn main() {
quote! {
impl #name {
pub fn type_name(&self) -> ::std::string::String {
::std::string::String::from(stringify!(#name))
}
}
}
}
Common standard-library paths:
| Bare name | Absolute path |
|---|---|
Option, Some, None | ::core::option::Option, ::core::option::Option::Some, … |
Result, Ok, Err | ::core::result::Result, … |
String | ::std::string::String |
Vec | ::std::vec::Vec |
Box | ::std::boxed::Box |
Default::default() | ::core::default::Default::default() |
Many
coreitems are also re-exported fromstd, so::std::option::Optionworks too. Using::corelets your generated code work in#![no_std]crates as well — a nice habit if you want your macro to be maximally portable.
Referring to your own crate
The same problem applies to your crate’s items. If your macro lives in a crate my_macros but
the trait it implements lives in a companion crate my_lib, the generated code must name
::my_lib::TheTrait, not TheTrait — the user may not have imported it. This is the other half
of why real macro crates use the re-export pattern you saw in chapter 3.
Robust crates often go one step further and accept the crate path as an attribute (serde’s
#[serde(crate = "...")]) so the macro keeps working even if the user renamed the dependency. That’s beyond this exercise, but it’s the same idea taken to its conclusion.
To keep each later exercise focused on its own new idea, the generated code in this chapter
mostly sticks to bare names (String, Vec, format!). That’s fine for tests that don’t shadow
those names — but in a macro you ship, qualify every external item with an absolute path, as the
capstone in the last section does.
Exercise
The exercise for this section is located in 04_derive/03_absolute_paths
The TypeName derive macro generates a type_name() method, but it refers to String by its
bare name. examples/shadowed.rs shadows String and currently fails to compile. Fix the
macro to use absolute paths so both passing tests compile.
Preventing name clashes in generated symbols
The previous section was about names your macro refers to. This one is about names your macro introduces — the helper functions, constants, local variables, and types you create in your generated code. Get these wrong and your macro works in isolation but breaks the moment it’s used twice, or next to the user’s own code.
Why derive output isn’t hygienic
macro_rules! macros are hygienic: a local variable a declarative macro introduces is kept
separate from the caller’s variables, even if they share a name. Procedural macros mostly are
not. An identifier you build with format_ident! or quote! is an ordinary call-site
identifier — it lives in the same namespace as the user’s code and can collide with it.
There are two failure modes:
-
Colliding with the user. You emit
const BUF_SIZE: usize = 1024;at module scope; the user already has aBUF_SIZE. Now their crate doesn’t compile. -
Colliding with yourself. You emit a fixed-name module-level helper, and the user derives your macro on two types in the same module. Your own expansions clash:
#![allow(unused)] fn main() { #[derive(Loud)] struct Cat; // emits `fn shout_impl(..)` #[derive(Loud)] struct Dog; // emits `fn shout_impl(..)` again -> error[E0428] }
Strategy 1: scope it away
The cleanest fix is to not put a name at module scope in the first place. Two ways:
-
Put helpers inside the
impl. An associated function is namespaced under the type, soCat’s andDog’s copies never collide:#![allow(unused)] fn main() { impl #name { pub fn shout(&self) -> String { Self::shout_impl(/* .. */) } fn shout_impl(/* .. */) -> String { /* .. */ } } } -
Wrap module-level items in an anonymous
const.const _: () = { .. };gives you a fresh, unnamable scope — perfect for things like trait-impl bound checks that can’t live inside animpl:#![allow(unused)] fn main() { const _: () = { // helpers here are invisible to the outside world }; }
Strategy 2: generate a unique name
When you genuinely need a named item at module scope, make the name unlikely to collide and
unique per type. The convention is a __ prefix plus the type name, built with
format_ident!:
#![allow(unused)]
fn main() {
let helper = format_ident!("__loud_shout_for_{}", name);
quote! {
impl #name {
pub fn shout(&self) -> String { #helper(stringify!(#name)) }
}
fn #helper(s: &str) -> String { /* .. */ }
}
}
The __ prefix keeps you clear of names a user would plausibly write, and splicing in #name
guarantees Cat and Dog get distinct helpers. The same #helper interpolation is used at
both the definition and the call site, so they always agree.
You may have seen crates generate truly outlandish names like
_IMPL_SERIALIZE_FOR_Foo. That’s this exact technique — a verbose, deliberately ugly prefix chosen to never collide with hand-written code.
Exercise
The exercise for this section is located in 04_derive/04_name_clashes
Loud above was the worked example. For the exercise you’ll apply strategy 2 to a trickier case:
the Accessors macro generates a module-level accessor function per field (emitted next to the
impl, not inside it). The unique name you saw for Loud combined a prefix with the type name —
but that isn’t enough here. Work out a format_ident! scheme that survives both clashes the test
(examples/accessors.rs) throws at it: two fields in one struct, and two structs that share a
field name. The functions live at module scope, so strategy 1 doesn’t apply.
Error reporting: panic vs compile_error!
Every macro you’ve written so far reaches for panic! when the input is wrong — deriving on an
enum, a tuple struct, and so on. That’s fine while you’re prototyping, but it’s a poor
experience for the people using your macro. This section is the first of four on doing better.
What the user sees when you panic
When a proc macro panics, the compiler catches the panic and turns it into an error like this:
error: proc-macro derive panicked
--> src/main.rs:3:10
|
3 | #[derive(Getters)]
| ^^^^^^^
|
= help: message: Getters can only be derived for structs with named fields
The message is there, but it’s buried under “proc-macro derive panicked,” it’s tagged as a help note, and the span points only at the derive name. It reads like your macro crashed — because, technically, it did.
compile_error! — a real diagnostic
compile_error! is a built-in macro
that emits a compiler error with your message when it’s expanded. Instead of panicking, your
macro can return a token stream that invokes it:
#![allow(unused)]
fn main() {
return quote! {
compile_error!("Getters can only be derived for structs with named fields");
};
}
Now the user sees a clean, top-level error:
error: Getters can only be derived for structs with named fields
--> src/main.rs:3:10
|
3 | #[derive(Getters)]
| ^^^^^^^
No “panicked,” no “help: message:” — just an error, exactly like one the compiler itself would
produce. You can even emit a compile_error! alongside valid code, so the rest of your
expansion still type-checks and you avoid a cascade of follow-on errors. (More on that idea when
we cover attribute macros.)
Why not stop here?
compile_error! is a big step up from a panic, but building the message by hand with quote!
gets awkward as soon as you have more than one error site, and the span still points at the
whole derive rather than at the specific thing that’s wrong. The next two sections introduce
syn::Error, which packages a message
together with a span and converts to a compile_error! for you — and lets you point the error
exactly where it belongs.
Exercise
The exercise for this section is located in 04_derive/05_panic_vs_compile_error
The Getters derive macro currently panic!s when used on anything other than a named struct.
Replace both panics with returned compile_error! invocations. The unit tests in the macro
crate check that misuse produces a compile_error! rather than a panic.
Error reporting: syn::Error and Span
Returning a hand-built compile_error! works, but it doesn’t scale. As soon as a macro has
several things that can go wrong — an unsupported shape here, a malformed attribute there — you
want a uniform way to raise an error from deep inside your logic and report it once at the
top. That’s what syn::Error gives you.
syn::Error and syn::Result
A syn::Error bundles two things: a message and a span (a location in the user’s source).
syn also defines type Result<T> = std::result::Result<T, syn::Error>, the standard return type
for fallible macro logic.
Refactor your implementation function to return a syn::Result:
#![allow(unused)]
fn main() {
fn getters_impl(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
// ...
return Err(syn::Error::new_spanned(
input,
"Getters can only be derived for structs with named fields",
));
// ...
Ok(quote! { /* ... */ })
}
}
Creating an error: where does the span come from?
There are two constructors:
Error::new(span, message)— you pass aSpanexplicitly.Error::new_spanned(tokens, message)— you pass anything that implementsToTokens, andsynuses its span. This is usually the more convenient one: pass theDeriveInput, aField, anIdent, etc., and the error points at that piece of syntax.
The span is what makes the error land in the right place — point at input and the error
underlines the whole item; point at a single field (next section) and it underlines just that
field.
Reporting the error
A syn::Error converts to a compile_error! token stream with
into_compile_error
(or to_compile_error,
which borrows). The idiomatic top-level wiring is:
#![allow(unused)]
fn main() {
#[proc_macro_derive(Getters)]
pub fn getters(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
getters_impl(&input)
.unwrap_or_else(syn::Error::into_compile_error)
.into()
}
}
unwrap_or_else either takes the Ok token stream or, on Err, turns the error into a
compile_error!. Your _impl function stays focused on the happy path and can use ? to
bubble errors up — exactly like ordinary Rust error handling.
syn::Errorcan also accumulate multiple errors: build several andcombinethem, and the compiler reports them all at once instead of one-at-a-time. Handy for validating every field before giving up.
Exercise
The exercise for this section is located in 04_derive/06_syn_error
The exercise starts from Getters exactly as the previous section left it — errors reported with
a hand-written compile_error!, and getters_impl returning a plain TokenStream. Refactor it
into the syn::Error / syn::Result form shown above, without changing what the user sees:
return a syn::Result from getters_impl, turn each compile_error! return into an Err built
with syn::Error::new_spanned, wrap the success path in Ok, and convert the error back to tokens
in the entry point. The unit tests call .unwrap_err() / .is_ok() on getters_impl, so they
only compile once it returns a Result — that’s how you’ll know you’ve threaded the type through.
Spans for better error messages
A syn::Error carries a span — a pointer into the user’s source code that tells the
compiler where to draw the ^^^^ underline. Choosing a good span is the difference between an
error the user can act on immediately and one that makes them hunt.
What a span is
A Span is a region of
source text. Every token syn parses remembers the span it came from, so when you have a piece
of the parsed input — a Field, an Ident, a Type — you also have the location where the
user wrote it. Error::new_spanned(thing, message) borrows that location.
Point at the smallest relevant thing
Compare two ways of rejecting a struct that has a ()-typed field:
#![allow(unused)]
fn main() {
// Span = the whole struct. Underlines everything; the user has to find the bad field.
return Err(syn::Error::new_spanned(input, "fields can't be `()`"));
}
error: fields can't be `()`
--> src/main.rs:3:1
|
3 | / struct Config {
4 | | name: String,
5 | | marker: (),
6 | | }
| |_^
#![allow(unused)]
fn main() {
// Span = just the offending field's type. Underlines exactly what's wrong.
return Err(syn::Error::new_spanned(&field.ty, "fields can't be `()`"));
}
error: fields can't be `()`
--> src/main.rs:5:13
|
5 | marker: (),
| ^^
The second one points the user straight at marker’s type. As a rule of thumb: span the
error on the most specific token that’s actually at fault — a field’s ty or ident, a
variant, an attribute — rather than the whole DeriveInput. Whole-item spans are right only
when the whole item is the problem (e.g. “this derive doesn’t support enums”).
Where does a good span come from?
You almost never construct a Span by hand. Instead you reach for the relevant parsed node and
let new_spanned take its span:
- a bad field →
new_spanned(&field.ty, ..)ornew_spanned(&field.ident, ..) - a bad variant →
new_spanned(&variant, ..) - a bad attribute →
new_spanned(&attr, ..)
It’s also good practice to put the offending item’s name into the message text.
Span::call_site()resolves to wherever the macro was invoked — the#[derive(...)]site — rather than to any particular token in the input. It’s the right span for identifiers your macro invents (it’s what you pass toIdent::new), but the wrong one for an error: spanned that way, the error underlines the whole derive instead of the offending field, so it doesn’t tell the user where to look. Span errors on a real input node withnew_spannedinstead.
Exercise
The exercise for this section is located in 04_derive/07_spans
The Getters macro now rejects fields of the unit type (). The per-field check is wired up;
your job is to return the error and span it so the compiler underlines just the offending field.
A shipped compile-fail snapshot, tests/fail/unit_field.stderr, is the spec: it pins both the
message and the span, so getting the span wrong — pointing at the whole struct, say — fails the
test even when the message is right. (A plain unit test can’t catch that: it never sees where the
^^^^ lands. Authoring these snapshots yourself is the next section.)
Testing error messages with trybuild
You spent three sections making Getters fail gracefully — real diagnostics, good spans. But
how do you know the error a user sees is actually the one you intended? You can’t see it from a
unit test: asserting that getters_impl returns an Err tells you that it failed, not what
the compiler prints or where it points. For that you need to compile the broken code for real
and capture the output — which is exactly what trybuild’s compile_fail mode does.
compile_fail and .stderr snapshots
In chapter 3 your compile_fail case captured a raw panic. Now you’ll use the same
compile_fail API to snapshot intentional diagnostics — the spanned syn::Errors you just
built:
#![allow(unused)]
fn main() {
#[test]
fn tests() {
let t = trybuild::TestCases::new();
t.pass("examples/*.rs");
t.compile_fail("tests/fail/*.rs");
}
}
Each file under tests/fail/ must fail to compile, and its diagnostic must match a sibling
.stderr snapshot:
tests/fail/
├── on_enum.rs
├── on_enum.stderr
├── unit_field.rs
└── unit_field.stderr
The .stderr file is the captured compiler output — message and the ^^^^ span. That makes
it a regression test for your error reporting: if a refactor accidentally changes a message or
moves a span, the snapshot stops matching and the test fails.
Generating snapshots
You don’t write .stderr files by hand. Write the .rs file, then let trybuild capture the
output for you:
TRYBUILD=overwrite cargo test --package trybuild-errors-exercise
On the first run there’s no .stderr, so trybuild writes one from the actual compiler output.
Always review the generated file with git diff — the whole point is to confirm the message
and span are what you intended, not just to rubber-stamp whatever came out. For Getters you’d
expect:
# tests/fail/unit_field.stderr
error: Getters can't generate a getter for the `()`-typed field `marker`
--> tests/fail/unit_field.rs:6:13
|
6 | marker: (),
| ^^
The span underlines just (), line 6 — exactly the per-field span you built in the previous
section. An enum, by contrast, underlines the whole item. The snapshot is what proves those
choices reached the user.
Snapshots are tied to the exact compiler output, so they can shift between Rust versions — and when a derive leans on a crate like
darling, a dependency bump can move the wording too. When a change is legitimate, refresh the snapshot the same way you first generated it: re-run withTRYBUILD=overwrite, thengit diffto confirm the new output is still what you intended before committing it. Keep your fail tests focused on your macro’s diagnostics (the message and span you control) rather than incidental downstream errors, which are more prone to these shifts.
Exercise
The exercise for this section is located in 04_derive/08_trybuild_errors
The Getters macro is complete. Two near-empty compile_fail files are scaffolded under
tests/fail/ (just an import and fn main). Fill each with a program that misuses Getters so it
fails to compile — one deriving on an enum, one on a struct with a ()-typed field. Then generate
the .stderr snapshots with TRYBUILD=overwrite and review them: confirm the enum error
underlines the whole item and the () case underlines just the field. The review is the point — a
snapshot only protects you once you’ve checked it says what you intended.
Container attributes
This is what made serde feel magic back in the overview: attributes that let the user tune
what your macro generates. We’ll start with container attributes — the ones that sit on the
struct or enum itself, like #[serde(rename_all = "camelCase")].
Declaring a helper attribute
By default, an attribute like #[greeting(prefix = "Hi")] is unknown to the compiler — it would
reject it before your macro ever ran. A derive macro has to declare the helper attributes it
intends to read, in the attributes(...) list of its proc_macro_derive:
#![allow(unused)]
fn main() {
#[proc_macro_derive(Greeting, attributes(greeting))]
pub fn greeting(input: TokenStream) -> TokenStream { /* ... */ }
}
Now #[greeting(...)] is recognised (but inert): the compiler leaves it attached to the item for
your derive to inspect, and ignores it for every other purpose.
Finding your attribute
Attributes on the container live in DeriveInput::attrs, a Vec<Attribute>. It contains every
attribute on the item — #[derive(..)], #[doc = ".."], and yours — so the first step is always
to filter down to the ones you own with
Attribute::path:
#![allow(unused)]
fn main() {
for attr in &input.attrs {
if !attr.path().is_ident("greeting") {
continue;
}
// ... this one is ours
}
}
Parsing the contents with parse_nested_meta
The arguments inside greeting( ... ) are your mini-syntax to parse. For the common
key = value, key = value shape, syn provides
Attribute::parse_nested_meta,
which calls your closure once per comma-separated entry:
#![allow(unused)]
fn main() {
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("prefix") {
let value = meta.value()?; // parse stream after the `=`
let s: syn::LitStr = value.parse()?; // expect a string literal
prefix = s.value(); // assign to the `let mut prefix` above — not `let`
Ok(())
} else {
Err(meta.error("unsupported greeting attribute; expected `prefix`"))
}
})?;
}
Things to notice:
meta.pathis the key (prefix);meta.value()returns a parse stream for the value after the=. Parse it into whatever you expect —LitStr,LitInt, a type, a path.- An unrecognised key returns
meta.error(..), asyn::Erroralready spanned on the offending key. This is the error machinery from earlier sections paying off: malformed attributes get a precise diagnostic for free. parse_nested_metaalso handles flags (#[greeting(loud)], wheremeta.pathis set but there’s no value) and nested lists — it’s the general-purpose tool for hand-written attribute parsing.- The closure can fail (it returns a
Result), so it can’t own the result. It instead assigns to alet mut prefixdeclared before the loop and captured by the closure —prefix = …, notlet prefix = …. Aletwould make a throwaway local and leave the outer variable untouched.
Exercise
The exercise for this section is located in 04_derive/09_container_attributes
Greeting above read a string attribute. For the exercise you’ll build the Repeat macro,
which reads an integer container attribute — #[repeat(times = N)] — and generates a
repeated() method returning the type’s name repeated N times (default 1). The attribute is
declared and the parse_nested_meta loop is wired up; your job is the value extraction. It’s the
same shape as the worked example, but the value isn’t a string this time — find the right literal
type and convert it to a number.
Field attributes
Container attributes configure the whole type; field attributes configure a single field —
serde’s #[serde(rename = "...")], #[serde(skip)], and friends. The mechanics are the same
as the previous section, with one difference: you read the attributes off each Field instead
of off the DeriveInput.
One declaration, two positions
You still declare the helper attribute once, on the derive:
#![allow(unused)]
fn main() {
#[proc_macro_derive(Labelled, attributes(label))]
}
That single declaration covers the attribute wherever it legally appears — on the container and
on fields. Which positions you actually read is up to your macro. Here we read it per field, so
we look at field.attrs rather than input.attrs:
#![allow(unused)]
fn main() {
fn field_label(field: &syn::Field) -> syn::Result<String> {
for attr in &field.attrs {
if attr.path().is_ident("label") {
// ... read this field's label
}
}
// default: the field's own name
Ok(field.ident.as_ref().unwrap().to_string())
}
}
parse_args for a single value
The previous section used parse_nested_meta for a comma-separated key = value list. When the
attribute holds a single value — #[label("Email address")] — there’s a more direct tool:
Attribute::parse_args,
which parses the tokens inside the parentheses as any Parse type:
#![allow(unused)]
fn main() {
let s: syn::LitStr = attr.parse_args()?;
let label = s.value();
}
parse_args works with any type implementing syn::parse::Parse — LitStr, LitInt, Ident,
Type, or your own custom-parsed struct. It’s the right reach whenever an attribute carries one
self-contained argument.
Rust offers three attribute shapes, and syn has a tool for each:
- bare path —
#[label]— checkattr.path().is_ident(..) - name-value —
#[label = "x"]— read it withattr.meta.require_name_value() - list —
#[label(...)]— parsed with one of two tools, depending on its contents:- one self-contained argument —
#[label("x")]—attr.parse_args() - a
key = valuelist —#[label(a = 1, b = 2)]—attr.parse_nested_meta(..)
- one self-contained argument —
Picking the shape is a design choice; following one ecosystem’s convention — usually serde’s — keeps your macro familiar to users.
Reading a name-value attribute
parse_args is the easy path, but it only applies to the list shape. The name-value shape
#[label = "x"] doesn’t go through a parser — require_name_value() hands you a
MetaNameValue whose .value is a
plain syn::Expr, and you dig the literal out
yourself. A string literal arrives as Expr::Lit wrapping a Lit::Str, so you match those two
layers and reject anything else with a spanned error:
#![allow(unused)]
fn main() {
let nv = attr.meta.require_name_value()?;
match &nv.value {
syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Str(s), .. }) => s.value(),
other => return Err(syn::Error::new_spanned(other, "expected a string literal")),
}
}
Spanning the error on other (the offending expression) makes the diagnostic point at exactly
what the user wrote after the =, the same span discipline from the error-reporting sections.
Collecting fallible results
Computing one label per field is a map that can fail, so collect into a Result to
short-circuit on the first bad field:
#![allow(unused)]
fn main() {
let labels = fields
.iter()
.map(field_label)
.collect::<syn::Result<Vec<String>>>()?;
}
This is a handy idiom: collect() turns an iterator of Results into a Result of a Vec,
propagating the first Err.
Exercise
The exercise for this section is located in 04_derive/10_field_attributes
Labelled above used the list shape #[label("...")] with parse_args. For the exercise
you’ll build Renamed, which uses the name-value shape instead: #[rename = "..."]. It
generates a column_names() method returning each field’s name, overridden by #[rename] when
present. The iteration and require_name_value call are wired up; your job is to pull the string
out of the attribute’s value expression — a different job from parse_args, since here you
inspect a syn::Expr yourself (see the three-shapes aside above).
Parsing attributes with darling
The last two sections parsed attributes by hand: loop over attrs, match on path(), call
parse_nested_meta, pull out values, raise errors for anything unexpected. It works, but it’s a
lot of fiddly, repetitive code — and it grows fast as you add options. darling
is a crate that generates all of that for you from a plain struct, the same way serde generates
(de)serialization.
Describe the attributes as a struct
With darling you declare the shape of your attributes as a Rust struct and derive the
parsing. There are derives mirroring each level of the input:
FromDeriveInput— the whole item, including container attributes.FromField— a single field.FromVariant— an enum variant.FromMeta— a single value, so you can parse attributes into your own enums and types.
#![allow(unused)]
fn main() {
use darling::{FromDeriveInput, FromField, ast, util::Ignored};
#[derive(FromDeriveInput)]
#[darling(attributes(model), supports(struct_named))]
struct ModelOpts {
ident: syn::Ident,
data: ast::Data<Ignored, FieldOpts>,
#[darling(default = "default_table")]
table: String,
}
fn default_table() -> String {
"items".to_string()
}
#[derive(FromField)]
#[darling(attributes(model))]
struct FieldOpts {
ident: Option<syn::Ident>,
rename: Option<String>, // `Option<T>` is optional automatically
}
}
A few things darling is doing here:
- Magic fields.
ident,data,attrs,generics,visare recognised by name and filled from the input —identis the type name,datais the body. ast::Data<V, F>captures the body generically:Vis the per-variant type (hereIgnored, since we only support structs),Fis the per-field type. Later you call.take_struct()to get the fields back asFieldOptsvalues.supports(struct_named)rejects enums, tuple structs, and unit structs for you, with a proper error — no hand-writtenmatchonData.- Optional options. An
Option<T>field (likerename) is optional automatically — it defaults toNonewith no annotation. For a non-Optionfield you opt in with#[darling(default)](falls back to the type’sDefault) or#[darling(default = "...")](calls a named function).tableuses the latter:#[darling(default = "default_table")]callsdefault_table(), which returns"items", when the user omits#[model(table = "...")]. Unknown keys and type mismatches become precisedarling::Errors automatically.
Wire it into the macro
Parsing is then a single fallible call, and darling::Error renders itself straight to
compile_error! tokens:
#![allow(unused)]
fn main() {
#[proc_macro_derive(Model, attributes(model))]
pub fn model(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
model_impl(&input)
.unwrap_or_else(|err| err.write_errors())
.into()
}
fn model_impl(input: &DeriveInput) -> darling::Result<proc_macro2::TokenStream> {
let opts = ModelOpts::from_derive_input(input)?;
// ... use opts.ident, opts.table, opts.data.take_struct(), ...
}
}
Compare this to section 9: the same configurability, but the parsing, defaulting, validation, and
error reporting are all generated. For anything beyond one or two trivial options, darling is
what real macro crates reach for.
Exercise
The exercise for this section is located in 04_derive/11_darling
Complete the Model derive macro. The darling schema (ModelOpts and FieldOpts) is written
for you — study it. Your job is the code generation: call from_derive_input, then emit
table_name() and columns() from the parsed options, using each field’s rename when present.
Handling generics
Every macro so far assumed the type was non-generic. The moment a user writes
struct Wrapper<T> { value: T }, a naive impl Wrapper { .. } is wrong — it’s missing the <T>,
and the body probably needs T to satisfy some bound. Handling generics correctly is the last
piece before you can write a macro people actually depend on.
What an impl header needs
For struct Wrapper<'a, T: Clone>, the impl you generate has to look like:
#![allow(unused)]
fn main() {
impl<'a, T: Clone> Wrapper<'a, T> { /* ... */ }
// ^^^^^^^^^^^^^^^ ^^^^^^^
// impl_generics ty_generics
}
Three distinct pieces, and they’re not identical: the impl<...> list carries bounds and
lifetimes, while the Wrapper<...> list is just the parameter names. Plus any where clause.
Getting these by hand is error-prone.
split_for_impl
syn’s Generics::split_for_impl
hands you all three, ready to interpolate:
#![allow(unused)]
fn main() {
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
quote! {
impl #impl_generics #name #ty_generics #where_clause {
// ...
}
}
}
Each piece knows how to render itself in the right spot: #impl_generics becomes <'a, T: Clone>,
#ty_generics becomes <'a, T>, and #where_clause becomes a where ... (or nothing). For a
non-generic type all three are empty, so the same code handles both cases.
Adding your own bounds
split_for_impl faithfully reproduces the user’s generics — but your generated code often needs
more. If your body calls format!("{:?}", ..) on a field of type T, then T: Debug must hold,
and the user didn’t write that. The fix is to add the bound before splitting: clone the generics,
push a bound onto each type parameter, then split the modified copy.
#![allow(unused)]
fn main() {
let mut generics = input.generics.clone();
for param in &mut generics.params {
if let syn::GenericParam::Type(type_param) = param {
type_param.bounds.push(syn::parse_quote!(::std::fmt::Debug));
}
}
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
}
Notes:
parse_quote!is likequote!, but it parses the tokens into asyntype — here aTypeParamBound. It’s the easiest way to build smallsynvalues from real syntax.- Match only on
GenericParam::Type; lifetimes (GenericParam::Lifetime) and const generics (GenericParam::Const) take no such bound. - Pushing onto a parameter’s
boundsadds to any bound the user already wrote (T: ClonebecomesT: Clone + Debug) rather than replacing it.
This blanket “add
Debugto every type parameter” is the same heuristic#[derive(Debug)]uses.
Exercise
The exercise for this section is located in 04_derive/12_generics
The worked example above added a Debug bound for DebugFields. For the exercise you’ll build a
different generic-aware macro, Empty, which generates an empty() -> Self constructor setting
every field to Default::default(). split_for_impl is already wired into the generated impl;
your job is the bound. Work out which trait bound the generated body now requires, and add it to
each type parameter the same way the example did. The plain test already passes — get generic
(which has type parameters) to pass too.
Building a #[derive(Builder)]
Time to put it all together. The builder pattern is a textbook use for a derive macro. For a
struct Command, #[derive(Builder)] generates a CommandBuilder that collects fields one at a
time and validates them at the end:
#![allow(unused)]
fn main() {
let cmd = Command::builder()
.executable("cargo".to_string())
.args(vec!["build".to_string()])
.build()?; // errors if a required field is missing
}
What the macro generates
For
#![allow(unused)]
fn main() {
#[derive(Builder)]
struct Command {
executable: String,
args: Vec<String>,
current_dir: Option<String>,
}
}
it emits roughly (bare Option/Result/String here for readability — the real macro
qualifies them with absolute paths, as the bullets below explain):
#![allow(unused)]
fn main() {
pub struct CommandBuilder {
executable: Option<String>,
args: Option<Vec<String>>,
current_dir: Option<String>, // already optional — see below
}
impl Command {
pub fn builder() -> CommandBuilder {
CommandBuilder { executable: None, args: None, current_dir: None }
}
}
impl CommandBuilder {
pub fn executable(&mut self, value: String) -> &mut Self { self.executable = Some(value); self }
// ... one setter per field ...
pub fn build(&mut self) -> Result<Command, Box<dyn std::error::Error>> {
Ok(Command {
executable: self.executable.clone().ok_or_else(|| /* "field `executable` is not set" */)?,
args: self.args.clone().ok_or_else(|| /* ... */)?,
current_dir: self.current_dir.clone(), // optional: no error
})
}
}
}
This single macro exercises the whole chapter:
- Per-field code generation — a builder field, an initialiser, a setter, and a
buildexpression for every field (chapter sections on struct fields). - Type inspection — fields that are already
Option<T>are optional: their setter takes the innerT, andbuild()doesn’t require them. Detecting that means looking inside thesyn::Type. - Error reporting —
build()returns aResultand names the first missing required field. - Robust output — absolute paths (
::core::option::Option,::std::result::Result) and a derived...Buildername viaformat_ident!.
Detecting Option<T>
The one genuinely new technique is digging into a type. A field’s ty is a syn::Type; for
Option<String> it’s a Type::Path whose last path segment is Option with one angle-bracketed
type argument. Pulling out that inner type is a drill-down through syn’s type model:
#![allow(unused)]
fn main() {
fn option_inner(ty: &syn::Type) -> Option<&syn::Type> {
let syn::Type::Path(type_path) = ty else { return None };
let segment = type_path.path.segments.last()?;
if segment.ident != "Option" { return None; }
let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { return None };
match args.args.first()? {
syn::GenericArgument::Type(inner) => Some(inner),
_ => None,
}
}
}
(This is a syntactic check: it matches the name Option, so a user who writes
std::option::Option<T> still works, but one who aliases type Maybe<T> = Option<T> would not —
a fundamental limitation of macros, which only ever see syntax, never resolved types.)
Exercise
The exercise for this section is located in 04_derive/13_builder
Complete #[derive(Builder)]. The per-field loop already builds most of the fragments for you —
the builder field, its initialiser, and the setter — and the option_inner helper is provided. But
none of it is wired up yet: builder_impl currently returns nothing. You have two jobs:
- Each field’s
build()expression — emit the value each field contributes insidebuild(): clone optional fields straight through, and error on unset required ones. - The final assembly — build and return the macro’s output, splicing in every per-field
fragment (the builder fields, initialisers, setters, and the
build()expressions from job 1): the...Builderstruct, thebuilder()constructor, and theimplblock carrying the setters and the falliblebuild().
Make examples/command.rs pass.
Going further. A common extension is a field attribute,
#[builder(each = "arg")], that generates a method to push items onto aVecfield one at a time. With this chapter behind you, you have every tool needed to add it: parse the field attribute (sections 10–11), and generate an extra setter. A great challenge if you want one.
Function-like macros
The derive macros you’ve built so far had it easy: the compiler always handed your macro the
same thing — the annotated struct or enum, parsed into a DeriveInput. The shape of the input
never varied — it was always a type definition — and your job was to generate extra code alongside
it.
Function-like macros lift that restriction. They look like a function call with a !:
#![allow(unused)]
fn main() {
let v = vec![1, 2, 3];
let s = format!("{name} is {age}");
let q = sqlx::query!("SELECT * FROM users WHERE id = $1", id);
}
“Function-like” describes the call syntax, not how the macro is implemented: vec! and
format! are declarative (macro_rules!), while
sqlx::query! is procedural — the
#[proc_macro] kind this chapter builds. Section 03 draws that line; for now
just note they all share the name!( ... ) shape.
That last one even checks its SQL against your database at compile time. Function-like macros can
receive any tokens at all between their delimiters — a comma-separated list, a SQL string, even
syntax that isn’t valid Rust. Whatever you write inside name! { ... } is handed to your macro as a
raw TokenStream, and it’s entirely up to you what it means.
Why is println! a macro?
It’s a fair question — why isn’t println just a function? Two reasons, and both are things a
plain function fundamentally cannot do:
- It’s variadic over mixed types.
println!("{} {}", 1, "two")takes a different number of arguments of different types every time it’s called. Rust functions have a fixed arity and fixed parameter types; there’s no way to writefn println(fmt, ...)like in C. - It checks the format string at compile time.
println!("{}")is a compile error — “1 positional argument in format string, but no arguments were given” — not a runtime panic. To produce that error the macro has to parse the literal"{}"while the program is being compiled and count its placeholders. A function only sees the string’s value at run time, far too late.
Anything that needs a variable shape of input, or needs to inspect its arguments as source code,
has to be a macro. println! is both.
Registering a function-like macro
You already know the crate layout from chapter 3 — a proc-macro crate, usually re-exported
through a facade crate. The only thing that changes is the attribute on your function:
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
#[proc_macro] // not #[proc_macro_derive(...)] and not #[proc_macro_attribute]
pub fn my_macro(input: TokenStream) -> TokenStream {
// `input` is whatever tokens the caller wrote inside `my_macro!( ... )`
todo!()
}
}
Three kinds of procedural macro, three attributes:
| Attribute | Invoked as | Input it receives |
|---|---|---|
#[proc_macro_derive(Foo)] | #[derive(Foo)] | the annotated type, as a DeriveInput |
#[proc_macro] | foo!( ... ) | the raw tokens between the delimiters |
#[proc_macro_attribute] | #[foo] item | the attribute’s args and the item |
A function-like macro can be invoked with any of the three delimiters — foo!(...), foo!{...},
or foo![...] — and they’re interchangeable; the macro can’t tell which you used (vec![1, 2, 3]
and vec! {1, 2, 3} are the same call). By convention !{ } is used for block-like or
statement-like DSLs and !( ) for expression-like calls.
The output is the same TokenStream you’ve been returning all along, and it’s spliced in wherever
the macro was called. That means a function-like macro can expand into an expression
(let x = foo!();) or, in item position, a set of items (foo!(); at module level) — depending
on where it’s invoked and what tokens it returns. (To run several statements in expression position,
return a block expression { ... } — you’ll do exactly that in the next section.)
Exercise
The exercise for this section is located in 05_function_like/00_overview
A warm-up from the caller’s side. Use
serde_json::json! — a
function-like macro that turns a JSON-shaped block of tokens into a
serde_json::Value — to build
the document the tests expect. You’re using a function-like macro here; from the next section
onwards you’ll build your own.
Parsing arbitrary token input
A derive macro never has to wonder what its input looks like — parse_macro_input!(input as DeriveInput) always works, because the compiler only ever hands it a type definition. A
function-like macro has no such guarantee. The tokens between foo!( ... ) are whatever the caller
typed, and you decide how to interpret them.
syn still does the heavy lifting; you just have to tell it what to parse. For input that’s a
list of ordinary Rust fragments, the tools you already know are enough.
A worked example: min!
Let’s build min!(a, b, c, ...) — a macro that expands to the minimum of its arguments, where each
argument is an arbitrary expression:
#![allow(unused)]
fn main() {
let smallest = min!(3 + 4, x, y, 0);
}
The input is a comma-separated list of expressions. syn models exactly that with
Punctuated<T, P> — a sequence
of T separated by the punctuation token P:
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
use quote::quote;
use syn::{Expr, Token, parse_macro_input, punctuated::Punctuated};
#[proc_macro]
pub fn min(input: TokenStream) -> TokenStream {
// Parse the input as a comma-separated list of expressions.
let args = parse_macro_input!(input with Punctuated::<Expr, Token![,]>::parse_terminated);
let mut iter = args.into_iter();
let first = iter.next().expect("min! needs at least one argument");
let rest = iter; // the remaining expressions
quote! {
{
let mut __min = #first;
#( __min = ::core::cmp::min(__min, #rest); )*
__min
}
}
.into()
}
}
Two new pieces compared to a derive macro:
parse_macro_input!(input with <parser>). Theas Typeform needs a type that implementsParse. When you instead want to call a specific parser function, use thewithform.Punctuated::parse_terminatedparses a list with an optional trailing comma — somin!(a, b,)is accepted, just like Rust accepts trailing commas everywhere else. (Token![,]issyn’s way of naming the,token type; there’s aToken![...]for every bit of Rust punctuation and every keyword.)#( ... )*over a runtime iterator. You metquote!’s repetition in chapter 3 when expanding struct fields. It works on anything iterable, including therestiterator here:quote!advances it once per repetition and splices each expression in turn.
That’s the whole macro. Note that min! generates a chain of min calls; it doesn’t compute
anything itself. The actual comparisons happen at run time, on whatever values the expressions
evaluate to — the macro only assembles the code. (The .expect(..) on empty input is a shortcut; a
production macro would report that with a syn::Error, as you did in chapter 4; the exercise’s
avg! already has that error wired up for you.)
Generating code that uses what you parsed
A subtle but important point: the generated code can depend not just on the expressions you parsed but on facts about them — how many there are, their positions, and so on. The number of arguments is known at compile time, so you can bake it straight into the output as a literal. That’s the new muscle the exercise asks you to flex.
Exercise
The exercise for this section is located in 05_function_like/01_parsing
Build a sibling of min!: a macro avg!(a, b, c, ...) that expands to the average of its
comma-separated arguments, as an f64. Parse the arguments exactly as min! does, then generate
code that sums them and divides by the count.
The twist over min! is that you need the number of arguments in your generated code — it’s the
divisor. Punctuated is a collection, so its length is a method call away; emit that count as part
of the output. Make examples/basic.rs pass. (The empty-input case, avg!(), is already handled
for you with a compile_error! and checked by tests/fail/empty.rs.)
Custom syntax
So far the tokens inside your macro have been ordinary Rust — a list of expressions. The real
reason to reach for a function-like macro is that the input doesn’t have to be valid Rust at all.
You can invent your own little language and parse it however you like. This is how
html! accepts <div> tags and
the declarative quote! accepts
#interpolation — syntax the Rust grammar has never heard of.
To parse custom syntax you implement Parse
for your own types. A Parse impl is handed a
ParseStream — a cursor over the
remaining tokens — and pulls items off it one at a time, in the order your grammar dictates.
Why not
darlinghere? In chapter 4 you useddarlingto parse a derive’s attributes, and in chapter 6 you’ll use it again for an attribute macro’s arguments. It doesn’t apply to the macros in this chapter, though:darlingonly understands the attribute meta-list grammar (key = value, flags), but a function-like macro receives arbitrary tokens. For custom syntax like=>, custom keywords, or anything that isn’t akey = valuelist, you write aParseimpl by hand — that’s what the rest of this chapter does. The one exception: if your macro’s input is just akey = valuelist, you can reuse theFromMetaapproach from chapter 6 verbatim.
A worked example: hashmap!
Let’s parse a small DSL: a map literal whose entries are written key => value.
#![allow(unused)]
fn main() {
let m = hashmap! {
"one" => 1,
"two" => 1 + 1,
};
}
First, a type for a single entry, with a hand-written Parse impl that spells out the grammar
“an expression, then =>, then another expression”. Both the key and the value are parsed as
Expr — not just string literals — so any expression works on either side, exactly like the
keys and values a real HashMap
accepts:
#![allow(unused)]
fn main() {
use syn::{
Expr, Token,
parse::{Parse, ParseStream},
punctuated::Punctuated,
};
struct Entry {
key: Expr,
value: Expr,
}
impl Parse for Entry {
fn parse(input: ParseStream) -> syn::Result<Self> {
let key: Expr = input.parse()?; // parse the key expression
input.parse::<Token![=>]>()?; // expect and consume `=>`
let value: Expr = input.parse()?; // parse the value expression
Ok(Entry { key, value })
}
}
}
input.parse::<T>() parses one T and advances the cursor; Token![=>] names the => token
type. If the => isn’t there, parse returns an Err and syn turns it into a compiler error
pointing at the offending token — you get good diagnostics for free.
The whole macro is then a comma-separated list of Entry, parsed with the same
parse_terminated you saw for min! (it works for any T: Parse, not just Expr):
#![allow(unused)]
fn main() {
struct Map {
entries: Punctuated<Entry, Token![,]>,
}
impl Parse for Map {
fn parse(input: ParseStream) -> syn::Result<Self> {
Ok(Map { entries: Punctuated::parse_terminated(input)? })
}
}
#[proc_macro]
pub fn hashmap(input: TokenStream) -> TokenStream {
let Map { entries } = parse_macro_input!(input as Map);
let inserts = entries.iter().map(|Entry { key, value }| {
quote! { __map.insert(#key, #value); }
});
quote! {
{
let mut __map = ::std::collections::HashMap::new();
#( #inserts )*
__map
}
}
.into()
}
}
Custom keywords
Entry reused Rust’s own Expr and =>. Often your DSL needs words that look like keywords but
aren’t real ones — asc, desc, state, on. syn won’t recognise them out of the box, so you
declare them with custom_keyword!,
conventionally inside a mod kw. Say you’re parsing a sort direction:
#![allow(unused)]
fn main() {
mod kw {
syn::custom_keyword!(asc);
syn::custom_keyword!(desc);
}
}
Each call generates a token type (kw::asc) you can parse and, crucially, peek. Peeking lets
you look at the next token without consuming it, so you can branch on what comes next:
#![allow(unused)]
fn main() {
// inside a `Parse` impl, deciding which keyword (if any) comes next:
let direction = if input.peek(kw::asc) {
input.parse::<kw::asc>()?; // consume it now that we know it's there
"ASC"
} else if input.peek(kw::desc) {
input.parse::<kw::desc>()?;
"DESC"
} else {
// Nothing we recognise — produce a clear error at the current position.
return Err(input.error("expected `asc` or `desc`"));
};
}
input.error(msg) builds a syn::Error spanned at the cursor; returning it makes misuse a
compile error that points exactly where parsing got stuck. This is the parsing-side equivalent of
the error reporting you did with syn::Error in chapter 4.
Exercise
The exercise for this section is located in 05_function_like/02_custom_syntax
Build a methods! macro for declaring HTTP endpoints:
#![allow(unused)]
fn main() {
let routes = methods! {
get "/",
post "/users",
};
// -> vec![("GET", "/"), ("POST", "/users")]
}
Each entry starts with a custom keyword — get or post — followed by a string-literal path. The
macro expands to a Vec<(&'static str, &'static str)> of (method, path) pairs. The list
parsing, the keyword declarations, and the code generation are written for you; your job is the
Parse impl for a single entry: peek for get/post, consume the right keyword, parse the path,
and reject anything else with a helpful error. The tests/fail case checks that a bad method is a
clean compile error, so don’t forget the else branch.
When to use function-like over declarative macros
There are two ways to write a macro that’s invoked like foo!( ... ):
- a declarative macro, written with
macro_rules!, and - a procedural function-like macro, the
#[proc_macro]kind you’ve been building.
They’re called identically, so from the caller’s side you often can’t tell which one you’re using —
vec! is declarative, sqlx::query! is
procedural, both look the same. The question for you, the
author, is which to write. Procedural macros are far more powerful, but that power isn’t free, so
the honest default is: reach for macro_rules! first, and only escalate when it can’t do the
job.
What macro_rules! is good at
macro_rules! is a pattern-matching templating engine. It matches the input against
fragment specifiers — $e:expr, $t:ty, $i:ident, $($xs:expr),* for repetition — and
expands a fixed template. For anything that’s “take some Rust fragments and stamp out a fixed shape
of code,” it’s the right tool, and it needs no extra crate, no syn, no compile step of its own:
#![allow(unused)]
fn main() {
macro_rules! my_vec {
($($x:expr),* $(,)?) => {{
let mut v = Vec::new();
$( v.push($x); )*
v
}};
}
}
That’s the whole comma-separated-list family — vec!, min!, hashset! — expressions in, a
template out. If macro_rules! can express it, prefer it.
What only a procedural macro can do
You need #[proc_macro] when the input or the work exceeds simple templating:
- Syntax that isn’t Rust fragments.
macro_rules!can only match things that fit a fragment specifier. The moment your DSL contains tokens that aren’t valid Rust expressions/types/etc. —<div>inhtml!,GET "/" => handlerin a router —macro_rules!can’t describe it, but a hand-writtenParseimpl can read any token at all. - Inspecting the input as data. Counting arguments and emitting the count, validating a format
or SQL string against a real schema, deriving identifiers from the input — that’s arbitrary Rust
code running at compile time.
macro_rules!only rearranges tokens; it can’t compute over them. - Real diagnostics. A procedural macro can emit a
syn::Errorspanned at the exact offending token (you did this in chapter 4).macro_rules!errors are notoriously hard to aim.
The cost is real: a separate proc-macro crate, a parser to write and maintain, and slower
compiles. So the rule of thumb is a ladder — climb only as high as you have to:
- A plain function if there’s no code generation at all.
macro_rules!if it’s token templating.- A procedural macro only when you genuinely need to parse custom syntax or compute over the input.
Exercise
The exercise for this section is located in 05_function_like/03_when_to_use
No macro to write this time — just judgement calls. For each scenario in the exercise, decide
whether a procedural function-like macro is warranted, or whether macro_rules! (or even a
plain function) would do. The test messages explain the reasoning for each.
Building a simple DSL: routes!
Time to put the chapter together. You’ll build a routes! macro — a tiny domain-specific language
for declaring HTTP routes, the kind of thing web frameworks expose:
#![allow(unused)]
fn main() {
let router = routes! {
GET "/" => index,
GET "/about" => about,
POST "/users" => create_user,
};
assert_eq!(router("GET", "/about"), Some("about us".to_string()));
assert_eq!(router("DELETE", "/"), None);
}
Each line is METHOD "path" => handler, where handler is an ordinary fn() -> String. The macro
expands to a closure that takes a method and path and dispatches to the first matching
handler — or returns None if nothing matches:
#![allow(unused)]
fn main() {
|method: &str, path: &str| -> Option<String> {
if method == "GET" && path == "/" { return Some(index()); }
if method == "GET" && path == "/about" { return Some(about()); }
if method == "POST" && path == "/users" { return Some(create_user()); }
None
}
}
This exercises everything from the chapter: a custom grammar (02), a hand-written Parse impl
over a Punctuated list (01–02), and quote! code generation with absolute paths
(chapter 4).
The grammar
A route has four parts read in order — a method identifier, a path literal, the => arrow, and a
handler identifier — so it gets a struct and a Parse impl:
#![allow(unused)]
fn main() {
struct Route {
method: Ident,
path: LitStr,
handler: Ident,
}
}
The Parse impl pulls those four pieces off the ParseStream one at a time, in grammar order —
the same technique as section 02’s Entry.
The method is a plain Ident rather than a custom keyword — GET,
POST, PUT and friends are all accepted uniformly, and the macro just stringifies whatever
identifier you wrote.
Generating the dispatcher
With the routes parsed, the output is one if arm per route wrapped in a closure. For each
Route you compare the runtime method/path against the route’s own — the method as a string
(route.method.to_string()), the path as its literal — and on a match, call the handler and return
Some(...). The arms are spliced into the closure body with quote!’s #( ... )* repetition, and
everything is qualified with absolute paths so the expansion compiles in any module:
#![allow(unused)]
fn main() {
quote! {
|method: &str, path: &str| -> ::core::option::Option<::std::string::String> {
#( #arms )*
::core::option::Option::None
}
}
}
Exercise
The exercise for this section is located in 05_function_like/04_routes
This is the chapter’s capstone, so you write both halves. First the parser: fill in the
Parse impl for a single Route, reading the method, path, =>, and handler off the stream in
order (the Routes wrapper around the Punctuated list is provided). Then the generator: turn
the parsed routes into the matching closure shown above — build one match arm per route by mapping
over the list, splice the arms into the closure body, and fall through to None. Reach for the
absolute paths you learned in chapter 4 so the generated code never collides with names in the
caller’s scope. tests/fail/missing_arrow.rs checks your parser rejects a missing =>, and
examples/router.rs checks the whole macro — make them both pass.
Going further. Real routers do more: matching path parameters like
/users/:idand handing them to the handler, supporting a fallback/404 handler, or grouping routes under a shared prefix. Each is a natural extension of the parser and the generator you just wrote — good challenges if you want to keep going.
Attribute macros
You’ve built derive macros (which add code to a type) and function-like macros (which turn a
blob of tokens into code). The third and final kind is the attribute macro — the #[...] you
write in front of an item to transform it.
You use them all the time:
#[test]
fn it_works() { /* ... */ }
#[tokio::main]
async fn main() { /* ... */ }
#[tracing::instrument]
fn handle(req: Request) { /* ... */ }
#[tokio::main] and
#[tracing::instrument] are
attribute macros. Unlike a derive, which leaves the original item untouched and only appends to it,
an attribute macro receives the item and returns its replacement — so it can rewrite the body,
change the signature, wrap it, or emit extra items alongside it.
#[test]uses the same#[...]syntax and feels identical, but it’s built into the compiler’s test harness rather than being a procedural macro — much as#[derive(Debug)]is built in (chapter 2). Third-party attributes like#[tokio::main]are procedural macros, and work exactly as described here.
Two token streams in
The registering attribute is #[proc_macro_attribute], and the function it marks takes two
TokenStreams, not one:
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
#[proc_macro_attribute]
pub fn my_attr(attr: TokenStream, item: TokenStream) -> TokenStream {
// `attr` = the tokens *inside* the attribute's parentheses
// `item` = the annotated item itself
item
}
}
For an invocation like
#![allow(unused)]
fn main() {
#[my_attr(every = "other", thing = 3)]
fn target() {}
}
attrisevery = "other", thing = 3— just the arguments, without the surrounding#[...].itemisfn target() {}— the whole function.
The macro above is the identity attribute: it returns item unchanged, so target compiles
exactly as written. That’s the smallest possible attribute macro, and a useful baseline — a real
one parses item (usually into a syn::ItemFn),
modifies it, and returns the modified version.
Tip: see what you’re given. When you’re unsure what’s in
attroritem, addeprintln!("{item}")(aTokenStreamimplementsDisplay) at the top of your macro and read it duringcargo build. This shows the macro’s input; to inspect its output, usecargo expandas you did in chapter 2. Theeprintln!trick still works even when the macro panics or produces invalid tokens — handy whencargo expandcan’t render the result.
Returning the modified item
Because the return value replaces the item, the usual shape is: parse, tweak, re-emit. Here’s a
minimal transforming example — #[auto_inline], which tags a function with #[inline]:
#![allow(unused)]
fn main() {
use quote::quote;
use syn::{ItemFn, parse_macro_input, parse_quote};
#[proc_macro_attribute]
pub fn auto_inline(_attr: TokenStream, item: TokenStream) -> TokenStream {
let mut func = parse_macro_input!(item as ItemFn);
func.attrs.push(parse_quote!(#[inline])); // add an attribute to the function
quote!(#func).into() // re-emit the whole function
}
}
parse_macro_input!(item as ItemFn) parses the function; func is now a mutable syntax tree you
can edit (func.attrs, func.sig, func.vis, func.block, …); and quote!(#func) turns it back
into tokens — ItemFn implements ToTokens, so it round-trips cleanly.
quote!, like any macro, accepts(),[], or{}delimiters. We’ve usedquote! { ... }so far; the parenthesizedquote!(#func)just reads nicely when you’re re-emitting a single item.
Exercise
The exercise for this section is located in 06_attribute/00_overview
Write #[make_public]: an attribute macro that takes a function and re-emits it with pub
visibility. It’s the smallest useful transform — parse the ItemFn, overwrite its visibility, and
return it — and it gets you comfortable with the parse-tweak-re-emit loop before the bigger
transforms in the rest of the chapter.
Transforming a function
The last section tweaked a function’s attributes and visibility. The real workhorse of
attribute macros is rewriting the body — wrapping the original code so something extra happens
around it. That’s how #[tracing::instrument] adds a span, how a #[test]-style harness reports
results, and how the #[retry] macro you’ll build at the end of the chapter works.
A worked example: #[timed]
Let’s wrap a function so it prints how long it took to run. From the caller’s side, you just slap the attribute on a function and use it as normal:
#[timed]
fn slow_add(a: u64, b: u64) -> u64 {
std::thread::sleep(std::time::Duration::from_millis(10));
a + b
}
fn main() {
let sum = slow_add(1, 2);
// prints something like: `slow_add took 10.1ms` to stderr
assert_eq!(sum, 3);
}
The function keeps its signature, arguments, and return value — #[timed] only adds the timing
message around the original body. Here’s the macro that makes it work:
#![allow(unused)]
fn main() {
use quote::quote;
use syn::{ItemFn, parse_macro_input, parse_quote};
#[proc_macro_attribute]
pub fn timed(_attr: TokenStream, item: TokenStream) -> TokenStream {
let mut func = parse_macro_input!(item as ItemFn);
let name = func.sig.ident.to_string(); // the function's name, for the message
let block = &func.block; // the original body: `{ ... }`
// A new body that runs the old one, then reports the elapsed time.
let new_body: syn::Block = parse_quote! {{
let __start = ::std::time::Instant::now();
let __result = #block;
::std::eprintln!("{} took {:?}", #name, __start.elapsed());
__result
}};
func.block = ::std::boxed::Box::new(new_body);
quote!(#func).into()
}
}
The key moves:
- Grab the original body.
func.blockis asyn::Block— the braces and everything inside them. - Build a replacement body.
parse_quote! {{ ... }}parses tokens into asyn::Block(the outer braces are the macro’s{{/}}delimiter; the inner braces are the block itself). Interpolating#blocksplices the original body in with its braces, solet __result = #block;runs it as a block expression and captures its value. - Preserve the result. The wrapper stashes the body’s value in
__result, does its extra work, then yields__resultas the block’s final expression — so the function still returns what it always did. The signature is untouched; only the body changed. - Put it back.
func.blockwants aBox<syn::Block>, thenquote!(#func)re-emits the whole function.
parse_quote!vsquote!. Both expand the same interpolating syntax, but they return different things.quote!produces aTokenStream— a flat sequence of tokens with no guarantee they form anything valid.parse_quote!expands those tokens and parses them into a concretesyntype inferred from the context — here, thelet new_body: syn::Blockannotation makes it produce asyn::Block, exactly whatfunc.blockneeds. Soparse_quote!is essentiallyquote!followed bysyn::parse2. Reach forquote!when you’re building the macro’s finalTokenStreamoutput (likequote!(#func)above), and forparse_quote!when you need an intermediate AST node to splice into an existing structure. One caveat:parse_quote!panics if the tokens don’t parse, which is fine for hardcoded snippets like this one but not for tokens built from untrusted user input — there, usesyn::parse2so you can return a proper compile error instead.
A note on
return. Because the original body becomes a block expression — as in#[timed]above — an earlyreturninside it returns from the whole function and skips the wrapper’s trailing code. For timing that’s usually fine; when it isn’t (as with#[retry]later), the trick is to run the body inside a closure instead, soreturnexits the closure rather than the function.
Exercise
The exercise for this section is located in 06_attribute/01_transform
Apply the same wrap-the-body technique to transform the result. Write #[trimmed]: it takes a
String-returning function and rewrites the body so the returned string is trimmed of surrounding
whitespace. Capture the original body’s value, post-process it, and return the new value — leaving
the function’s signature and arguments exactly as they were. Make examples/trim.rs pass.
Graceful error handling: re-emitting the original item
Attribute macros have an error-reporting hazard that derive macros don’t.
A derive macro only ever adds code, so if it bails out the original type is still there. An attribute macro replaces the item it annotates — whatever you return becomes the item. That makes the error path easy to get wrong, in two ways that both produce baffling diagnostics:
- Panicking. A
panic!, anunwrap, or a straytodo!()in your macro surfaces aserror: custom attribute panickedwith your message but no span pointing at the user’s code. Never report errors by panicking. - Dropping the item silently. If a branch returns empty or partial tokens without a
diagnostic, the annotated function simply vanishes — and the user gets
error[E0425]: cannot find function 'greet'at every call site, with nothing explaining why it’s missing.
The fix
On the error path, do two things: produce a real compile_error!, and re-emit the original item
next to it.
#![allow(unused)]
fn main() {
#[proc_macro_attribute]
pub fn no_args(_attr: TokenStream, item: TokenStream) -> TokenStream {
let func = parse_macro_input!(item as ItemFn);
if !func.sig.inputs.is_empty() {
let error = syn::Error::new_spanned(&func.sig.ident, "expected no arguments")
.to_compile_error();
return quote! { #error #func }.into(); // diagnostic + the untouched item
}
quote!(#func).into()
}
}
compile_error! and a normal item coexist happily: the compile_error! produces a clean, spanned
diagnostic, and the re-emitted #func keeps the function present. Two things are worth knowing
about why this is the right habit:
- The
compile_error!is what tames the cascade. Modern rustc, once it sees yourcompile_error!, suppresses the follow-on “cannot find …” errors — so even returning only the error reads cleanly in a batch build. The genuinely bad version is the silent drop above, which emits nocompile_error!at all and leaves the user with an unexplained “cannot find function”. - Re-emitting the item is still best practice. It keeps the function intact for
rust-analyzerand incremental tooling — which want to see the item even while it’s erroneous — and it’s robust in the cases the cascade isn’t suppressed.quote! { #error #item }is the idiom to memorize: error first, original item second.
This is the attribute-macro counterpart to the spanned syn::Error work from chapter 4 — same
syn::Error, but now you’re careful to keep the item too.
Exercise
The exercise for this section is located in 06_attribute/02_graceful_errors
Write #[describe]. On a function that takes no arguments, it re-emits the function and adds a
companion fn describe_<name>() -> &'static str returning the function’s name. On a function that
does take arguments, it must report the error gracefully — turning the syn::Error into a
compile_error! (never a panic) and re-emitting the original function alongside it.
The companion-generation and validation are written for you; your job is the error branch. The
tests/fail snapshot checks that misuse produces your clean, spanned diagnostic rather than the
todo!() panic — error: custom attribute panicked — that the stub starts with.
Parsing attribute arguments with darling’s FromMeta
So far the attr token stream has been empty — #[make_public], #[trimmed], #[describe] take
no arguments. Now we’ll use it. Configurable attribute macros read settings from inside the
parentheses:
#![allow(unused)]
fn main() {
#[retry(times = 3, delay_ms = 100)]
#[route(method = "POST", path = "/users")]
#[serde(rename = "id", default)]
}
Everything between the parentheses arrives as the first TokenStream. You could parse it by
hand with a syn Parse impl, but you already met the better tool in chapter 4:
darling. There you used FromDeriveInput to read a derive’s
attributes; for an attribute macro’s arguments the trait is
FromMeta, and it works the same
declarative way — describe the arguments as a struct, let darling generate the parser and the
error messages.
A worked example: #[greeting(...)]
#![allow(unused)]
fn main() {
use darling::{FromMeta, ast::NestedMeta};
use quote::quote;
use syn::{ItemFn, parse_macro_input};
#[derive(FromMeta)]
struct GreetingArgs {
message: String,
#[darling(default)] // optional; `bool::default()` is `false`
loud: bool,
}
#[proc_macro_attribute]
pub fn greeting(attr: TokenStream, item: TokenStream) -> TokenStream {
let func = parse_macro_input!(item as ItemFn);
// 1. Split the attribute tokens into a list of nested-meta items.
let meta = match NestedMeta::parse_meta_list(attr.into()) {
Ok(meta) => meta,
Err(err) => return darling::Error::from(err).write_errors().into(),
};
// 2. Parse our schema out of that list.
let args = match GreetingArgs::from_list(&meta) {
Ok(args) => args,
Err(err) => return err.write_errors().into(),
};
// ... use `args.message` (a String) and `args.loud` (a bool) to generate code ...
quote!(#func).into()
}
}
Three things to note:
NestedMeta::parse_meta_listturns the raw attribute tokens into theVec<NestedMeta>thatFromMetaconsumes. Theattrparameter is aproc_macro::TokenStream, soattr.into()converts it to theproc_macro2::TokenStreamthatparse_meta_listexpects. It can fail on malformed input — and it fails with asyn::Error, so we wrap it withdarling::Error::from(..)to get a uniform error type.from_listis theFromMetaentry point, mirroringfrom_derive_inputfrom chapter 4. It reports unknown keys, missing required fields, and wrong value types for you.write_errors()renders adarling::Errorascompile_error!tokens. Returning that is your error path — nopanic!, a real diagnostic pointing at the bad argument.
Required vs optional works exactly as it did with FromDeriveInput: a bare field is required, and
#[darling(default)] (or #[darling(default = "some_fn")] for a custom default) makes it optional.
Exercise
The exercise for this section is located in 06_attribute/03_darling_args
Write #[endpoint(path = "...", method = "...")]. It re-emits the annotated function and generates
<name>_path() and <name>_method() accessors returning the parsed values. path is required;
method is optional and defaults to "GET".
The code generation is provided — your job is the argument parsing: define the FromMeta struct
(with the right required/optional fields) and wire up the two-step parse. The pass test omits
method on one endpoint, so you’ll need the default to make it compile; the tests/fail snapshot
checks that a missing path produces a clean darling error.
Building a #[retry] attribute
Time to combine the whole chapter into one real-world macro: #[retry], which wraps a fallible
function so it automatically tries again when it fails.
#![allow(unused)]
fn main() {
#[retry(times = 3, delay_ms = 100)]
fn fetch(url: &str) -> Result<String, HttpError> {
// ... a flaky network call ...
}
}
fetch now runs up to three times, pausing 100ms between attempts, and only surfaces an Err if
every attempt failed. The caller’s code doesn’t change at all — the signature is identical; only
the body is rewritten.
This pulls together everything you’ve built:
- Attribute arguments (
times,delay_ms) parsed withdarling’sFromMeta(section 03). - Body transformation — replace the function’s block while keeping its signature (section 01).
- Absolute paths in the generated code, so it compiles in any module (chapter 4).
What the macro generates
For the fetch above, #[retry] rewrites the body to roughly:
#![allow(unused)]
fn main() {
fn fetch(url: &str) -> Result<String, HttpError> {
let mut attempt: u32 = 0;
loop {
attempt += 1;
let result = (|| -> Result<String, HttpError> {
// ... the original body ...
})();
match result {
Ok(value) => return Ok(value),
Err(err) => {
if attempt >= 3 {
return Err(err);
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
}
}
}
(Shown with plain paths and concrete values for readability — the real expansion uses times and
delay_ms from the parsed arguments, and absolute paths like ::core::result::Result and
::std::thread::sleep, for the hygiene reasons from chapter 4.)
The one subtle part: the closure
Why wrap the original body in (|| { ... })() instead of running it directly?
Because the body is fallible code, it almost certainly uses ? and return. If you spliced it
straight into the loop, a ? on the first failed attempt would return the Err out of fetch
entirely — defeating the whole point of retrying. Running the body inside a closure that returns
the function’s own type contains that: ? and return exit the closure, handing you a
Result you can inspect and decide whether to retry.
You can build that closure’s return type straight from the parsed function: func.sig.output is the
-> Result<...> token sequence, so (|| #output #block)() produces
(|| -> Result<...> { <body> })(). (This is exactly the “run the body in a closure” escape hatch
the timing section hinted at.)
The rest is a plain loop: count attempts, return on Ok, and on Err either give up (last attempt)
or sleep and go round again.
Exercise
The exercise for this section is located in 06_attribute/04_retry
Finish #[retry]. The argument parsing (times, delay_ms via FromMeta) and the function
parsing are written for you; your job is the heart of the macro — generating the retry loop and
installing it as the new function body.
Build a syn::Block with parse_quote! that wraps the original body in the closure shown above,
loops up to times times, returns on success, and sleeps delay_ms between failed attempts before
giving up with the last error. Reach for absolute paths (::core::result::Result, ::std::thread,
::std::time::Duration) so the generated code never depends on what’s in scope at the call site.
Make examples/retrying.rs pass.
Going further. Real retry helpers add exponential backoff (doubling the delay each time), a jitter factor, or a predicate that only retries certain errors. Each is a small extension of the loop you just generated — and a good way to cement the technique.
Putting it all together
You’ve now built all three kinds of procedural macro: a #[derive(Builder)] (chapter 4), a
routes! DSL (chapter 5), and a #[retry] attribute (chapter 6). Each chapter ended with a
capstone that combined that chapter’s techniques. This final chapter combines the techniques from
across the whole workshop into one macro: #[derive(StateMachine)].
A state machine is a perfect fit for a derive macro. The states are the variants of an enum, the allowed transitions are written as attributes, and the macro turns that declaration into the methods that enforce the rules at runtime:
#![allow(unused)]
fn main() {
#[derive(StateMachine)]
enum TrafficLight {
#[initial]
#[transition(Green)]
Red,
#[transition(Yellow)]
Green,
#[transition(Red)]
Yellow,
}
}
That declaration reads like a specification — start at Red; Red may become Green; Green may
become Yellow; Yellow may become Red — and the macro makes it executable:
#![allow(unused)]
fn main() {
let light = TrafficLight::initial(); // Red
assert!(light.can_transition_to(&TrafficLight::Green));
let next = light.transition_to(TrafficLight::Green).unwrap(); // Green
let bad = next.transition_to(TrafficLight::Red); // Err: not allowed
}
What the macro generates
For TrafficLight, #[derive(StateMachine)] emits roughly this (shown with bare paths for
readability — the real output uses absolute paths, as the bullets below explain):
#![allow(unused)]
fn main() {
#[derive(Debug)]
pub struct TrafficLightInvalidTransition {
from: &'static str,
to: &'static str,
}
// + Display / Error impls, so it reads as "invalid transition from Red to Yellow"
impl TrafficLight {
pub fn initial() -> Self {
TrafficLight::Red
}
pub fn name(&self) -> &'static str {
match self {
TrafficLight::Red => "Red",
TrafficLight::Green => "Green",
TrafficLight::Yellow => "Yellow",
}
}
pub fn can_transition_to(&self, target: &Self) -> bool {
match (self, target) {
(TrafficLight::Red, TrafficLight::Green) => true,
(TrafficLight::Green, TrafficLight::Yellow) => true,
(TrafficLight::Yellow, TrafficLight::Red) => true,
_ => false,
}
}
pub fn transition_to(self, target: Self) -> Result<Self, TrafficLightInvalidTransition> {
if self.can_transition_to(&target) {
Ok(target)
} else {
Err(TrafficLightInvalidTransition { from: self.name(), to: target.name() })
}
}
}
}
This one macro draws on nearly everything you’ve learned:
- Per-variant code generation — iterate the enum’s variants and emit a
name()arm for each and acan_transition_toarm for each declared edge (chapter 4, Handling enums). - Custom helper attributes —
#[initial]and#[transition(..)]are declared with#[proc_macro_derive(StateMachine, attributes(initial, transition))]and read off each variant (chapter 4, Container / field attributes). - Error reporting — a
#[transition(Purple)]that names a state which doesn’t exist is rejected at compile time, with the error spanned onPurpleitself; so is a missing or duplicated#[initial], a non-enum, or a variant that carries data (chapter 4,syn::ErrorandSpan). - A generated identifier — the per-type
TrafficLightInvalidTransitionis built withformat_ident!, so two derives in the same module never clash (chapter 4, Preventing name clashes), and the generated impls use absolute paths like::core::result::Resultand::core::fmt::Displayso they compile in any context (chapter 4, Using absolute paths).
The shape of the solution
The work splits cleanly into two halves — understand the input, then emit the output — and the skeleton you’re given is organised that way:
parse_state_machinevalidates theDeriveInputand distils it into a plainstruct StateMachine { name, variants, initial, transitions }. All the rules live here: it must be an enum, every variant must be a unit variant, exactly one must be#[initial], and every#[transition(..)]target must be a real state.generatetakes that validatedStateMachineand produces theTokenStream— it never has to worry about bad input, because step 1 already rejected it.
Splitting “validate” from “generate” like this is how most non-trivial macros are structured: by the
time you’re calling quote!, every value you splice in is known to be good.
A couple of things worth noticing
match (self, target). can_transition_to matches on the pair of states, with one arm per
declared edge and a _ => false catch-all. Because the states are unit variants, the patterns are
just (Enum::A, Enum::B) — no payload to bind — and you never need the enum to implement
PartialEq.
Validate against the variant list. To reject #[transition(Purple)], the macro first collects
every variant name, then checks each transition target against that list. That ordering matters:
you can’t know whether Purple is valid until you’ve seen all the states. It’s the same
“syntactic, not semantic” check you met with the builder’s Option<T> detection — the macro only
ever compares identifiers, never resolved types.
Exercise
The exercise for this section is located in 07_capstone/00_overview
This is the capstone, so you write the macro itself — not just one corner of it. Only the boilerplate
is provided: the #[proc_macro_derive] entry point, the StateMachine struct that the two halves
pass between them, and the one-line glue that wires them together. Both halves are todo!():
parse_state_machine— validate theDeriveInputand distil it into aStateMachine. Every broken rule is a spannedsyn::Error: it must be an enum, every variant must be a unit variant, exactly one must be#[initial], and every#[transition(..)]target must name a real state. You read the#[transition(..)]arguments withparse_args_withand aPunctuatedlist, exactly as in chapters 4–5.generate— turn that validatedStateMachineinto theTokenStreamshown in What the macro generates above: the per-type..InvalidTransitionerror (named withformat_ident!), itsDisplay/Errorimpls written with absolute paths, and the four methods, their match arms built by iterating the variants and edges.
The inline TODOs spell out each rule and which API to reach for. Make examples/traffic_light.rs
pass and every file in tests/fail/ produce its expected compiler error.
Going further. A state machine is fertile ground for extensions, each a small reuse of a technique from the workshop: emit a
transitions()method listing every reachable state; reject a machine where some state is unreachable from#[initial]; add an#[on_enter]hook; or design a richer DSL —state_machine! { Red -> Green -> Yellow -> Red }as a function-like macro (chapter 5) instead of a derive. With all three macro kinds behind you, you have the tools for any of them. Congratulations — you’ve finished the workshop.