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 expandprints your code with every macro expanded. Install it once withcargo install cargo-expand; it works with your normal stable toolchain.cargo expandshows the whole crate;cargo expand path::to::modulenarrows it down.
$ 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.
- rust-analyzer in VS Code and other editors has a command "Expand macro recursively at caret" that shows the expansion of the call under your cursor.
- Put
stringify!(...)orprintln!calls inside the transcriber to see what was matched. - Read the error's note:
in this macro invocationpoints at your call, and the arrows point into the macro's rules.
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