RustArena

Step 5 of 6

Hygiene, small DSLs and debugging macros

Hygiene

Macros in Rust are hygienic: a local variable created inside a macro lives in its own world, separate from the caller's variables, even if the names are the same.

macro_rules! double_it {
    ($e:expr) => {{
        let x = $e;
        x * 2
    }};
}

fn main() {
    let x = 10;
    println!("{}", double_it!(x + 1));
    println!("{}", x);
}
22
10

The macro's let x doesn't capture or shadow the caller's x: $e is still x + 1 with the caller's x, giving 22, and the caller's x is untouched. In C, the same trick with #define would quietly compute with the wrong variable.

Hygiene works the other way too: a macro can't reach into the caller's local variables unless they're passed in.

macro_rules! add_count {
    ($x:expr) => {
        $x + count
    };
}

fn main() {
    let count = 2;
    println!("{}", add_count!(5));
}
error[E0425]: cannot find value `count` in this scope

The compiler even adds: an identifier with the same name exists, but is not accessible due to macro hygiene. Pass it in instead (add_count!(5, count)); then it's visible at the call site, which is what readers expect.

Hygiene covers local variables and labels. Function, struct and other item names a macro creates or uses are looked up normally, which is why make_adder!(add_five, 5) could create a function you call later. For macros shared between crates, mark them #[macro_export] and write paths as $crate::... so they resolve inside your crate, wherever the macro is used.

Small DSLs

A DSL (domain-specific language) is a mini-language for one job. Macros let you invent readable syntax that expands to plain Rust. A favorite at work is a table of test cases that generates one #[test] per row:

macro_rules! level_tests {
    ($($name:ident: $input:expr => $expected:expr,)*) => {
        $(
            #[test]
            fn $name() {
                assert_eq!(parse_level($input), $expected);
            }
        )*
    };
}

level_tests! {
    parses_info: "info" => Some(Level::Info),
    parses_upper: "WARN" => Some(Level::Warn),
    rejects_junk: "loud" => None,
}

Each row becomes a separate test with its own name, so a failure tells you exactly which case broke. Crates like rstest and test-case do this with attribute macros. Other DSLs you'll meet: serde_json::json!({ "id": 1 }), html! in web frameworks, and sqlx::query!.

Here's a version you can run that collects results instead of generating tests. stringify! turns tokens into a string literal, so the table can report the source text of each input:

macro_rules! check_table {
    ($func:ident { $($input:expr => $expected:expr),* $(,)? }) => {
        $(
            let got = $func($input);
            let mark = if got == $expected { "ok  " } else { "FAIL" };
            println!("{} {}({}) = {:?}", mark, stringify!($func), stringify!($input), got);
        )*
    };
}

fn grade(score: u32) -> char {
    match score {
        90.. => 'A',
        75..=89 => 'B',
        _ => 'C',
    }
}

fn main() {
    check_table!(grade {
        95 => 'A',
        80 => 'B',
        74 => 'B',
    });
}
ok   grade(95) = 'A'
ok   grade(80) = 'B'
FAIL grade(74) = 'C'

Debugging macros

When a macro doesn't do what you expect, look at what it expands to:

$ cargo expand
fn main() {
    let x = 10;
    {
        ::std::io::_print(format_args!("{0}\n", { let x = x + 1; x * 2 }));
    };
    ...

(println! itself expands too, which is why the output is noisier than your code.) Notice let x = x + 1;: printed code can't show hygiene, so the macro's x and yours look like one variable. The compiler still keeps them apart; read expanded code with that in mind.

Your task

Write test_table!, which runs a function over a table of cases and returns a Vec<String> describing each failure. test_table!(double { 1 => 2, 5 => 11 }) returns vec!["double(5): expected 11, got 10"] (the function and input are shown as source text with stringify!, the expected and actual values with {:?}). Rows that pass add nothing. Allow a trailing comma, and evaluate each expected value once.

Challenge 2

Thanks to hygiene, a swap! macro can use a temporary variable called tmp even when the caller has a variable called tmp. Write swap!(a, b) (taking two identifiers of mutable variables) that swaps their values using let tmp = $a;. The program should print tmp = 2, other = 1.

Challenge 3

Write a state_machine! DSL. Each row is From + event => To, and the macro generates fn next_state(state: &str, event: &str) -> Option<&'static str>, which returns the target state's name for a known (state, event) pair and None otherwise. Then use it to define this machine for a media player:

Idle + play => Playing
Playing + pause => Paused
Paused + play => Playing
Playing + stop => Idle
Paused + stop => Idle

Hint: stringify! works in match patterns too.

Previous: Recursive macros Next: Procedural macros and derive