macro_rules! Is an NFA: How Rust Matches Repetitions Without Backtracking
rustc's macro_rules matcher is a Thompson NFA over the input tokens, with a thread set per input token and epsilon transitions for Kleene stars.
TL;DR:
macro_rules!matching in rustc is not a backtracking matcher. It is an NFA: for each input token, rustc maintains a set of live matcher positions, advances all of them, and follows epsilon transitions for repetitions. There is no rewind, no retry, and no exponential blowup from a rule like$($x:expr),*.
Matching macro_rules! is the part of macro expansion that everyone
implements badly. The obvious implementation is recursive backtracking: try
to match the rule, fail, back up, try the next rule. That is exponential in
the worst case and makes the error messages terrible.
rustc took the other road.
The module documentation is the specification
The best documentation of this design is the header of the implementation file, which is a tutorial in itself:
// compiler/rustc_expand/src/mbe/macro_parser.rs:1-14
//! This is an NFA-based parser, which calls out to the main Rust parser for named non-terminals
//! (which it commits to fully when it hits one in a grammar). There's a set of current NFA threads
//! and a set of next ones. Instead of NTs, we have a special case for Kleene star. The big-O, in
//! pathological cases, is worse than traditional use of NFA or Earley parsing, but it's an easier
//! fit for Macro-by-Example-style rules.
//!
//! (In order to prevent the pathological case, we'd need to lazily construct the resulting
//! `NamedMatch`es at the very end. It'd be a pain, and require more memory to keep around old
//! matcher positions, but it would also save overhead)
//!
//! We don't say this parser uses the Earley algorithm, because it's unnecessarily inaccurate.
//! The macro parser restricts itself to the features of finite state automata. Earley parsers
//! can be described as an extension of NFAs with completion rules, prediction rules, and recursion.
Two admissions right at the top: the big-O is worse than a textbook NFA in pathological cases, and the fix would require lazily constructing the results. Both are honest engineering notes.
How it works
// compiler/rustc_expand/src/mbe/macro_parser.rs:16-40
//! Quick intro to how the parser works:
//!
//! A "matcher position" (a.k.a. "position" or "mp") is a dot in the middle of a matcher, usually
//! written as a `·`. For example `· a $( a )* a b` is one, as is `a $( · a )* a b`.
//!
//! The parser walks through the input a token at a time, maintaining a list
//! of threads consistent with the current position in the input string: `cur_mps`.
//!
//! As it processes them, it fills up `eof_mps` with threads that would be valid if
//! the macro invocation is now over, `bb_mps` with threads that are waiting on
//! a Rust non-terminal like `$e:expr`, and `next_mps` with threads that are waiting
//! on a particular token. Most of the logic concerns moving the · through the
//! repetitions indicated by Kleene stars. The rules for moving the · without
//! consuming any input are called epsilon transitions. It only advances or calls
//! out to the real Rust parser when no `cur_mps` threads remain.
Three sets of threads:
cur_mps— positions that are live at the current input token;next_mps— positions waiting for a particular token;bb_mps— positions blocked on a Rust non-terminal ($e:expr);eof_mps— positions that would accept the input if it ended here.
And the worked example, which is worth reading in full because it makes the algorithm concrete:
// compiler/rustc_expand/src/mbe/macro_parser.rs:41-70
//! Example:
//!
//! ```text, ignore
//! Start parsing a a a a b against [· a $( a )* a b].
//!
//! Remaining input: a a a a b
//! next: [· a $( a )* a b]
//!
//! - - - Advance over an a. - - -
//!
//! Remaining input: a a a b
//! cur: [a · $( a )* a b]
//! Descend/Skip (first position).
//! next: [a $( · a )* a b] [a $( a )* · a b].
//!
//! - - - Advance over an a. - - -
//!
//! Remaining input: a a b
//! cur: [a $( a · )* a b] [a $( a )* a · b]
//! Follow epsilon transition: Finish/Repeat (first position)
//! next: [a $( a )* · a b] [a $( · a )* a b] [a $( a )* a · b]
//!
//! - - - Advance over an a. - - - (this looks exactly like the last step)
//!
//! Remaining input: a b
//! cur: [a $( a · )* a b] [a $( a )* a · b]
//! Follow epsilon transition: Finish/Repeat (first position)
//! next: [a $( a )* · a b] [a $( · a )* a b] [a $( a )* a · b]
//!
//! - - - Advance over an a. - - -
//!
//! Remaining input: b
//! cur: [a $( a · )* a b] [a $( a )* a · b]
//! Follow epsilon transition: Finish/Repeat (first position)
//! next: [a $( a )* · a b] [a $( · a )* a b] [a $( a )* a · b]
//!
//! - - - Advance over a b. - - -
//!
//! Remaining input: ''
//! eof: [a $( a )* a b ·]
//! ```
Notice that after the first a, there are already two live positions
(the “skip the repetition” and the “descend into the repetition” ones). The
set of threads is what makes the matcher non-backtracking: both
possibilities are explored simultaneously, and the one that fails simply
dies.
The matcher is a flat array of locations
// compiler/rustc_expand/src/mbe/macro_parser.rs:99-126
pub(crate) enum MatcherLoc {
Token {
token: Token,
},
Sequence {
op: KleeneOp,
num_metavar_decls: usize,
idx_first_after: usize,
next_metavar: usize,
seq_depth: usize,
},
SequenceKleeneOpNoSep {
op: KleeneOp,
idx_first: usize,
},
SequenceSep {
separator: Token,
},
SequenceKleeneOpAfterSep {
idx_first: usize,
},
MetaVarDecl {
span: Span,
bind: Ident,
kind: NonterminalKind,
next_metavar: usize,
seq_depth: usize,
},
Eof,
}and the comment above it explains why this matters:
This means a matcher can be represented by
&[MatcherLoc], and traversal mostly involves simply incrementing the current matcher position index by one.
A “dot” in a matcher is an index into a flat array. Advancing is
idx += 1. There are no linked lists, no tree walks, no allocation per
position. The KleeneOp variants encode the repetition structure with
back-references (idx_first_after, idx_first) into the same array, so a
repetition is a jump, not a recursive structure.
Threads are cheap, and the matches are shared
// compiler/rustc_expand/src/mbe/macro_parser.rs:245-262
/// A single matcher position, representing the state of matching.
#[derive(Debug)]
struct MatcherPos {
/// The index into `TtParser::locs`, which represents the "dot".
idx: usize,
/// The matches made against metavar decls so far. On a successful match, this vector ends up
/// with one element per metavar decl in the matcher. ...
///
/// It is critical to performance that this is an `Rc`, because it gets cloned frequently when
/// processing sequences. Mostly for sequence-ending possibilities that must be tried but end
/// up failing.
matches: Rc<Vec<NamedMatch>>,
}
// This type is used a lot. Make sure it doesn't unintentionally get bigger.
#[cfg(target_pointer_width = "64")]
rustc_data_structures::static_assert_size!(MatcherPos, 16);The matches vector is behind an Rc because when the matcher is inside a
repetition, each possible “how many repetitions so far” is a separate
thread, and they share the common prefix of matches. Rc::make_mut in
push_match gives copy-on-write semantics: threads that diverge pay for
the copy, threads that stay in sync share it.
And the size assertion: 16 bytes per thread. With a few dozen threads per input token and thousands of input tokens, that is the difference between a few kilobytes and a few megabytes.
Named non-terminals commit
The NFA covers the macro grammar. When it hits a Rust non-terminal like
$e:expr, it hands off:
calls out to the main Rust parser for named non-terminals (which it commits to fully when it hits one in a grammar)
“Commits to fully” is the key word. The NFA cannot run a Rust expression parser and then backtrack into the middle of it, so once a non-terminal is encountered, the parse of that fragment must succeed. This is why some macro_rules patterns that “should” work do not — and it is a deliberate trade: committing gives a real Rust parser error message when the fragment does not parse, instead of a macro error.
The pathological case, and the note about it
The header comment admits:
The big-O, in pathological cases, is worse than traditional use of NFA or Earley parsing, but it’s an easier fit for Macro-by-Example-style rules.
The reason is the eager construction of NamedMatches: each thread
carries the matches it has made so far, and those are built eagerly rather
than lazily. A pathological rule can make the set of threads grow
exponentially, each with a matches vector.
The fix would be to construct the matches only at the end, but then the matcher would have to keep every old position alive — more memory, more complexity. The comment says as much.
Where it lives
compiler/rustc_expand/src/mbe/macro_parser.rs— the whole thing, with an excellent module-level tutorial.compiler/rustc_expand/src/mbe/mod.rs— the rule representation and the expansion driver.
Takeaway
The NFA approach to macro_rules! is a good example of a technique that
solves the classic problem (backtracking blowup) by accepting a
different, rarer problem (thread explosion). And the implementation
documents its own trade-off in the first fourteen lines, which is more than
most compiler code manages.