When macros hide the bug
You write a macro to generate a Debug implementation for a custom struct. The macro definition looks correct. You invoke it. You run cargo build. The compiler rejects you with a wall of errors about missing methods or unexpected tokens. You stare at the macro source. The syntax is valid. The logic seems sound. The error message points to a line number that doesn't exist in your file.
The problem isn't in the macro source. The problem is in the code the macro generates. Rust macros run at compile time and transform your code before the compiler checks types. You see the macro invocation. The compiler sees the expanded result. When the expansion contains a bug, the error message reflects the generated code, not your source. You cannot fix what you cannot see.
Stop guessing. Run cargo expand to reveal the generated code.
What cargo expand actually does
Rust macros are code generators. You write a pattern using macro_rules! or a procedural macro, and the compiler replaces that pattern with concrete tokens. This happens invisibly during the build process. cargo expand bridges the gap between your source and the compiler's view.
The tool invokes the Rust compiler with special flags. It runs the macro expansion phase. It stops before type checking and linking. It prints the resulting tokens to standard output. You get the exact code the compiler is about to type-check.
Think of a macro like a template engine. You write a template with placeholders. The tool fills in the placeholders and produces the final document. Usually, you only see the final document after it's been processed. cargo expand is a "view source" button for that processing step. It shows you the filled-in template before the compiler validates it.
Macros are code generators. Treat them like functions that return code.
Minimal example
Start with a simple declarative macro that generates a struct and a method. This example demonstrates how cargo expand reveals the generated structure.
// src/lib.rs
/// Generates a struct with a single field and a getter method.
macro_rules! make_struct {
// Matches an identifier for the struct name and one for the field.
($name:ident, $field:ident) => {
struct $name {
$field: i32,
}
impl $name {
fn get_value(&self) -> i32 {
self.$field
}
}
};
}
// Invokes the macro. The compiler replaces this line with the generated code.
make_struct!(Point, x);
pub fn main() {
let p = Point { x: 10 };
println!("{}", p.get_value());
}
Install the tool globally if you haven't already. Then run the expansion command in your project root.
# Install the expansion tool.
cargo install cargo-expand
# Expand the default binary target.
cargo expand
The output shows the expanded code. The macro invocation make_struct!(Point, x) disappears. In its place, you see the generated struct Point and impl Point block. The main function remains unchanged.
// Output snippet from cargo expand
struct Point {
x: i32,
}
impl Point {
fn get_value(&self) -> i32 {
self.x
}
}
pub fn main() {
let p = Point { x: 10 };
println!("{}", p.get_value());
}
The expansion confirms the macro produced the expected structure. If the macro had a bug, such as a missing brace or a typo in the field name, the output would reveal it immediately.
Run cargo expand. The bug is usually obvious in the output.
Real-world debugging
Real projects involve dependencies, workspaces, and complex macro interactions. The default cargo expand output can be massive. It includes all dependencies and the entire crate. Use flags to focus on what matters.
Targeting specific items
Use the --item flag to filter the output by a specific function, struct, or module. This is essential when debugging a single macro in a large codebase.
# Expand only the code related to the "Point" struct.
cargo expand --item Point
# Expand only the code related to the "utils" module.
cargo expand --item utils
The tool searches for items matching the name and prints only the relevant expanded code. This reduces noise and makes it easier to spot errors.
Handling workspaces and targets
In a workspace, cargo expand defaults to the first package. Use --lib, --bin, or --package to select the correct target.
# Expand the library target of the current package.
cargo expand --lib
# Expand a specific binary in a workspace.
cargo expand --bin my_binary
# Expand a specific package in a workspace.
cargo expand --package my_crate
These flags mirror the behavior of cargo build. They ensure you are expanding the correct artifact.
Features and conditional compilation
Macros often depend on features. Conditional compilation inside macros is common. If you enable a feature, the macro might generate different code. Pass the --features flag to match your build configuration.
# Expand with a specific feature enabled.
cargo expand --features "advanced"
# Expand with multiple features.
cargo expand --features "advanced,debug"
Failing to pass features can lead to misleading expansions. The output might show a fallback branch that your actual build never uses. Always match the feature flags to your runtime configuration.
Pretty printing
cargo expand formats the output by default using prettyplease or rustfmt. This makes the code readable. If you need the raw token stream for advanced debugging, use the --raw flag.
# Show raw tokens without formatting.
cargo expand --raw
Raw output includes hygiene markers and internal details. It is harder to read but useful when investigating compiler internals.
Filter early. Read less. Find the bug faster.
Pitfalls and gotchas
cargo expand is a powerful debugging tool, but it has limitations. Understanding these prevents wasted time and confusion.
Hygiene and copy-pasting
The expanded output is for inspection only. Do not copy-paste the output back into your source code. Rust macros use hygiene to prevent name collisions. The compiler tracks where identifiers come from. The expanded output might contain identifiers that look valid but rely on internal hygiene context. Copying them can break the build or introduce subtle bugs.
Treat the output as a diagnostic view. Use it to understand the structure. Write your fixes in the macro source.
Output size
Expanding a large crate with many dependencies produces megabytes of text. Piping the output to a file or a pager is standard practice.
# Save output to a file for searching.
cargo expand > expanded.rs
# View output in a pager.
cargo expand | less
Combine this with --item to keep the output manageable. Expanding the entire crate is rarely useful for debugging a specific macro.
Build scripts and environment
cargo expand runs in a separate build context. It executes build scripts and respects environment variables. However, it might not reflect runtime optimizations or specific compiler flags unless explicitly configured. The tool is for understanding macro behavior, not for analyzing optimized machine code.
If your macro depends on env! variables, ensure those variables are set before running cargo expand. The tool inherits the environment from your shell.
Installation and toolchain
cargo expand is an external tool. It is not part of the standard Rust distribution. You must install it manually. If you get a "command not found" error, run cargo install cargo-expand.
The tool works with stable Rust. It handles the necessary compiler flags automatically. You do not need a nightly toolchain to use it.
The output is for debugging, not for copy-pasting. Trust the compiler's hygiene.
Decision matrix
Choose the right tool for your macro debugging workflow. Different scenarios call for different approaches.
Use cargo expand when you need a quick command-line view of macro expansions without configuring your editor. It works everywhere and requires no setup beyond installation.
Use your IDE's macro expansion feature when you want to inspect code inline while writing. Most modern Rust editors support peeking at macro expansions with a keyboard shortcut. This is faster for iterative development.
Use rustc -Z unpretty=expanded when you are building a custom toolchain or need to bypass cargo entirely. This is a nightly-only flag and requires manual dependency resolution. It is rarely needed for standard projects.
Use cargo expand --item when the full crate output is too large to read. Target the specific function or struct to isolate the relevant code. This is the standard approach for focused debugging.
Use cargo expand --features when your macro behavior depends on feature flags. Ensure the expansion matches your build configuration to avoid misleading results.
Pick the tool that fits your workflow. cargo expand is the universal fallback.