Skip to content

The Rust Engineering Handbook / Chapter 75

Declarative Macros: Syntax, Hygiene, and Recursion

Design and review macro_rules! interfaces as bounded token grammars with explicit expansion, scope, diagnostic, and compile-time costs.

error: expected `visibility static NAME = [METHOD "/path" => handler, ...];`
 --> src/routes.rs:8:1
  |
8 | / routes! {
9 | |     pub static API = [GET "/health": health];
10| | }
  | |_^

The colon looks harmless. In a textual preprocessor it might be one character in a replacement template. In Rust it prevents the invocation from matching the DSL’s only valid grammar, so a deliberate catch-all arm rejects the complete token tree before any runtime program exists.

That distinction opens a new stage of the handbook. The preceding parts used Rust’s built-in ownership, type, concurrency, and unsafe contracts. Metaprogramming lets a library author add a small language to those contracts. The power is real: one definition can produce items, expressions, types, or patterns; it can repeat syntax; and it can preserve relationships that a string generator would lose. The cost is also real. A macro creates a second interface whose grammar, name resolution, diagnostics, compile time, and compatibility must be engineered.

The useful model is not “code that writes code.” A macro_rules! definition is an ordered set of token-tree matchers and syntax transcribers. Review it as a language boundary.

Trace the invocation one token at a time

The lab’s route table has one public arm:

#[macro_export]
macro_rules! routes {
    (
        $vis:vis static $name:ident = [
            $( $method:ident $path:literal => $handler:path ),+ $(,)?
        ];
    ) => {
        $vis static $name: &[$crate::Route] = &[
            $( $crate::__route(stringify!($method), $path, stringify!($handler)) ),+
        ];
    };
    ($($invalid:tt)*) => {
        compile_error!(
            "expected `visibility static NAME = [METHOD \"/path\" => handler, ...];`"
        );
    };
}

Read the matcher as a parser. $vis:vis accepts a visibility fragment, including empty visibility. Literal tokens static, =, [, and ]; establish landmarks. $name:ident captures one identifier. Inside the repetition, the method is an identifier, the URL is a literal, and the handler is a path. ),+ requires one or more entries separated by commas. $(,)? accepts one optional trailing comma.

The compiler tries arms in order and uses the first successful match. It does not backtrack through an arbitrary grammar or look ahead until an interpretation becomes convenient. Once the first matcher succeeds, an error produced while transcribing that arm does not cause the compiler to try a later arm. Ordering is therefore part of the public interface: put specific accepted forms before a final rejection arm, and do not use overlapping arms whose priority a maintainer must simulate mentally.

An invocation arrives as tokens and delimited groups. The parentheses in routes!(...), the square-bracketed entry list, and any groups inside handler paths form token trees. Whitespace is generally not grammar. Delimiters and punctuation are. The macro does not receive a source string and slice it at commas; the Rust lexer has already identified tokens and nested groups.

Invocation token trees enter ordered matcher arms and the first successful match sends captures into a transcriber that emits item, expression, type, or pattern syntax. A mixed-site hygiene lane separates definition-site locals and labels, invocation-site ordinary paths, and the special dollar-crate path to the defining crate.
Read a declarative macro as a parser and syntax transcriber: ordered arms choose one grammar, captures keep their syntax roles, and name resolution depends on definition site, invocation site, or `$crate`.

The transcriber is constrained by captures. A metavariable captured under one repetition must be transcribed under the same number and nesting of repetitions. If two captures drive the same output repetition, they must have compatible lengths. These restrictions prevent an expansion from guessing whether three methods and two paths should zip, cross-product, or truncate.

The expansion is syntax, not a string pasted into a file. For the two-entry invocation, its relevant shape is:

pub static API: &[::macro_contract::Route] = &[
    ::macro_contract::__route("GET", "/health", "health"),
    ::macro_contract::__route("POST", "/events", "ingest"),
];

stringify! preserves token spelling for metadata; it does not resolve or call the handler. That is an intentional lab simplification. A production router that calls handlers should transcribe $handler into a typed field or generated dispatch function so ordinary type checking verifies the path. The difference belongs in review: accepting a path fragment gives the input a syntactic shape, but only generated use gives it semantic work.

Fragment specifiers are grammar commitments

A fragment specifier is both a filter and a promise to future maintainers. Use the narrowest fragment that represents the input’s job:

Fragment Suitable contract Frequent mistake
ident a name or keyword-shaped token expecting a qualified path
path a type or value path treating it as an arbitrary expression
expr an expression in the definition edition following it with punctuation forbidden by follow-set rules
ty a type trying to inspect its internal tokens in a downstream macro
pat / pat_param a pattern, with different top-level alternation rules ignoring the definition edition
item a complete item using it when only a name and body are required
literal a literal expression claiming its value satisfies a domain constraint
tt one token tree using it as an unstructured escape hatch
vis possibly empty visibility assuming $crate can override privacy

Fragments are not domain validators. $path:literal accepts "accounts" even though a router may require a leading slash. A declarative macro can add separate literal-token arms or arrange generated const checks for limited rules, but it cannot generally inspect a string literal’s contents. If rich validation and field-specific spans are central to the interface, that pressure points toward a procedural macro or an ordinary typed constructor.

Captured fragments also become opaque when forwarded to another declarative macro. If an outer macro captures $value:expr and passes it to an inner macro, the inner matcher can match it as an expr; it cannot normally crack it open by matching literal 3. ident, lifetime, and tt are exceptions to this forwarding restriction. Do not create a chain of helper macros that secretly depends on reparsing opaque fragments.

Follow-set restrictions protect future Rust syntax. An expr or stmt fragment may be followed only by =>, ,, or ;; a matcher cannot claim today-unused punctuation that a future expression grammar might adopt. The robust DSL uses separators Rust already recognizes around that fragment. A grammar such as $expr:expr [ $index:expr ] is rejected even when it appears locally unambiguous, because [ could become meaningful after an expression.

Repetition is structural, not iteration

$(... ),*, $(... );+, and $(...)? describe repeated syntax. They do not execute a loop at runtime. The operator determines cardinality: * permits zero, + requires at least one, and ? permits at most one and therefore takes no separator.

Choose cardinality from the domain. The route table uses + because an empty public registry would probably indicate a configuration mistake. If an empty table is valid, use * and test it explicitly. Trailing punctuation should be a conscious ergonomic choice rather than an accidental second grammar. $(,)? accepts exactly one optional trailing comma; a loose $(,)* would accept many commas and make invalid input look valid.

Nested repetition deserves suspicion. It is appropriate for genuinely nested structures, such as groups containing fields. It is not a convenient way to avoid defining an intermediate type. Review the matcher and transcriber side by side, mark each repetition depth, and verify which metavariable determines each output count. When that diagram becomes difficult, the public grammar is already difficult.

Expansion size is a resource. A table of 20 entries produces a bounded amount of syntax. A macro that duplicates a large expression for every pair of inputs can multiply type checking, monomorphization, code size, and diagnostic noise. “No runtime loop” is not “no cost.” Measure clean and incremental builds, inspect generated symbols or binary size when duplication matters, and cap inputs if the grammar is exposed to generated or untrusted source.

Ambiguity fails at the matcher boundary

Consider this intentionally rejected definition:

macro_rules! ambiguous {
    ($($head:ident)* $tail:ident) => { stringify!($tail) };
}

let _ = ambiguous!(alpha);

At alpha, the matcher cannot decide whether the identifier belongs to the repeated head sequence or the final tail. Rust reports local ambiguity; it does not look ahead to discover that the invocation ends. The repair is a grammar delimiter, not a more optimistic compiler:

macro_rules! split_last {
    ($($head:ident),* ; $tail:ident) => {
        ([$(stringify!($head)),*], stringify!($tail))
    };
}

The semicolon gives the parser a boundary. A keyword can do the same job: fields ... => handler .... Good macro syntax has visible landmarks. It should not require edition-specific parser trivia or priority among similar arms to be readable.

Weak repairs compile while degrading the language. Capturing the entire invocation as $($tt:tt)* and recursively munching until something works can move an error far from its cause. Adding a broad first arm can silently reinterpret input previously rejected. Accepting both colon and arrow forms indefinitely creates two dialects. Prefer one canonical grammar, a narrow compatibility arm with a deprecation path when necessary, and a final compile_error! that shows the accepted shape.

The error arm in routes! points to the invocation as a whole. That is less precise than a parser that can underline the colon, but it is stable and useful. More arms can diagnose specific near misses:

($vis:vis static $name:ident = [$method:ident $path:literal : $handler:path];) => {
    compile_error!("use `=>` between the route path and handler")
};

Do this only for likely mistakes. An exhaustive shadow grammar doubles maintenance and can introduce overlap. The best diagnostic often comes from shrinking the accepted language.

Hygiene separates names without promising isolation

Declarative macros have mixed-site hygiene. Loop labels, block labels, and local variables are resolved at the macro definition site. Other symbols are generally resolved at the invocation site. Locals introduced by separate macro expansions do not become one shared binding merely because they have the same spelling.

That prevents a common textual-preprocessor failure. A local named temporary introduced inside an expansion does not casually capture a caller’s temporary. It does not make every name safe. An unqualified Option, helper function, or nested macro invocation may resolve differently depending on what the caller has in scope.

Use $crate for paths back into the defining crate:

$vis static $name: &[$crate::Route] = &[
    $( $crate::__route(stringify!($method), $path, stringify!($handler)) ),+
];

$crate is not the package name and is not affected when a dependency is renamed. It begins a path in the crate that defined the macro. For a non-macro item, provide the complete module path after it. It also does not bypass visibility: an exported macro invoked by another crate can use $crate::internal::helper only if the referenced item is visible as required from that expansion context. The lab exposes a hidden-from-docs public helper for that reason. “Hidden” affects documentation; pub affects access.

Do not export every helper macro by default. #[macro_export] places the macro at the crate root for path-based use, regardless of the module in which its definition appears. Internal recursive helpers can remain lexically scoped when they do not need cross-crate resolution. The older local_inner_macros migration facility is discouraged for new macros; qualify helpers deliberately.

Names supplied by callers should usually remain caller-owned captures. A macro that invents a public Error, Builder, or register item risks collision. If generation of named public items is the product, make the names explicit input. If an internal item must be invented, give it an unlikely, implementation-specific name and keep its visibility narrow—while recognizing that a procedural macro has still different hygiene limitations.

Expansion context changes what a valid transcriber means

A macro invocation can appear where Rust expects an item, expression, statement, pattern, or type, but a particular macro should have a clear contract.

An item macro might emit structs, functions, implementations, or statics. It must handle visibility, attributes, generics, and namespace collisions deliberately. The routes! lab is an item macro: placing it in expression position is a category error.

An expression macro should usually expand to one expression. A surrounding block is often useful:

macro_rules! measured {
    ($body:expr) => {{
        let started = ::std::time::Instant::now();
        let value = $body;
        (started.elapsed(), value)
    }};
}

The inner block contains introduced locals and preserves expression value. It does not make evaluation duplication safe: transcribing $body twice would evaluate side effects twice. Count every capture use.

A type macro may select or construct a type, while a pattern macro expands within pattern grammar. Both tend to be harder for readers and tools to discover than type aliases, associated types, or ordinary patterns. Their benefit must exceed that search cost. Test each supported context in its real syntactic position; a token sequence that parses as an item is not evidence that it parses as an expression.

Semicolon behavior can be subtle because macro invocations and their expansions participate in surrounding grammar. Write examples in the supported context and use rustfmt as evidence. Avoid transcribers that depend on callers adding or omitting punctuation by intuition.

Recursion is a parser technique with a budget

Declarative macros can invoke themselves or helpers to process one token-tree segment at a time. A token-tree muncher usually has a base arm and recursive arms:

macro_rules! count_idents {
    (@count $n:expr;) => { $n };
    (@count $n:expr; $head:ident $(, $tail:ident)*) => {
        count_idents!(@count $n + 1usize; $($tail),*)
    };
    ($($name:ident),* $(,)?) => {
        count_idents!(@count 0usize; $($name),*)
    };
}

This teaches the mechanism, but it is not the preferred route implementation. Repetition expresses a flat list more directly and usually expands with less matching work. Recursion is justified when the input grammar carries state that one repetition cannot represent, such as accumulating options before a terminal clause.

Rust limits macro expansion recursion. A crate can raise #![recursion_limit = "..."], but that is a pressure gauge, not the first repair. Deep recursion may reflect a huge valid input, accidental nontermination, or a quadratic token muncher. First simplify the grammar, consume larger units per step, replace recursion with repetition, and inspect generated size. Only then raise a limit with a documented maximum input and compile-time measurement.

Never accept arbitrary source as a compile-time denial-of-service surface without bounds. Macro input normally comes from trusted source control, yet generated code, plugin ecosystems, and downstream crates can amplify it. A public macro’s expansion complexity is part of its operational contract.

Editions belong to the definition

Edition-sensitive fragment behavior is determined by the edition of the macro definition, not the invocation crate. In Rust 2024, expr can match top-level underscore expressions and const-block expressions that older definition editions excluded. expr_2021 preserves the older boundary for compatibility. Similarly, the meaning of pat changed in Rust 2021 to accept top-level or-patterns; pat_param retains the narrower form useful before a literal | separator.

This matters when a library changes edition without changing its public macro spelling. A newly accepted invocation can overlap another arm and therefore alter which arm wins. Run the edition migration lint, add boundary tests before changing the definition edition, and record fragment choices in the compatibility review. Do not blindly replace expr_2021 with expr; decide whether the expanded language is intended.

Path-based macro imports are the modern baseline: callers can write use macro_contract::routes; and invoke routes!. Legacy #[macro_use] extern crate ... behavior still explains older code, but new APIs should not depend on macro-use prelude ordering. Visibility, macro namespace, and textual scope remain distinct concepts; test the actual cross-crate call shape.

Debug expansion with stable evidence first

Begin at the smallest failing invocation. Remove unrelated entries, replace complex expressions with literals, and identify the first token the matcher cannot classify. Then inspect these layers:

  1. Does the invocation form a valid delimited token tree?
  2. Which arm should match, and can an earlier arm also match?
  3. Do fragment follow sets permit the next token?
  4. Are repetitions driven at identical nesting depths?
  5. In which Rust context will the transcribed syntax be parsed?
  6. Which names resolve at the definition crate and which at the call site?
  7. Does a captured expression execute more than once?

Compiler diagnostics, a minimized fixture, ordinary tests, compile-fail UI fixtures, and cargo expand-style ecosystem tools can reveal the result. Expansion-printing compiler flags and trace_macros! have historically required nightly or unstable facilities; they are useful investigations when pinned and labeled, not stable API promises. Never paste “cleaned up” diagnostic output into evidence. Preserve toolchain identity, invocation, and enough source for spans to remain meaningful.

Test successful runtime behavior, emitted type behavior, and rejected grammar separately. The lab runs two positive tests, one doctest at the public call site, an intentional local-ambiguity fixture, and an invalid-DSL fixture that checks the catch-all message. For a public macro, add downstream-crate tests so $crate, visibility, renamed dependencies, editions, features, and no_std expectations are exercised in the environment that can break them.

Decide whether a macro earns its language

A function is clearer when inputs are values, type checking already expresses the contract, ordinary control flow is adequate, and call-site tooling matters. The route table could be a const slice of Route values; that alternative is explicit, searchable, and easy to refactor.

A trait is clearer when behavior varies by type, downstream implementations are intended, or generic bounds should name a capability. An associated-const trait can express route metadata without inventing punctuation. A builder is clearer when validation is naturally staged at runtime and configuration comes from files or the environment.

A declarative macro earns its place when it must repeat or conditionally emit Rust syntax, accept variable syntactic forms that types cannot express ergonomically, or enforce a compact compile-time grammar whose benefits exceed its diagnostic and maintenance cost. It should produce unsurprising Rust and leave runtime behavior visible.

Use this review on the routes! DSL:

  • State the one canonical grammar without reading the transcriber.
  • Identify the fragment and cardinality of every capture.
  • Prove that accepted forms are unambiguous and that near misses fail readably.
  • Trace every capture into output; flag duplicated evaluation or unused semantic input.
  • Mark every generated name as caller-owned, definition-owned, or deliberately invented.
  • Test visibility and $crate from a renamed downstream dependency.
  • Bound expansion depth and output size for the largest supported input.
  • Compare the macro with a const value, function, trait, and build-time generator.
  • Record the definition edition and test syntax at its fragment boundaries.

Worked review: should the route language grow?

Suppose the service team asks for per-route middleware, a generated enum of route names, duplicate-path rejection, and environment-controlled enablement. The existing invocation might grow into this:

routes! {
    pub static API = [
        GET "/health" => health,
        POST "/events" => ingest with [authenticate, rate_limit]
            if feature = "ingest",
    ];
}

Do not begin by adding optional repetitions. Classify each requirement by the phase that can enforce it.

Middleware names are Rust paths and can be transcribed into a typed static slice or composed call. Declarative matching can preserve their order and require brackets. Type checking can prove that each path has the expected callable or trait shape only if the expansion actually uses it in such a context. The matcher cannot prove two middleware functions have compatible state or error semantics. The generated type boundary must do that work.

Generating a route-name enum is syntax production, so a macro is capable of it. The public naming policy is the hard part. Turning "/health" into Health requires inspecting and transforming a string literal, which macro_rules! cannot do generally. Asking the caller for HEALTH: GET "/health" => health makes the name explicit and preserves spans, but adds redundancy. A procedural macro can derive an identifier, yet derived naming creates compatibility questions around punctuation, collisions, and acronym rules. The best declarative design may generate an array and let the caller define a separate domain enum rather than pretend the path is a sound identifier source.

Duplicate paths are a value-level relationship among literal contents. Separate matcher captures cannot compare arbitrary strings for equality. One can enumerate special tokens or build recursive arms that compare identifier spellings, but URLs are literals and the language will quickly outrun those tricks. Generated code can sometimes force a const-time error through another mechanism, although the diagnostic may point into generated syntax. A procedural validator can compare decoded literals and attach errors to the second occurrence. A runtime constructor can also reject duplicates when configuration is assembled, with simpler tooling but later feedback. The correct choice depends on whether the route set is source-static and whether compile-time rejection justifies the dependency.

Environment-controlled enablement should not cause a procedural or declarative macro to inspect the process environment. Cargo features are compile-time configuration and can be represented by attributes on generated items, but the macro must preserve additive-feature semantics and test every supported combination. Deployment environment is runtime configuration; baking it into expansion produces different binaries from invisible inputs and breaks reproducibility. Generate the complete registry and filter or select it through an ordinary typed configuration boundary.

After classification, the team records three plausible designs:

Design Strength Cost and failure mode
keep the small routes! grammar trivial static tables, low dependencies, readable expansion duplicate validation stays elsewhere; generated enum is rejected
introduce a procedural route macro precise literal validation and spans; richer syntax tree host-executed dependency, higher clean-build cost, larger compatibility surface
replace the DSL with typed const values and a checked constructor ordinary IDE/refactor behavior; domain validation is explicit more call-site syntax; some invalid configurations fail at const evaluation or runtime

The review chooses the first design for the current service. Middleware becomes a separate typed wrapper around handlers. Route names become explicit domain data rather than generated identifiers. The registry’s test checks duplicates with a normal function, and a build gate runs that test. If duplicate rejection must later occur during compilation of downstream crates, that one requirement can justify reevaluating a procedural front end; it does not justify smuggling environment policy into the macro.

Now inspect a second proposed change: accepting both GET "/x" => handler and "/x" GET handler. Both are parseable. Supporting both provides no capability and doubles documentation, near-miss diagnostics, tests, and arm-order reasoning. The team rejects the alternate form. Ergonomics is not the number of syntaxes accepted; it is how reliably a reader can retrieve and correct the canonical syntax.

The same review considers a recursive sublanguage for grouping prefixes:

routes! {
    group "/v1" {
        GET "/health" => health,
        group "/accounts" {
            GET "/:id" => account,
        }
    }
}

This grammar requires recursive traversal and path composition. It may be worthwhile if groups also carry typed shared middleware and if most registries are deeply structured. It is not worthwhile merely to save repeated "/v1" text. Compile-time concatenation of literal contents, useful duplicate diagnostics across groups, and readable expansion all become harder. A normal builder can represent nested groups as values, validate them with ordinary code, and return a flat registry. The macro should win on a measured reader or correctness outcome, not on character count.

Finally, write the compatibility record before merging any grammar change:

accepted syntax added or removed:
arm priority affected:
definition-edition boundary affected:
generated items, names, and visibility changed:
runtime evaluation count or order changed:
expansion size at maximum supported input:
positive and rejected downstream fixtures:
function/trait/value alternative reconsidered:

This artifact turns a macro diff into a language review. It also gives the next chapter a clean handoff: if a requirement needs semantic parsing, value comparison, or precise literal spans, the decision to use a procedural macro begins with named evidence rather than enthusiasm for metaprogramming.

Macro ownership also needs an operational policy. Assign a maintainer for the grammar rather than treating it as a utility nobody owns. Track clean-build and incremental-build cost when a change increases expansion volume. Include the public invocation in documentation tests, but keep larger downstream fixtures in separate crates so lexical scope cannot hide export defects. When an edition or MSRV changes, rerun accepted and rejected boundary syntax before changing snapshots. When a diagnostic changes, decide whether the grammar changed, the compiler presentation changed, or an earlier arm accidentally captured the input.

Observability belongs mostly to build evidence, not generated runtime logging. A declarative macro cannot time itself through a stable language interface, and inserting logs into emitted code would change the application. Measure compilation externally and inspect representative expansion when cost regresses. At runtime, generated routes should expose the same metrics and tracing as handwritten routes through the ordinary router abstraction. The macro should not invent a parallel telemetry convention.

Panic and security review follow the same boundary. Expansion should not generate unwrap() merely because inputs were syntactically accepted; semantic failures still need typed handling. Do not include secret literals in compile_error! messages or stringify arbitrary configuration into public metadata. A declarative macro cannot perform I/O, which keeps its direct build-time authority narrower than a procedural macro, but it can still generate dangerous calls, enormous programs, or confusing unsafe blocks. Review the output under the chapter that owns those semantics.

Portability is another reason to keep the transcriber ordinary. Emit core paths when the runtime contract does not require std; gate platform-specific items with explicit caller-visible configuration; and test target-specific expansion in the actual target matrix. A macro that parses on the host but emits unavailable target APIs has not satisfied portability. Its compile-time success merely delayed the failure into generated code.

These concerns reinforce the selection rule: syntax compression alone is a weak justification. A good macro centralizes a relationship the type system will verify after expansion, preserves source-local intent, and makes repeated output easier to audit than handwritten variants. If it hides runtime policy, multiplies configurations, or requires specialized debugging for routine changes, the interface is charging more complexity than it removes.

Now extend the lab with an optional HEAD route only if the current ident grammar already admits it. Add duplicate-path rejection only after recording why macro_rules! cannot compare arbitrary literal values and whether that obligation belongs in an ordinary test, a const check, or the next chapter’s procedural architecture. The exercise is complete when the language decision—not merely another accepted token sequence—is reviewable.

Durable rules

  • macro_rules! matches token trees against ordered arms and transcribes Rust syntax; it is not textual replacement.
  • Fragment types, separators, cardinality, arm order, and definition edition form a public grammar.
  • Mixed-site hygiene prevents some capture but does not remove path, visibility, or invented-name obligations.
  • $crate names the defining crate; it neither bypasses privacy nor validates generated semantics.
  • Repetition is preferable for flat structure; recursive parsing needs an explicit expansion budget.
  • Precise rejection tests are as important as successful expansion tests.
  • Prefer a function, value, builder, or trait whenever Rust’s existing language expresses the contract more clearly.

Sources and version notes

Verified against the Rust 2024 language model and Rust 1.97.0 compiler evidence on 2026-07-12, with an additional Rust 1.85.0 MSRV pass. Exact diagnostic prose is not a stable language guarantee.

Procedural macros can inspect and validate richer syntax, but they do not erase the language-design obligations exposed here. They move those obligations into a compiler-executed program whose architecture, spans, dependencies, and security surface require their own review.