๐Ÿฆ€ Functional Rust

430: Debugging Macros with cargo expand

Difficulty: 3 Level: Advanced Use `cargo expand` to see exactly what your macros generate, and `compile_error!` to emit helpful diagnostics โ€” the two essential tools when a macro misbehaves.

The Problem This Solves

Macro errors are notoriously cryptic. The error points to the call site but the bug is in the expansion. You stare at `error[E0308]: mismatched types` in your template code and have no idea which arm matched, what tokens were captured, or what the expanded output looks like. Without tooling, debugging macros means adding `println!` calls you can't even see compile, or mentally tracing recursive expansions by hand. The other failure mode is silent: the macro runs but does the wrong thing. Maybe it matched the wrong arm, maybe a repetition produced one more or fewer item than expected, maybe `$a + sum!($($tail),*)` has a comma issue in the base case. These are impossible to diagnose without seeing the expansion. `cargo expand` solves this: it runs the macro expander and prints the fully-expanded Rust source. What you see is what the compiler compiles. You can read it, copy it, and compile it manually to isolate issues.

The Intuition

`cargo expand` is a thin wrapper around the compiler's internal `-Z unpretty=expanded` flag. It produces valid Rust source with all macros expanded โ€” `vec![1,2,3]` becomes its full `Vec` construction, your `sum!(1,2,3)` becomes `1 + (2 + 3)`, your derive impls become fully written trait implementations. For interactive debugging, `compile_error!` is your `panic!` โ€” it emits a hard error with your message during expansion. `stringify!($x)` shows you exactly what tokens were captured in a fragment. Together, these let you instrument macros like you'd instrument runtime code.

How It Works in Rust

// โ”€โ”€ Tool 1: cargo expand โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Install: cargo install cargo-expand
// Run:     cargo expand          (entire crate)
//          cargo expand my_mod   (one module)
// What you see: every macro call replaced with its full expansion.

// โ”€โ”€ Tool 2: stringify! to inspect captured tokens โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
macro_rules! trace_input {
 ($($x:tt)*) => {{
     // Shows exactly what tokens were matched โ€” indispensable for debugging
     println!("Macro received: {}", stringify!($($x)*));
 }};
}
// trace_input!(1 + 2 * "foo") โ†’ prints: Macro received: 1 + 2 * "foo"

// โ”€โ”€ Tool 3: compile_error! to diagnose wrong-arm matches โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
macro_rules! exactly_two {
 ($a:expr, $b:expr) => { ($a, $b) };
 ($($other:tt)*) => {
     // Shows the offending tokens in the error message
     compile_error!(concat!(
         "exactly_two! needs exactly 2 args, got: ",
         stringify!($($other)*)
     ))
 };
}

// โ”€โ”€ Mentally tracing recursive expansion โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// sum!(1, 2, 3):
//   โ†’ 1 + sum!(2, 3)       matches ($head, $($tail),*)
//   โ†’ 1 + (2 + sum!(3))    matches ($head, $($tail),*)
//   โ†’ 1 + (2 + 3)          matches ($head)
//   = 6

macro_rules! sum {
 () => { 0 };
 ($head:expr $(, $tail:expr)*) => {
     $head + sum!($($tail),*)
 };
}

// โ”€โ”€ Nightly: trace_macros! prints each expansion step โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// #![feature(trace_macros)]
// trace_macros!(true);
// sum!(1, 2, 3);  // prints each recursive step to stderr
// trace_macros!(false);
Debugging checklist when a macro fails: 1. `cargo expand` โ€” see the full expansion 2. `println!("arm: {}", stringify!($x))` โ€” check what was matched 3. `compile_error!` in unexpected arms โ€” force a diagnostic with context 4. Simplify the call site to the minimum failing case

What This Unlocks

Key Differences

ConceptOCamlRust
See macro expansion`ocamlfind ocamlopt -ppx ... -dsource` or `ppxlib` dump`cargo expand` (cargo-expand crate)
Runtime traceN/A for ppx`trace_macros!(true)` (nightly)
Compile-time error from macro`Location.raise_errorf` in ppx`compile_error!("msg")`
Inspect matched tokensN/A`stringify!($x)` prints token tree as string
// Debugging macros with trace_macros! and cargo expand

// trace_macros!(true); // Nightly only โ€” prints each expansion step
// trace_macros!(false); // Turn off

// A macro with a bug we'll debug
macro_rules! buggy_example {
    // Missing: what if list is empty?
    ($head:expr, $($tail:expr),+) => {
        $head + buggy_example!($($tail),+)
    };
    ($only:expr) => { $only }; // base case
}

// Debugging technique 1: add compile_error! to print matched arm
macro_rules! debug_which_arm {
    () => { compile_error!("matched empty arm") };
    ($single:expr) => { $single };
    ($a:expr, $b:expr) => { $a + $b };
    ($($x:expr),+) => { "more than 2" };
}

// Debugging technique 2: stringify! to see what's being matched
macro_rules! trace_input {
    ($($x:tt)*) => {
        {
            println!("Macro received: {}", stringify!($($x)*));
        }
    };
}

// Debugging technique 3: step through expansion manually
// Imagine: my_macro!(1, 2, 3)
// Step 1: matches ($head:expr, $($tail:expr),+) with head=1, tail=2,3
// Step 2: expands to 1 + my_macro!(2, 3)
// Step 3: matches ($head:expr, $($tail:expr),+) with head=2, tail=3
// Step 4: expands to 1 + (2 + my_macro!(3))
// Step 5: matches ($only:expr) with only=3
// Result: 1 + (2 + 3) = 6

// Well-designed macro for comparison
macro_rules! sum {
    () => { 0 };
    ($head:expr $(, $tail:expr)*) => {
        $head + sum!($($tail),*)
    };
}

// Macro that validates input count
macro_rules! exactly_two {
    ($a:expr, $b:expr) => { ($a, $b) };
    ($($other:tt)*) => {
        compile_error!(concat!(
            "exactly_two! requires exactly 2 args, got: ",
            stringify!($($other)*)
        ))
    };
}

fn main() {
    // Working macro
    println!("buggy_example!(1, 2, 3) = {}", buggy_example!(1, 2, 3));

    // trace_input shows what was passed
    trace_input!(hello world 42);
    trace_input!(1 + 2, "foo");

    // sum macro
    println!("sum!() = {}", sum!());
    println!("sum!(5) = {}", sum!(5));
    println!("sum!(1, 2, 3, 4) = {}", sum!(1, 2, 3, 4));

    // exactly_two
    let (a, b) = exactly_two!(10, 20);
    println!("exactly_two: {}, {}", a, b);

    // Cargo expand command (not runtime, but shown for reference):
    println!("
Debugging tools:");
    println!("  cargo install cargo-expand");
    println!("  cargo expand -- 2>&1 | head -100");
    println!("  RUSTFLAGS='-Z trace-macros' cargo build (nightly)");
}

#[cfg(test)]
mod tests {
    #[test]
    fn test_sum() {
        assert_eq!(sum!(), 0);
        assert_eq!(sum!(5), 5);
        assert_eq!(sum!(1, 2, 3), 6);
        assert_eq!(sum!(1, 2, 3, 4, 5), 15);
    }

    #[test]
    fn test_buggy_becomes_correct() {
        assert_eq!(buggy_example!(10, 20, 30), 60);
    }
}
(* Debugging macros in OCaml *)

(* OCaml macro debugging via ppx dump flags *)
(* cargo-like: ocamlfind ocamlopt -package ppx_deriving -ppx ... *)

(* Debugging technique: add Printf to macro output *)
let debug_macro_expand name value =
  Printf.printf "[MACRO EXPAND] %s => %s\n" name value

(* Verify expansion produces expected results *)
let test_macro_output name actual expected =
  if actual = expected then
    Printf.printf "[OK] %s\n" name
  else
    Printf.printf "[FAIL] %s: expected %s, got %s\n" name expected actual

let () =
  (* Simulate macro that we're debugging *)
  debug_macro_expand "stringify!(x + y)" ""x + y"";
  debug_macro_expand "concat!("a", "b")" ""ab"";

  test_macro_output "simple add" (string_of_int (1 + 2)) "3";
  test_macro_output "concat" ("foo" ^ "bar") "foobar"