Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

The Rust Reference

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.

The Rust Reference

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(),
        }
    }
}
}

Debug and Clone are actually built into the compiler, not implemented as procedural macros — but they use the same #[derive(...)] syntax. Third-party derive macros like serde::Serialize or thiserror::Error are 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! or println! — 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::Debug instead of Debug). This is a deliberate choice: generated code must not break if the user has a different item named Debug in 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 the Display message for each variant.
  • #[from] can be added to a variant field to automatically generate a From implementation. It also implies #[source], so you don’t need both on the same field.
  • #[source] marks a field as the error source for the Error::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.


  1. 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 = true in its Cargo.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

DeclarativeProcedural
Simple token substitutionYesOverkill
Variadic argumentsYesYes
Inspecting struct fieldsNoYes
Custom attributesNoYes
Derive implementationsNoYes
Needs a separate crateNoYes

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.


  1. 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, “Declarative macro_rules! derive macros” lets you write a derive in macro_rules! — inspecting a type’s fields and generating an impl without 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-macro2 makes your macro code testable
  • How syn parses Rust code into a structured representation
  • How quote lets 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 impl block, 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 type
  • generics — any generic parameters
  • data — the body (struct fields or enum variants)
  • attrs — attributes on the type
  • vis — 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_macro types 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 of DeriveInput (this is on by default)
  • parsing — enables the parsing infrastructure
  • printing — enables converting syn types 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 over field_idents, producing each element separated by commas (,). The * means “zero or more times.”
  • stringify!(#field_idents) — the body repeated for each element. #field_idents is replaced with the current Ident on 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:

  • serde re-exports macros from serde_derive.
  • thiserror re-exports macros from thiserror-impl.
  • clap re-exports macros from clap_derive.
  • tokio re-exports macros from tokio-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:

  1. Add the re-export-exercise-macros crate as a path dependency in Cargo.toml.
  2. Re-export the Describe derive macro with pub use in src/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 .stderr file.

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: pass tests compile and run the generated code, so a typo like feild_names fails the test instead of slipping through.
  • It closes blind spot 2: compile_fail tests snapshot the exact diagnostic the user sees. Once you start improving error messages in chapter 4, the .stderr file is what proves those messages are actually helpful — and it catches regressions if a syn::Error span later changes.
  • It complements proc-macro2 unit tests rather than replacing them: unit tests verify your logic fast and in isolation, trybuild verifies 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 darling crate.
  • Generics — making a derive work for struct Wrapper<T> { .. }, lifetimes and where clauses 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_all accepts 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.named is a list of Fields, each with an ident (Some) and a ty.
  • Unnamed (tuple struct) — fields.unnamed is a list of Fields, each with ident == None and a ty. 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 — a syn::Fields, exactly the same enum you met for structs
  • discriminant — the = 1 part of enum 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 nameAbsolute 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 core items are also re-exported from std, so ::std::option::Option works too. Using ::core lets 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:

  1. Colliding with the user. You emit const BUF_SIZE: usize = 1024; at module scope; the user already has a BUF_SIZE. Now their crate doesn’t compile.

  2. 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, so Cat’s and Dog’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 an impl:

    #![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:

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::Error can also accumulate multiple errors: build several and combine them, 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, ..) or new_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 to Ident::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 with new_spanned instead.

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 with TRYBUILD=overwrite, then git diff to 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.path is 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(..), a syn::Error already 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_meta also handles flags (#[greeting(loud)], where meta.path is 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 a let mut prefix declared before the loop and captured by the closure — prefix = …, not let prefix = …. A let would 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] — check attr.path().is_ident(..)
  • name-value — #[label = "x"] — read it with attr.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 = value list — #[label(a = 1, b = 2)] — attr.parse_nested_meta(..)

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, vis are recognised by name and filled from the input — ident is the type name, data is the body.
  • ast::Data<V, F> captures the body generically: V is the per-variant type (here Ignored, since we only support structs), F is the per-field type. Later you call .take_struct() to get the fields back as FieldOpts values.
  • supports(struct_named) rejects enums, tuple structs, and unit structs for you, with a proper error — no hand-written match on Data.
  • Optional options. An Option<T> field (like rename) is optional automatically — it defaults to None with no annotation. For a non-Option field you opt in with #[darling(default)] (falls back to the type’s Default) or #[darling(default = "...")] (calls a named function). table uses the latter: #[darling(default = "default_table")] calls default_table(), which returns "items", when the user omits #[model(table = "...")]. Unknown keys and type mismatches become precise darling::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 like quote!, but it parses the tokens into a syn type — here a TypeParamBound. It’s the easiest way to build small syn values 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 bounds adds to any bound the user already wrote (T: Clone becomes T: Clone + Debug) rather than replacing it.

This blanket “add Debug to 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 build expression for every field (chapter sections on struct fields).
  • Type inspection — fields that are already Option<T> are optional: their setter takes the inner T, and build() doesn’t require them. Detecting that means looking inside the syn::Type.
  • Error reporting — build() returns a Result and names the first missing required field.
  • Robust output — absolute paths (::core::option::Option, ::std::result::Result) and a derived ...Builder name via format_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:

  1. Each field’s build() expression — emit the value each field contributes inside build(): clone optional fields straight through, and error on unset required ones.
  2. 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 ...Builder struct, the builder() constructor, and the impl block carrying the setters and the fallible build().

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 a Vec field 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 write fn 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:

AttributeInvoked asInput 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] itemthe 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>). The as Type form needs a type that implements Parse. When you instead want to call a specific parser function, use the with form. Punctuated::parse_terminated parses a list with an optional trailing comma — so min!(a, b,) is accepted, just like Rust accepts trailing commas everywhere else. (Token![,] is syn’s way of naming the , token type; there’s a Token![...] for every bit of Rust punctuation and every keyword.)
  • #( ... )* over a runtime iterator. You met quote!’s repetition in chapter 3 when expanding struct fields. It works on anything iterable, including the rest iterator 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 darling here? In chapter 4 you used darling to 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: darling only 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 a key = value list, you write a Parse impl by hand — that’s what the rest of this chapter does. The one exception: if your macro’s input is just a key = value list, you can reuse the FromMeta approach 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> in html!, GET "/" => handler in a router — macro_rules! can’t describe it, but a hand-written Parse impl 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::Error spanned 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/:id and 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() {}
}
  • attr is every = "other", thing = 3 — just the arguments, without the surrounding #[...].
  • item is fn 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 attr or item, add eprintln!("{item}") (a TokenStream implements Display) at the top of your macro and read it during cargo build. This shows the macro’s input; to inspect its output, use cargo expand as you did in chapter 2. The eprintln! trick still works even when the macro panics or produces invalid tokens — handy when cargo expand can’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 used quote! { ... } so far; the parenthesized quote!(#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.block is a syn::Block — the braces and everything inside them.
  • Build a replacement body. parse_quote! {{ ... }} parses tokens into a syn::Block (the outer braces are the macro’s {{/}} delimiter; the inner braces are the block itself). Interpolating #block splices the original body in with its braces, so let __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 __result as 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.block wants a Box<syn::Block>, then quote!(#func) re-emits the whole function.

parse_quote! vs quote!. Both expand the same interpolating syntax, but they return different things. quote! produces a TokenStream — a flat sequence of tokens with no guarantee they form anything valid. parse_quote! expands those tokens and parses them into a concrete syn type inferred from the context — here, the let new_body: syn::Block annotation makes it produce a syn::Block, exactly what func.block needs. So parse_quote! is essentially quote! followed by syn::parse2. Reach for quote! when you’re building the macro’s final TokenStream output (like quote!(#func) above), and for parse_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, use syn::parse2 so 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 early return inside 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, so return exits 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!, an unwrap, or a stray todo!() in your macro surfaces as error: custom attribute panicked with 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 your compile_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 no compile_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-analyzer and 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_list turns the raw attribute tokens into the Vec<NestedMeta> that FromMeta consumes. The attr parameter is a proc_macro::TokenStream, so attr.into() converts it to the proc_macro2::TokenStream that parse_meta_list expects. It can fail on malformed input — and it fails with a syn::Error, so we wrap it with darling::Error::from(..) to get a uniform error type.
  • from_list is the FromMeta entry point, mirroring from_derive_input from chapter 4. It reports unknown keys, missing required fields, and wrong value types for you.
  • write_errors() renders a darling::Error as compile_error! tokens. Returning that is your error path — no panic!, 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 with darling’s FromMeta (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 a can_transition_to arm 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 on Purple itself; so is a missing or duplicated #[initial], a non-enum, or a variant that carries data (chapter 4, syn::Error and Span).
  • A generated identifier — the per-type TrafficLightInvalidTransition is built with format_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::Result and ::core::fmt::Display so 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:

  1. parse_state_machine validates the DeriveInput and distils it into a plain struct 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.
  2. generate takes that validated StateMachine and produces the TokenStream — 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!():

  1. parse_state_machine — validate the DeriveInput and distil it into a StateMachine. Every broken rule is a spanned syn::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 with parse_args_with and a Punctuated list, exactly as in chapters 4–5.
  2. generate — turn that validated StateMachine into the TokenStream shown in What the macro generates above: the per-type ..InvalidTransition error (named with format_ident!), its Display/Error impls 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.