Skip to content
← All Rust articles

Rust explainer

Debugging macros when expansion goes wrong

About 6 min to read. Allow extra time to work through examples.

Locate a macro error, inspect captured tokens, check rule selection, and reduce the failing invocation.

This article uses Rust. You can make it your practice mode to browse related content across the site.

A macro error can point into the macro definition even when a particular call triggers it. Start with the diagnostic, then reduce the call until you can explain which rule matches and what that rule produces.

Read the message and its source locations

Check the highlighted code, the invocation location, and any note naming the originating macro. The location alone does not identify the failure stage.

  • A malformed matcher can fail when the macro is defined. For example, an expr fragment cannot be followed by every possible token.
  • A call can fail to match, encounter a parsing error, or be locally ambiguous. Check separators, optional trailing commas and fragment kinds.
  • A matched rule can fail during expansion, name resolution or type checking. Repetition-depth errors may appear only when a rule is invoked, even if the highlighted code is in its definition.

Read the first relevant diagnostic before chasing later errors. Notes such as “this error originates in the macro” can help trace nested calls, but not every diagnostic has that exact wording.

Inspect captured tokens with stringify!

stringify! produces a string literal from tokens without evaluating them. This example prints the captured expression and evaluates it once:

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

fn main() {
    let mut calls = 0;
    let answer = trace_it!({ calls += 1; 40 + 2 });
    assert_eq!((answer, calls), (42, 1));
}

The string is formed at compile time, but eprintln! runs only when execution reaches it. If the expanded program does not compile, this trace cannot run. Temporary tracing can also affect timing, so remove it after investigating.

stringify! can accept balanced token sequences that are not valid expressions, but $e:expr must still match an expression before this example reaches it. Use a tt matcher when you need to inspect a more general token sequence. It does not recursively expand macro calls contained in its input.

Do not depend on exact whitespace in its output. For example, Rust 1.98.1 prints stringify!(1+2) and stringify!(1 + 2) differently. The stringify! documentation warns that the resulting representation may change in future versions.

Use type errors to inspect types

A deliberate mismatch can reveal the type of an expansion. This complete example is intentionally rejected with E0308:

macro_rules! number {
    () => { 40 + 2 };
}

fn main() {
    let _: () = number!();
}

The diagnostic reports an integer where () was expected and points back into the macro. It does not print a complete expansion. If the macro already produces (), this particular probe does not create a mismatch. Remove the deliberate error once it has answered your question.

Check metavariables and unused rules

Enable meta_variable_misuse while developing a macro to catch some problems before the affected rule is invoked. It is allowed by default. This standalone example compiles with a warning because $missing was never captured:

#![warn(meta_variable_misuse)]
#![allow(unused_macros)]

macro_rules! broken {
    ($value:expr) => { $missing };
}

fn main() {}

The lint also checks repetition misuse, but it is not a proof that every expansion is valid. It does not generally warn about a captured metavariable simply because the rule never uses it. Nested macro definitions can make its analysis imperfect; review the warning in context. See the lint documentation.

Two other lints answer different questions:

Lint Default What a warning tells you
unused_macros warn A macro definition was not used in the analyzed code.
unused_macro_rules allow A rule was not used by the analyzed invocations.

Enable the second with #![warn(unused_macro_rules)]. An unused rule may be shadowed by an earlier rule, or its intended input may simply be absent from your tests. Exported macros and downstream callers also limit what local unused-code analysis can establish. Add representative calls before deciding to remove an arm. Warnings enabled in an exercise can become grading failures under clippy-driver -D warnings; fix the issue or remove temporary probes.

Preserve rule order while tracing a match

Rules are tried in source order. The first successful match is expanded; a later error in that expansion does not make Rust retry another arm.

In a small copy of the macro, replace each rule's output with a different marker while preserving the matchers and their order:

macro_rules! selected {
    ($name:ident) => { "identifier arm" };
    ($e:expr) => { "expression arm" };
}

fn main() {
    assert_eq!(selected!(item), "identifier arm");
    assert_eq!(selected!(1 + 2), "expression arm");
}

Here an identifier can match both arms, but the first wins. Deleting the first arm would make the second match item; that would not tell you which arm the original macro selected. Marker strings work in expression position. For other contexts, a temporary compile_error! message in the selected rule can identify it without running the program. Recursive macros may need markers at only one expansion level so the probe preserves the behavior in question.

Reduce the failing call, then choose more tooling if needed

Remove one part of the input at a time, keeping changes only when the same failure remains. Preserve required delimiters and separators. Smaller calls make repetition structure easier to inspect, but a bug may require multiple elements or a particular nesting depth. Halving the input is not guaranteed to retain it.

The site's Rust runner compiles an assembled source file directly with rustc; it does not provide a Cargo expansion-viewer command. In a local Cargo project, cargo expand can display expanded source. It needs a nightly compiler available for expansion, even when your project normally uses stable Rust. Its text output is lossy and does not preserve all hygiene information, so treat it as an inspection aid.

On a nightly toolchain, -Z macro-backtrace provides more expansion context; trace_macros! and log_syntax! are also unstable tools. They are optional local debugging aids, not requirements for the stable examples above.

A useful sequence is: read the diagnostic, identify the matching rule, inspect its captures, and shrink the failing invocation. Use a type probe for a type question and an expansion viewer when you need to inspect generated code.

step 13 of 28

Macros, FFI and Type-Driven Design

Up next Build vector, pattern-matching and map macros