24. Rust Declarative and Procedural Macros¶
Rust macros generate syntax that is subsequently checked as Rust code; they are not runtime functions with unusual punctuation. This lesson builds a small local workspace to demonstrate macro_rules! and all three procedural macro forms without fetching dependencies.
Learning goals and prerequisites¶
You should be comfortable with crates and visibility, traits and testing. Pinning from lesson 23 is not needed by the program; this is another language mechanism, not an extension of an async runtime.
By the end, identify matched fragments and repeated expansion, check that expressions are evaluated as intended, and distinguish adding items with derive from replacing an attributed item. Use stable Rust, Cargo and edition 2024. No sensor access, installed macro tools or external crates are required.
Rust macro_rules: syntax first, types later¶
Our list macro has one arm for no arguments and one for comma-separated expressions. $value:expr matches an expression, not a runtime i32 value. Repetition with + requires at least one expression; $(,)? permits one trailing comma. The transcriber repeats one push per matched expression, using an extra block so the expansion is a single expression.
The second macro simply calls a normal function through $crate. It is deliberately unnecessary for this calculation: an ordinary function is the better default when no syntax generation is needed. Compare the fragment and repetition rules in the macros-by-example Reference.
Create the six-file workspace¶
Create a fresh directory with the following files. The root is a library/binary package; fixture-macros is a separate compiler plugin package with proc-macro = true. A Cargo workspace coordinates their builds and lockfile, while the path dependency makes the plugin available to the root package. No registry package is needed.
Cargo.toml¶
fixture-macros/Cargo.toml¶
src/lib.rs¶
fixture-macros/src/lib.rs¶
src/main.rs¶
tests/public_macros.rs¶
Expected output:
Read the expansion before assuming its behaviour¶
The tests are part of this macro's contract: empty input needs an element type, zero is retained, arguments are evaluated once and in order, and its temporary values binding does not overwrite the caller's values. Macro code could instead duplicate an expression or ignore it; a macro invocation does not inherently guarantee ordinary function-style argument evaluation. The Book's macro introduction provides a useful comparison with functions.
readings! exports at the crate root. $crate in bounded_reading! refers to the defining library, even when its caller uses a different imported name or defines its own checked_reading. It does not bypass visibility: our helper must still be public for callers outside the library. Declarative macros have mixed-site hygiene, not automatic protection for every referenced name. Consult the hygiene rules when expanding references to other items.
Three procedural macro interfaces¶
The procedural macro Reference distinguishes function-like expansion, derive-generated additional items and attribute-generated replacement items. All run during compilation in another crate. Our derive's output is an impl, not a replacement struct. add_debug keeps the item token stream and prefixes another attribute; fixture_value produces an expression.
The compiler-provided TokenTree API represents groups, identifiers, punctuation and literals. Our derive examines three top-level tokens. It intentionally rejects public structs, fields, generics, enums, unions and additional attributes. This is a tiny specified input language, not a general Rust parser. Printing arbitrary token streams as strings and reparsing is not a robust substitute for parsing full Rust syntax; preserve spans and input tokens when building production diagnostics.
The generated FixtureName path resolves at the caller. That is why both consumer files import the trait, as well as the derive macro with the same name in the macro namespace. Procedural macro output is not hygienically isolated from caller names. A production derive needs a considered path-resolution strategy and input parser; our limited example advertises neither.
Compile-time code has a trust boundary¶
These macros do not read files, contact a network or inspect the Pi. A third-party procedural macro or build script can run code with the compiler process's resources during a build. Treat dependency selection as a trust decision; generated code must also obey ordinary typing, ownership and privacy. In cross-compilation, a procedural macro executes on the build host rather than becoming Pi runtime code. Building natively here verifies the examples on the Pi, not a cross-compilation setup.
Deliberately failing: generated calls remain type-checked¶
In a separate copy of this workspace, replace only src/main.rs with:
cargo check reports E0308: our function expects i32, not str. Matching an expr is not permission to pass any type to the emitted function. Repair the argument as 46_700, or explicitly parse text through a Result-returning API if text is the intended input.
Exercises and troubleshooting¶
- Repair the type failure and add an assertion for Some(46700). Do not hide parsing failure behind a fabricated zero.
- Add more side-effecting expressions to readings!. Predict their values and the final call counter before running the tests.
- Remove the optional trailing comma matcher and retain the existing calls. Expect a macro-matching error. Repair either the grammar or the invocations deliberately.
- Make checked_reading private while retaining its exported macro and binary caller. Expect E0603: $crate repairs resolution, not visibility.
- Apply FixtureName to a named-field or generic struct. Expect our explicit input-restriction diagnostic, not a guessed implementation. Extend a parser only if you are prepared to support those shapes and test them.
- Call fixture_value!(123), and separately apply #[add_debug(extra)]. Both must reject arguments instead of silently ignoring user input.
- Remove the trait import from a derive consumer. Expect E0404 for the generated impl: a derive macro with that name exists in another namespace, but the trait is absent. Explain the limitation instead of claiming that a proc-macro crate automatically imports its trait.
- Prefer a normal function for the range policy. Identify why syntax repetition and generated implementations, unlike that policy, actually need macro machinery.
Verification and next step¶
On October 10, 2026, the six-file workspace was verified on the authorised Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust and Cargo 1.99.0, and edition 2024. Workspace checks, five library tests and three integration tests in debug/release, formatting, documentation and debug/release output comparisons passed, as did the integer-argument repair. Eight compiler failures verified type checking, private helpers, trailing-comma matching, named-field/generic derive rejection, unexpected function/attribute arguments and a missing trait import. The fixture macros intentionally remain limited; this lesson makes no claim to implement a production derive library.
Continue with attributes, cfg, features and editions: identify which code is compiled and test each feature configuration.