Lesson 1.6 — Disambiguation: maximal munch, rule priority and Reps' linear-time munch¶
Techniques: maximal munch (longest match), rule priority (first rule wins ties), the quadratic worst case of naive maximal munch and Reps' linear-time memoized algorithm · Pebble implements: maximal munch with priority in the hand-written lexer (E2) and in the ★ table-driven lexer of the lab · Prerequisites: Lessons 1.2 and 1.5 · Time: 3–4 hours
A set of token rules rarely splits an input in only one way. <= could be < then =; iff could be the keyword if then an identifier f; 1..2 could be 1. then .2 if floats allowed a trailing point. Every lexer resolves this with two rules that go back to the first compilers: take the longest prefix any rule matches, and among rules that match that longest prefix, take the first one. This lesson makes the rules precise, shows the cost they can hide (a quadratic worst case), and gives Reps' algorithm that removes it.
1. Problem and motivation¶
The token rules define a language of tokens; the input is a word over that language in more than one way, or in no way at all. The lexer must pick one tokenization, deterministically, without the parser's help, and in linear time.
Maximal munch¶
"At each point, take the longest token" is the rule stated by the C standard (translation phase 3: "the next preprocessing token is the longest sequence of characters that could constitute a preprocessing token") and by virtually every language definition, including Pebble's (spec §3.6). It is simple, it needs only local information, and programmers can predict it. It is also sometimes wrong for the programmer's intent (a+++++b in C), and it can fail to tokenize an input that has a valid tokenization.
Rule priority¶
When two rules match the same longest prefix (the keyword if and the identifier rule on if), the earlier rule in the specification wins. Lex introduced this convention [FLEX-Manual]; lexer generators implement it by tagging each DFA state with the smallest rule index it accepts. Hand-written lexers get the same effect by looking up an identifier in a keyword table after scanning it (Lesson 1.8).
Reps' linear-time maximal munch¶
A DFA scanner that backs up to the last accepting position can re-read the same bytes from many token starts: \(\Theta(n^{2})\) on adversarial rule sets. Reps [Rep98] showed that memoizing which (state, position) pairs have already failed makes maximal munch linear in the input for every rule set. Production lexers do not need it (their rules have bounded backup), but it closes the gap between "DFA" and "linear time".
2. Definitions and algorithms¶
Definition 1.6.1 (Tokenization)
Let \(r_1, \dots, r_k\) be token rules with \(\varepsilon \notin L(r_i)\) and \(T = \bigcup_i L(r_i)\). A tokenization of \(w\) is a sequence \(w = t_1 t_2 \cdots t_m\) with every \(t_j \in T\). \(w\) may have zero, one or many tokenizations.
Definition 1.6.2 (Maximal-munch tokenization)
The maximal-munch tokenization of \(w\), \(\mathrm{MM}(w)\), is defined greedily: if \(w = \varepsilon\) it is empty; otherwise let \(t\) be the longest nonempty prefix of \(w\) in \(T\) and \(\mathrm{MM}(w) = t \cdot \mathrm{MM}(w')\) where \(w = t w'\). If no nonempty prefix is in \(T\), the lexer reports an error at this position (Pebble and the lab emit a one-byte error token and continue).
Definition 1.6.3 (Rule priority)
The kind of a token \(t\) is \(\mathrm{kind}(t) = \min \{\, i \mid t \in L(r_i) \,\}\). In a lexer DFA (the subset construction of the union of the rules, Algorithm 1.2.5 with rule tags), the tag of a state \(S\) is \(\min\{\, i \mid S\) contains an accepting NFA state of \(r_i \,\}\).
Definition 1.6.4 (Scan cost)
The cost of a scanner on \(w\) is the number of bytes it feeds to the DFA, counting the byte that leads to the dead state. A scanner backs up when it returns a token shorter than the prefix it has read.
Definitions on Pebble
With Pebble's punctuators, <= has tokenizations < = and <=; maximal munch picks <=. For a<<=b, MM gives a, <<, =, b (spec §3.6), because <<= is not a token. For iff, MM gives the single token iff, kind identifier; for if, both the keyword rule and the identifier rule match the whole prefix, and priority gives kw_if.
Maximal munch¶
Algorithm 1.6.5 (Maximal munch with backup)
- Input: a lexer DFA (states tagged per Definition 1.6.3, dead state \(\bot\)); input \(w\).
- Output: \(\mathrm{MM}(w)\) with kinds, or error tokens where no rule matches.
- Precondition: no rule matches ε.
- Postcondition: tokens as in Definitions 1.6.2 and 1.6.3 (Proposition 1.6.8).
- Invariant: at the top of the outer loop, the tokens emitted so far are \(\mathrm{MM}\) of the consumed prefix \(w_{0..\mathit{pos}}\).
function MaximalMunch(D, w):
pos ← 0; out ← []
while pos < |w|:
q ← q0; i ← pos; last ← none # last = (tag, end) of the longest accepted prefix
while i < |w|:
q ← δ(q, w[i])
if q = ⊥: break
i ← i + 1
if tag(q) ≠ none: last ← (tag(q), i)
if last = none:
append (error, w[pos]) to out; pos ← pos + 1
else:
append (rule last.tag, w[pos .. last.end)) to out; pos ← last.end # back up
return out
Maximal munch in Clang: a+++++b
Reproduce (clang 23.1.2; any OS):
printf 'int f(int a, int b) { return a+++++b; }\n' > t1.c
clang-23 -fsyntax-only -Xclang -dump-tokens t1.c 2>&1 | sed -n '12,16p'
clang-23 -fsyntax-only t1.c
Output (complete):
identifier 'a' Loc=<t1.c:1:30> [LeadingSpace]
plusplus '++' Loc=<t1.c:1:31>
plusplus '++' Loc=<t1.c:1:33>
plus '+' Loc=<t1.c:1:35>
identifier 'b' Loc=<t1.c:1:36>
t1.c:1:33: error: expression is not assignable
1 | int f(int a, int b) { return a+++++b; }
| ~~~^
1 error generated.
What to notice: a+++++b has the tokenization a ++ + ++ b, which would parse as (a++) + (++b). Maximal munch does not look for it: at each step it takes the longest token, so it produces a ++ ++ + b, and the parser then rejects (a++)++ because a++ is not an lvalue. The lexer never consults the grammar (Definition 1.6.2 is greedy by design).
Rule priority¶
Algorithm 1.6.6 (Priority by state tags)
- Input: rules \(r_1, \dots, r_k\) in priority order.
- Output: a lexer DFA whose states carry the tag of Definition 1.6.3; and, for the generator, the list of rules that can never win.
- Precondition: as Algorithm 1.2.5.
- Postcondition: a DFA state's tag is the kind of every word leading to it that is a token (Proposition 1.6.9); rule \(j\) is reported unmatchable iff no reachable state has tag \(j\).
- Invariant: every reachable DFA state \(S\) satisfies \(S = \hat\Delta(u)\) for the words \(u\) leading to it (Theorem 1.2.10).
function LexerDFA(r1, …, rk):
N ← new start state s with ε-edges to Thompson(ri).start for i = 1..k,
accept state of Thompson(ri) tagged i
D ← SubsetConstruction(N) # Algorithm 1.2.5
for S in D.states: tag(S) ← min { i | S contains the accept state tagged i } (none if empty)
for j in 1..k: if no S has tag(S) = j: warn "rule j cannot be matched"
return Minimize(D) with the initial partition by tag # Lesson 1.4
flex warns when priority makes a rule dead
Reproduce (flex 2.6.4; any OS):
cat > prio.l <<'EOF'
%option noyywrap nodefault
%%
[a-z]+ { return 1; }
"if" { return 2; }
.|\n { return 3; }
%%
EOF
flex -o prio.c prio.l
Output (complete):
What to notice: the identifier rule comes first, so every DFA state that accepts if also accepts rule 1 and gets tag 1 (Definition 1.6.3). No state has tag 2, and flex reports it: the last loop of Algorithm 1.6.6. Swap the two rules and the warning disappears; this is why keyword rules come before the identifier rule in every lexer specification.
Reps' linear-time maximal munch¶
Algorithm 1.6.7 (Reps' memoized maximal munch)
- Input: as Algorithm 1.6.5.
- Output: the same token sequence.
- Precondition: as Algorithm 1.6.5.
- Postcondition: same output as Algorithm 1.6.5 (Theorem 1.6.10), with cost \(O(\lvert Q \rvert \cdot \lvert w \rvert)\) (Proposition 1.6.12).
- Invariant: every pair \((q, i) \in \mathit{Failed}\) is hopeless: running the DFA from state \(q\) at position \(i\) never reaches an accepting state before dying or reaching the end of the input.
function RepsMunch(D, w):
Failed ← {}; pos ← 0; out ← []
while pos < |w|:
q ← q0; i ← pos; last ← none; trail ← []
while i < |w| and (q, i) ∉ Failed:
append (q, i) to trail
q ← δ(q, w[i])
if q = ⊥: break
i ← i + 1
if tag(q) ≠ none: last ← (tag(q), i); trail ← [] # the pairs before an accept are not hopeless
Failed ← Failed ∪ trail # everything after the last accept failed
emit exactly as Algorithm 1.6.5
return out
Failed is a bit table of \(\lvert Q \rvert \times (\lvert w \rvert + 1)\) bits, or a hash set.
flex reports the rules that force backing up
Reproduce (flex 2.6.4; any OS):
cat > munch.l <<'EOF'
%option noyywrap nodefault
%%
"." { return 1; }
"..." { return 2; }
[0-9]+ { return 3; }
.|\n { return 4; }
%%
EOF
flex -b -o munch.c munch.l
cat lex.backup
Output (complete):
State #7 is non-accepting -
associated rule line numbers:
4
out-transitions: [ . ]
jam-transitions: EOF [ \000-- /-\377 ]
Compressed tables always back up.
What to notice: after .. the scanner is in state 7, which accepts nothing (.. is not a token) but can still reach .... On ..x it reads x, dies, and backs up to the . it accepted after one character: Algorithm 1.6.5's backup. flex does not memoize failures, so a rule set whose backing-up states can chain (the {a, a*b} family of §5) gives it quadratic behavior; Reps' Failed table is the fix. Here the backup is bounded (at most two bytes), which is why production lexers do not bother.
3. Worked example¶
Maximal munch¶
Rules int = [0-9]+, float = [0-9]+\.[0-9]+, dot = \., dotdot = \.\. (Pebble's number/range fragment). The oracle's lexer DFA (states named in discovery order, classes "other", ., digit):
| state | other | . |
digit | tag |
|---|---|---|---|---|
| → A | – | B | C | |
| B | – | D | – | dot |
| C | – | E | C | int |
| D | – | – | – | dotdot |
| E | – | – | F | |
| F | – | – | F | float |
Maximal munch on 1..2:
| token start | DFA run (state after each byte) | last accept | token | reads |
|---|---|---|---|---|
| 0 | A →1 C (int) →. E →. dead |
(int, 1) | int:1 (backs up 1 byte) |
3 |
| 1 | A →. B (dot) →. D (dotdot) →2 dead |
(dotdot, 3) | dotdot:.. |
3 |
| 3 | A →2 C (int) → end |
(int, 4) | int:2 |
1 |
Seven reads for four bytes. The first scan shows why Pebble requires digits on both sides of the point (spec §3.4): state E, "digits and a point", is not accepting, so 1. is never a token and 0..10 stays a range.
Try it
./course drill maximal-munch --seed 2 --difficulty medium: the == rule without an = rule makes a lone = an error token after backing up.
Rule priority¶
Rules if (1) and [a-z]+ (2) on if iff. The DFA state after if contains the accepting states of both rules: tag \(\min\{1, 2\} = 1\), kind kw_if. After iff only rule 2 accepts: identifier. With the rules in the opposite order, the state after if would get tag 1 = the identifier rule, and the keyword rule would never win (the flex box above).
Reps' linear-time maximal munch¶
Rules a (1) and ab = a*b (2), input \(a^{8}\), no b anywhere. Naive maximal munch (oracle maximal_munch):
| token start | bytes read before dying at the end | token | reads |
|---|---|---|---|
| 0 | positions 0–7 (hoping for a b) |
a:a |
8 |
| 1 | 1–7 | a:a |
7 |
| 2 | 2–7 | a:a |
6 |
| … | … | … | … |
| 7 | 7 | a:a |
1 |
Total \(8 + 7 + \cdots + 1 = 36 = \frac{8 \cdot 9}{2}\) reads. Reps' algorithm (oracle maximal_munch_reps) scans from 0 to the end once and marks as hopeless the seven (state, position) pairs it visited after its last accept (at position 1); from then on each scan stops two bytes in, at a marked pair:
| token start | reads | new hopeless pairs | total marked |
|---|---|---|---|
| 0 | 8 | 7 | 7 |
| 1 | 2 | 1 | 8 |
| 2 | 2 | 1 | 9 |
| 3 | 2 | 1 | 10 |
| 4 | 2 | 1 | 11 |
| 5 | 2 | 1 | 12 |
| 6 | 2 | 1 | 13 |
| 7 | 1 | 0 | 13 |
21 reads instead of 36; for \(a^{32}\) the counts are 528 and 93 (Proposition 1.6.11 and 1.6.12).
4. Invariants and correctness¶
Maximal munch¶
Proposition 1.6.8 (Maximal munch is well defined, deterministic, and sometimes too greedy)
(i) \(\mathrm{MM}(w)\) is unique, and Algorithm 1.6.5 computes it. (ii) There are rule sets and inputs with a tokenization but for which \(\mathrm{MM}\) fails.
Proof
(i) The longest prefix in \(T\) is unique when it exists, so the greedy definition determines one sequence; by induction on the number of emitted tokens (the invariant), each outer iteration emits exactly the longest accepted prefix, by Proposition 1.5.12 applied to the lexer DFA. (ii) Rules a, ab, bc and input abc: the tokenization a bc exists, but the longest first token is ab and c matches no rule. \(\square\)
Rule priority¶
Proposition 1.6.9 (State tags give the kind)
In the lexer DFA of Algorithm 1.6.6, if \(\hat\delta(q_0, t) = S\) and \(t \in T\), then \(\mathrm{tag}(S) = \mathrm{kind}(t)\). Minimization with the initial partition by tag preserves this.
Proof
By Theorem 1.2.10, \(S = \hat\Delta(t)\), the set of NFA states reachable on \(t\). The accepting state of rule \(i\)'s Thompson fragment is in \(S\) iff \(t \in L(r_i)\) (Theorem 1.1.10, the fragments being disjoint below the fresh start). So the minimum tag in \(S\) is \(\min\{i \mid t \in L(r_i)\} = \mathrm{kind}(t)\). Minimization merges only states with the same tag (initial partition), and the quotient preserves the tag of every class. \(\square\)
Reps' linear-time maximal munch¶
Theorem 1.6.10 (Reps' algorithm is correct)
Algorithm 1.6.7 emits the same tokens as Algorithm 1.6.5, and its invariant holds throughout.
Proof sketch (full proof: [Rep98, §3])
Invariant: a pair is added to Failed only if it was visited after the last accepting configuration of a scan that then died or hit the end of the input (the trail is reset at every accept). From such a pair the deterministic run is the remainder of that scan, which reaches no accepting state: the pair is hopeless. If the scan stopped because it met a pair already in Failed, the pairs on its trail lead deterministically into a hopeless pair without an accept in between, so they are hopeless too. Equivalence: at any token start, the naive scan and the memoized scan follow the same DFA path; the memoized one stops early only at a hopeless pair, beyond which the naive scan would find no further accept. Both therefore end with the same last, and emit the same token.
5. Complexity¶
Variables: \(n = \lvert w \rvert\); \(\lvert Q \rvert\) = lexer DFA states; \(b\) = maximal backup (the longest distance from an accepting configuration to the byte where the scan dies), when bounded.
| Technique | Cost (worst) | Cost (typical) | Space |
|---|---|---|---|
| Maximal munch (Algorithm 1.6.5) | \(\Theta(n^{2})\) | \(O(n \cdot (1 + b))\) with bounded backup | \(O(1)\) |
| Rule priority (tags) | no run-time cost | – | one tag per state |
| Reps (Algorithm 1.6.7) | \(O(\lvert Q \rvert \cdot n)\) | \(O(n)\) | \(O(\lvert Q \rvert \cdot n)\) bits, or a hash set of visited pairs |
Proposition 1.6.11 (Naive maximal munch is quadratic)
For rules a and a*b and input \(a^{n}\), Algorithm 1.6.5 reads exactly \(n(n+1)/2\) bytes.
Proof
Every token is a (no b exists), so there are \(n\) token starts \(0, \dots, n-1\). From start \(i\), the DFA stays alive through all remaining \(a\)'s because a*b could still match, and dies only at the end of the input: \(n - i\) reads, with the last accept after the first byte. \(\sum_{i=0}^{n-1} (n - i) = n(n+1)/2\). The oracle measures 10, 36, 136, 528 for \(n = 4, 8, 16, 32\). \(\square\)
Proposition 1.6.12 (Reps' algorithm is linear)
Algorithm 1.6.7 feeds at most \((\lvert Q \rvert + 1)(n + 1)\) bytes to the DFA.
Proof
Each read happens from a pair \((q, i)\) with \(i < n\), just appended to a trail, and each scan's reads split at its last accept. Reads before the last accept (from positions \(\mathit{pos}, \dots, \mathit{last.end} - 1\)) are one per byte of the emitted token, and each byte of the input belongs to exactly one token: at most \(n\) in total. Reads after the last accept, including the read that leads to \(\bot\), come from pairs on the final trail (it is reset at every accept, and a scan without any accept keeps all its pairs), and the whole trail goes into Failed. A pair in Failed stops every later scan before it is read from, so each of the at most \(\lvert Q \rvert\, n\) pairs with \(i < n\) is read from at most once in this role. Total: at most \(\lvert Q \rvert\, n + n \le (\lvert Q \rvert + 1)(n + 1)\). \(\square\)
Pathological input. \(\Theta(n^{2})\) needs a rule whose prefixes are all non-accepting "promises" (a*b) next to a short rule (a). Real token sets have bounded backup (C: .. before ...; Pebble: 1. before 1.5, at most one byte), so \(b \le 2\) and naive maximal munch is linear in practice.
At scale. The lab's table lexer (naive backup) runs Pebble at 90 MB/s (ch01-regexbench); Pebble's rules back up at most one byte (1. then .).
6. Variants and refinements¶
Maximal munch¶
- Exceptions in the standard (C++ [lex.pptoken]p3):
<::is lexed as<::unless followed by:or>, sostd::vector<::std::string>works; Clang implements it in the'<'case ofLexTokenInternal. Such exceptions are hand-written special cases. - Parser-driven splitting: C++
>>in templates is lexed as one token and split by the parser (Lesson 1.7); the lexer stays greedy.
Rule priority¶
- Explicit priorities (some generators allow numeric priorities instead of textual order), and longest-then-priority (the standard) vs priority-then-longest (PEG-style ordered choice, where the first rule that matches wins regardless of length).
- Keyword lookup after the fact (hand-written lexers): scan an identifier, then consult a keyword table (Lesson 1.8); priority becomes "keywords win when the whole identifier is a keyword".
Reps' linear-time maximal munch¶
- Bounded-backup analysis (flex
-b): detect at generation time which states can back up, and how far; if bounded, the naive algorithm is linear with constant \(b\). - Tokenization by regular-expression parsing (Sulzmann–Lu POSIX submatching with derivatives [SL14]): compute the longest-match split of a whole input as one POSIX parse of \((r_1 \mid \cdots \mid r_k)^{*}\), whose disambiguation rule is maximal munch; linear in the input for a fixed expression.
7. In real compilers¶
Maximal munch¶
- Clang
clang/lib/Lex/Lexer.cpp— the lookahead ladders inLexTokenInternal('+','<','.'cases); the<::exception is in the'<'case (LLVM 23.1.2) [CLANG-Lexer]; box in §2. - Pebble spec §3.6 and
solutions/pebble/lib/Lex/src/Lexer.cpp(&-,<<,..).
Find where LLVM does it. In clang/lib/Lex/Lexer.cpp at llvmorg-23.1.2, find the comment quoting "C++0x [lex.pptoken]p3" in LexTokenInternal. Question: what does Clang return for <:: when the next character is neither : nor >? (Quiz llvm-lex-pptoken-exception.)
Rule priority¶
- flex: textual order, and the "rule cannot be matched" warning (flex 2.6.4) [FLEX-Manual]; box in §2.
- re2c: the same convention; re2c also warns about unreachable rules.
- The course ★ lab:
labs/ch01-regex/inputs/pebble.lexspeclists the 21 keywords beforeidentifier.
Reps' linear-time maximal munch¶
- flex
-bgenerateslex.backup(flex 2.6.4) [FLEX-Manual]; box in §2. We know of no production lexer that implements Reps' table: bounded backup makes it unnecessary. - The course oracle
tools/course/lib/regex.py—maximal_munchandmaximal_munch_reps; themaximal-munchdrill (hard) asks for the naive read count.
8. Comparison¶
| Technique | Power / precision | Speed | Output / error quality | Implementation effort | Typical use |
|---|---|---|---|---|---|
| Maximal munch | deterministic; may miss a valid tokenization | \(O(n)\) with bounded backup, \(\Theta(n^{2})\) worst | predictable for programmers; errors surface in the parser (a+++++b) |
trivial on a DFA | every lexer |
| Rule priority | resolves equal-length ties | free at run time | generators can warn about dead rules | trivial | keywords vs identifiers |
| Reps' memoized munch | same tokens as maximal munch | \(O(\lvert Q \rvert n)\) worst | same | small (a failure table) | theory; tools that accept arbitrary rule sets |
Choose maximal munch with priority always; it is what language specifications mean. Add Reps' memoization when users supply arbitrary rule sets (a lexer generator library, a regex tokenizer) and you cannot bound the backup.
9. Assessment¶
| Technique | Quiz ids | Drill | Flashcard tag | Exercises |
|---|---|---|---|---|
| Maximal munch | munch-tokens, llvm-lex-pptoken-exception |
./course drill maximal-munch |
maximal-munch |
E2 |
| Rule priority | priority-order, munch-tokens |
./course drill maximal-munch --difficulty easy |
rule-priority |
Lab L7 ★ |
| Reps' linear-time maximal munch | reps-reads, munch-quadratic |
./course drill maximal-munch --difficulty hard (asks the naive read count) |
reps |
— |
Pitfall
Maximal munch is not "the tokenization the parser needs". It is local and greedy; when it disagrees with the grammar (a+++++b, C++ >>), the fix is either a special case in the lexer or a parser that splits tokens, never a smarter global search in the lexer.
References¶
See the chapter references.