Pass 3 — Lowering¶
Entry point: lowering::lower (src/lowering/mod.rs).
Output: a StateTable — the flat, backend-agnostic representation
of both the parser and the lexer.
Lowering produces two things: the lexer DFA (what the runtime
consults to turn bytes into tokens) and the parser state machine
(what consumes those tokens). The DFA is the simpler of the two and
runs first at parse time, so this chapter covers it first. The state
machine is then built by three sub-passes — build, layout,
and fuse — the last of which also triggers DFA compilation as
part of laying out the final StateTable.
The StateTable¶
The artifact handed to code generation holds:
grammar_name— copied from theGrammar, used by backends for file and package names.tokens: Vec<TokenInfo>— non-fragment tokens, each with its resolved pattern (fragments inlined), itsskipflag, and a stable numeric kind id (1-based;0is EOF,-1is lexer ERROR).rule_kinds: Vec<String>— names of the non-fragment rules in declaration order. A rule’sRuleKindid is its index here.first_sets: Vec<FirstSet>— the interned FIRST-set pool. Each entry is aVec<Vec<i16>>: a set of token-id sequences.sync_sets: Vec<Vec<i16>>— the interned SYNC-set pool. Each entry is a flat list of token ids.states: BTreeMap<StateId, State>— every parser state, keyed by id. EachStateholds a label and a short straight-line program ofOps.entry_states: Vec<(String, StateId)>— public entry points, one per non-fragment rule.eof_id,error_id— the reserved sentinel kinds.k— the grammar’s LL(k).lexer_dfa— the compiled lexer DFA.
The lexer DFA¶
File: src/lowering/lexer_dfa.rs.
At runtime the lexer turns the source bytes into a stream of tokens before the parser state machine ever runs, so it is the natural place to start. The DFA is compiled from the list of non-fragment tokens — with fragment references resolved by the build phase beforehand — using standard Thompson construction plus subset determinization:
NFA construction. Each token’s
TokenPatternis compiled to an NFA fragment whose end state accepts that token’s kind id.Seqconcatenates fragments;Altε-joins them at both ends;Opt/Star/Plususe the classical ε-transition patterns.Refpatterns are unreachable at this point — fragments are resolved earlier — so reaching one is a bug.Top-level alternation. All tokens share a single start state via ε-transitions. This is what lets the lexer try every pattern in parallel.
Subset construction. The NFA is determinized into a flat
Vec<DfaState>. Each state carries an optionalaccept: i16plus its byte transitions already grouped intoByteArmruns (bytes sharing a target collapse into one arm; contiguous bytes within an arm collapse into ranges). State0([DEAD]) is reserved as the dead sink — every missing transition lands there, so the runtime’s inner loop is a single branch (“exit on 0”). The start state is always1([START]). The accept kind for a DFA state is the minimum token id present in the collapsed NFA states, which encodes “declaration order = priority”: tokens declared earlier win on ties.
At runtime the lexer implements longest match: advance until the
transition table lands on dead, then back up to the last DFA state
that had an accept. The skip flag on TokenInfo is read by
the parser’s pump, not the DFA — skip tokens are matched the same
way as any other, they are just routed to a side queue on their way
into the event stream.
The bytes the DFA consumes are UTF-8 octets. Character classes with multi-byte codepoints expand to multi-byte NFA paths, so the DFA implicitly handles UTF-8 without a separate decoder.
The state-machine op set¶
Each state is a small straight-line program over these ops:
Enter(kind)/Exit(kind)Emit a structural event.
Expect { kind, token_name, sync }Consume a token of
kind; on mismatch, emit an error and recover to the given SYNC set.PushRet(state)Push a return address onto the call stack. At layout time it is followed by a
Jumpinto the callee, but fuse may splice thatJumpaway by inlining the callee’s leading straight-line ops, in which casePushRetis followed directly by whatever ops were absorbed (Enter/Exit/Expect/anotherPushRet/Ret). The semantics are unchanged — the return address is on the stack before any absorbed op runs.Jump(state)Unconditional transfer.
RetPop the top return address (or terminate if the stack is empty).
Star { first, body, next }While the lookahead matches
first, callbodyand re-enter this state. Otherwise fall through tonext.Opt { first, body, next }If the lookahead matches
first, callbodyonce and continue atnext. Otherwise skip straight tonext.Dispatch { tree, sync, next }Pick one
Altarm using aDispatchTreeover up toktokens of lookahead, or recover viasyncon no match.
DispatchTree is a flat decision tree: Leaf(action) or
Switch { depth, arms, default }. Each Switch inspects
look(depth).kind and branches into sub-trees — this maps
directly onto nested switch statements in every target.
Sub-pass 3a — Build¶
File: src/lowering/build.rs.
Output: a Program (blocks with symbolic block ids, interned
FIRST/SYNC pools, token metadata).
The build phase walks each rule body and emits a block of block-level
ops. It is a faithful translation of the Expr tree:
Expr::Token(n)→Op::Expect, carrying the token name and the current rule’s SYNC id.Expr::Rule(n)→Op::Call { target: Target::Rule(n) }. The call target is symbolic — the actual block id is resolved in sub-pass 3b.Expr::Seq(xs)→ the children are emitted into the same block, each with an accurately computed tail (FIRST of the remainder plus the outer tail) so their prediction sets are precise.Expr::Alt(xs)→ oneOp::Dispatch. Each non-wholly-nullable arm becomes a sub-block whose entry id is stored alongside its FIRST-set id.has_epssummarizes whether any arm is nullable, which is what turns the outer dispatch default from “error” to “fall through”.Expr::Opt(x)/Expr::Star(x)→ oneOp::Opt/Op::Starplus a sub-block for the body.Expr::Plus(x)→ one mandatoryOp::Callinto the body’s sub-block, followed by anOp::Starover the same sub-block. The body is lowered once and shared.
Public rules are wrapped in Op::Enter / Op::Exit pairs so
their subtree appears in the event stream. Fragment rules are emitted
raw — calling into a fragment is indistinguishable from inlining its
body.
FIRST-set interning. Every prediction set that appears in an op is deduplicated through a hash table and replaced with a numeric id. This lets two syntactically-similar grammar fragments share a single table entry in the final output, which keeps the emitted source small.
SYNC-set computation. At the start of each rule, the build phase
computes the rule’s recovery set (FOLLOW(rule) ∪ {EOF}), interns
it, and caches the id in current_sync for the duration of that
rule’s lowering. Every Expect the builder emits stamps this id
on itself, so recovery at any point in the rule hits the same set.
Labels. Each op carries a human-readable label
(expr:alt0:call:atom) used in debug dumps and as a comment in the
generated code. Sub-blocks extend the parent’s label so the hierarchy
is visible without extra bookkeeping.
Sub-pass 3b — Layout¶
File: src/lowering/layout.rs.
Layout turns the block-level IR into numbered parser states and resolves every symbolic target into a concrete state id.
Steps:
Block ordering. BFS from each public rule’s entry block; each block reached is appended to an
orderlist. This keeps a rule and everything it transitively reaches adjacent in the state numbering, which is what makes debug dumps and generated switch tables readable.Entry-id assignment. Each block is assigned a starting
StateId, reserving one slot per op plus one for a trailingRetstate. Id0is reserved for theTERMINATEDsentinel; real ids start at1.Op lowering. Each block-level op is translated to a state-level op sequence:
Straight-line ops (
Enter,Exit,Expect,Call) are followed by an explicitJump(fall)so each state has a visible exit.A
Call { target }becomesPushRet(fall); Jump(resolve(target)).Branchy ops (
Opt,Star,Dispatch) encodenextdirectly inside the op and do not need a trailing jump.
Dispatch-tree construction. The block-level
Op::Dispatchcarries a flat list of(first_set_id, target)arms. Layout expands those into aDispatchTreeviabuild_dispatch_tree: each arm contributes one entry per sequence in its FIRST set, and the trie groups entries by theirdepth-th token at each level. An arm whose prediction sequence runs out at depth d terminates the branch with that arm’s id, shadowing the outer default for any deeper prefixes.Lexer DFA.
lexer_dfa::compileis called on the token list (see the The lexer DFA section above). The resultingVec<DfaState>is stored on the state table.
Output: a StateTable — complete but not yet optimised.
Sub-pass 3c — Fuse¶
File: src/lowering/fuse.rs.
Fuse performs two small but important clean-ups:
Jump-chain splicing. A state whose last op is
Jump(T)can often absorbT’s ops directly, turningA → B → C → Dinto a singleA. The trailingJumpis popped andT’s ops are appended in its place. Splicing stops only whenT’s first op is branchy (Dispatch,Opt, orStar) — those states are usually shared entry points and inlining them would duplicate the control-flow structure — otherwise any successor is fair game, soRet,Enter,Exit,Expect, andPushRetall get absorbed when they lead a target state. A visited-set guards against jump chains that loop back on themselves, and aDEFAULT_MAX_DEPTHbound limits how many successors a single splice absorbs.One consequence worth calling out: the
[PushRet(fall), Jump(callee)]pair thatCalllayouts into is a normal splice candidate. After fuse, theJumpinto the callee is often gone and the callee’s leading ops are pasted directly after thePushRet. The return address still sits on the stack before any of the absorbed ops run, so the semantics are unchanged.Dead-state elimination. BFS from the public entry points, following every reachable target (
Jump,PushRet,Opt.body,Opt.next, every leaf of everyDispatchTree). Any state not reached is removed from the table.
The result is the StateTable that reaches code generation.
Interactions between build, layout, and fuse¶
A few design notes that matter when reading the source:
BlockIdandFirstSetId/SyncSetIdlet the build phase stay symbolic. The layout phase resolves block ids intoStateId; the intern pools are passed through to the state table verbatim. Backends never see block ids.Every
Expectinside a rule refers to the same SYNC set — computed once from the rule’s FOLLOW set. That’s why the intern pool stays small even for grammars with many productions.Fuse runs last on purpose. Splicing before dispatch-tree construction would obscure the control-flow shape the tree is built around; splicing after eliminates the single-op, single-exit states that the naïve layout produces so the emitted
switchhas fewer labels without changing the semantics.
What comes out¶
A StateTable is all a backend needs. From this point forward
nothing about the grammar source, the FIRST/FOLLOW computation, or
the block-level IR is visible — the code-generation pass sees only
states, ops, intern pools, and the lexer DFA.