Skip to content
← All articles

Debugging macros when expansion goes wrong

A seven-step procedure for macro errors: read the span, turn on meta_variable_misuse, stringify! what you captured, and force the compiler to print the expansion.

Macro errors are the worst debugging experience in Rust, and the reason is structural: the compiler is complaining about code that does not appear in your file. A span points at a + you never typed, inside an expansion you never saw, produced by a rule you have to reconstruct in your head.

This article is a procedure. Work through it in order and the experience becomes routine.

Step 0: read where the span points

Before anything else, notice which of three places the error is anchored to, because it tells you which stage failed.

At the macro definition. The matcher itself is illegal. Follow-set violations (18.12), transcriber repetitions that mention no metavariable, depth mismatches. Nothing has been invoked yet; this is a definition-time error and the fix is in the macro_rules! block.

At the call site, saying “no rules expected this token”. Every rule was tried and none matched. Usually a fragment specifier that does not claim what you thought it claimed (18.3), or a missing arm for a trailing comma.

Inside the expansion. The macro matched and produced code that does not compile or does not type-check. This is the disorienting case and the rest of the article is about it.

Diagnostics inside an expansion carry a footer that names the macro:

= note: this error originates in the macro `my_macro`

If you take nothing else from this article, take the habit of scrolling down to that line. It is the difference between “the compiler is angry about something in my file” and “the compiler is angry about hashmap!“.

Step 1: stringify! — see what you actually captured

The most common cause of a confusing expansion is that a metavariable does not hold what you assumed. stringify! turns any token stream into a string literal at compile time, so you can print it:

macro_rules! trace_it {
    ($e:expr) => {{
        println!("captured: {}", stringify!($e));
        $e
    }};
}
```

Two things worth knowing about it. It preserves the tokens but not the
whitespace: `stringify!(1+2)` and `stringify!(1 + 2)` both give `"1 + 2"`,
because it re-renders from the token stream. And it happily accepts things that
are not valid Rust at all, which makes it useful for inspecting a raw `tt` soup
mid-munch.

Dropping a `stringify!` into a recursive muncher's rules and printing at each
step is the closest thing to a debugger you have. Delete them afterwards.

## Step 2: make the compiler print the expansion for you

There is no expansion viewer here — `cargo expand` needs cargo, and
`-Z macro-backtrace`, `trace_macros!` and `log_syntax!` are all nightly-only. But
there is a reliable trick that works anywhere: **cause a type error on purpose**.

```rust
let _: () = my_macro!(whatever);
```

`()` is almost never the right type, so the compiler complains — and to explain
itself it prints the expression it found, which is the expansion. It is crude,
it truncates for large expansions, and it is by far the fastest way to see what
a macro produced when you have no tooling.

A variant for item-generating macros: give the generated type a deliberately
wrong use, like calling a method that does not exist on it. The "no method named
… found for struct …" message names the struct the macro built.

## Step 3: `meta_variable_misuse` — validate at definition time

This is the single most under-used tool in the macro system, and the one to reach
for before any of the above.

`meta_variable_misuse` is a rustc lint, **allow by default**, that checks
transcribers against their matchers *when the macro is defined*:

```rust
#[warn(meta_variable_misuse)]
macro_rules! table {
    ( $( $name:ident => $( $val:expr ),* ; )* ) => {
        $( ($name, $val) ),*      // <- $val used at depth 1, captured at depth 2
    };
}

Without the lint, nothing is said until somebody calls the macro, and then the error arrives at their call site. With it, you get told at the definition, in the file you are editing, before the macro has any users.

It catches depth mismatches, metavariables that were never captured, and metavariables captured but never used. Turn it on at the top of any module where you are writing macros:

#![warn(meta_variable_misuse)]

It is allow-by-default because it has false positives on some legitimate advanced patterns — which is a reason not to deny it, not a reason to leave it off while developing.

Step 4: find the dead arms

Two more allow-by-default lints earn their keep:

  • unused_macros — the whole macro is never invoked. Often means you misspelled the name at the call site, and the “cannot find macro” error you are staring at elsewhere is the other half of the same mistake.
  • unused_macro_rules — an individual arm is never reached. This is the ordering check. If you wrote a specific rule and it is reported unused, some earlier rule is more general than you thought (18.2, 18.8, 18.9).
#![warn(unused_macros, unused_macro_rules)]

unused_macro_rules is worth switching on every time you write a multi-arm macro and worth switching off again before you ship, since a public macro legitimately has arms this crate never uses.

Step 5: bisect the rules

When you have a macro with eight arms and no idea which one matched, comment out all but one and see whether the error changes from “expansion is wrong” to “no rules expected this token”. The arm that still matches is the arm that fired.

This sounds primitive because it is, but with first-match-wins semantics and no tracing available it is often the shortest path.

Step 6: shrink the invocation

The same discipline as any other bug. Halve the input, keep the half that still fails, repeat. Macro inputs bisect unusually well because a repetition with three elements fails the same way as one with thirty, and because a muncher’s O(n²) behaviour means the small case also compiles faster.

What is not available here

Say this plainly so you do not waste an hour looking:

  • cargo expand requires a cargo project. Outside this course it is the first tool to install (cargo install cargo-expand); inside it, use step 2.
  • trace_macros!(true) and log_syntax! are nightly-only.
  • -Z macro-backtrace, which turns the one-line “originates in the macro” note into a full expansion chain, is nightly-only.

The two that work everywhere — stringify! and #[warn(meta_variable_misuse)] — are also the two that catch the largest share of real mistakes. Learn those and the nightly tools become a nicety rather than a necessity.

A checklist to keep

  1. Where does the span point: definition, call site, or expansion?
  2. Scroll to the “originates in the macro” note.
  3. #[warn(meta_variable_misuse)] on the definition.
  4. stringify! the metavariables you are unsure of.
  5. let _: () = … to see the expansion.
  6. Comment out arms until you know which one matched.
  7. Shrink the invocation until it is minimal.

Most macro bugs fall out at step 3 or 4.