Skip to content

Lesson 2.7 — Syntax error recovery in top-down parsers

Techniques: panic mode with FOLLOW-based synchronizing sets, phrase-level recovery, insertion/deletion repair (local single-token and global minimum-distance) · Pebble implements: panic mode (lab exercise E6) · Lab: labs/ch02-ll1-toolkit (SPEC, contract parseWithRecovery; ll1 parse --recover) · Prerequisites: Lesson 2.5 · Time: 3–4 hours

A compiler that stops at the first syntax error makes you recompile once per typo. The predictive parser of Lesson 2.5 detects an error at the first token that cannot continue a sentence (Theorem 2.5.9); this lesson is about what it does next: get back into a state from which parsing can continue, report each real error once, and avoid a cascade of spurious ones. The three families trade simplicity (skip tokens until something familiar appears) against precision (guess the edit the programmer forgot).

1. Problem and motivation

Input: an LL(1) parser that has just found an error (top of stack \(X\), lookahead \(a\), \(M[X, a]\) empty or \(X \ne a\)). Output: a changed configuration (stack and/or input) from which parsing resumes, plus a diagnostic. Requirements: always terminate, report at least one error per real mistake, and avoid reporting errors the programmer did not make. pebblec uses panic mode in its hand-written parser, like Clang and Go.

Panic mode with FOLLOW-based synchronizing sets

Skip input tokens until one appears that the parser can synchronize on, then pop the stack to a state that expects it. Wirth's recursive-descent compilers passed "stop symbol" sets down the calls [Wir76]; for table-driven LL parsers the Dragon book fills the empty cells \(M[A, a]\) with "synch" when \(a \in \mathrm{FOLLOW}(A)\) [ALSU07 §4.4.5]. It is simple, never loops (Theorem 2.7.9), and loses some text after each error.

Phrase-level recovery

Fill each empty table entry with a small routine that makes a local correction: insert the missing ), delete the stray ), insert a missing operand. Graham and Rhodes systematized local phrase-level corrections [GR75]; hand-written parsers do the same with messages such as "expected ';'" and a fix-it hint.

Insertion/deletion repair

Search for the smallest edit to the token stream that makes the program parse. Irons proposed error-correcting parsing [Iro63]; Aho and Peterson gave a minimum-distance error-correcting parser for any CFG in \(O(n^{3})\) [AP72]; Fischer, Milton and Quiring showed that LL(1) parsers can repair with insertions only for a large class of grammars [FMQ80]; Burke and Fisher combined local and global repair for LL and LR [BF87]. ANTLR 4's default strategy uses the cheapest local version: delete one token or insert one token when that lets parsing continue, otherwise fall back to panic mode [ANTLR4-ErrorStrategy].

2. Definitions and algorithms

Configurations and moves are those of Definition 2.5.1; \(M\) is conflict-free.

Definition 2.7.1 (Error configuration, synchronizing set, cascade)

An error configuration is a configuration \((X \gamma, i)\) with lookahead \(a = t_{i+1}\) in which no move of Definition 2.5.1 applies: \(X \in T\) and \(X \ne a\); or \(X \in N\) and \(M[X, a] = \emptyset\); or \(X = \$\) and \(a \ne \$\). A synchronizing set \(\mathrm{SYNC}(A) \subseteq T \cup \{\$\}\) is the set of lookaheads on which recovery pops \(A\); here \(\mathrm{SYNC}(A) = \mathrm{FOLLOW}(A) \cup \{\$\}\). A reported error is a cascade if it is caused by an earlier recovery rather than by a mistake in the input.

Definition 2.7.2 (Edits and repair distance)

An edit inserts one token or deletes one token (a substitution counts as two edits). The repair distance \(d(w)\) of \(w \in T^{*}\) is the minimum number of edits that turn \(w\) into a sentence of \(L(G)\) (finite whenever \(L(G) \neq \emptyset\): delete everything, insert a shortest sentence). For \(X \in N \cup T\) and a span, \(\mathrm{cost}(X, i, j)\) is the minimum number of edits that turn \(t_{i+1} \cdots t_j\) into a string of \(L(X)\).

Distance on the running example

For the expression grammar of §3, \(d(\mathtt{id}\ {+}\ {*}\ \mathtt{id}\ {)}) = 2\): deleting * and ) gives id + id, and no single edit yields a sentence (the input has one surplus ) and a + directly followed by *, two independent defects). \(d(\mathtt{id}\ {)}) = 1\).

Panic mode with FOLLOW-based synchronizing sets

Algorithm 2.7.3 (RecoverPanic)

  • Input: an error configuration \((X \gamma, i)\) of the driver of Algorithm 2.5.4.
  • Output: a new configuration and one recorded error.
  • Precondition: \(M\) conflict-free; FOLLOW sets final.
  • Postcondition: the new configuration has either a shorter stack or a larger \(i\); nothing was pushed (Lemma 2.7.8).
  • Invariant: the driver, with this routine in place of return error, always reaches \((\$, n)\) (Theorem 2.7.9).

Reference solution: solutions/labs/ch02-ll1-toolkit/src/PredictiveParser.cpp; contract ll1::parseWithRecovery. The driver of Lesson 2.5 calls it instead of returning an error and continues its loop.

function RecoverPanic(stack, i):                      # called with top X, lookahead a = t(i)
    record an error at position i
    if X = $:                         skip every remaining token;  return        # extra input
    if X is a terminal:               pop X;  return                            # pretend X was there
    if a ∈ FOLLOW(X) or a = $:        pop X;  return                            # X derived "nothing"
    i ← i + 1                                                                   # skip a, keep X

Clang and Go synchronize after an error

Reproduce (run with clang 23.1.2 and gofmt from go 1.24.7; the Go source pointer [GO-Parser] is pinned at go1.23.0, which has the same advance(stmtStart) recovery; any OS):

cat > panic.c <<'EOF'
int f(int a, int b) {
  int x = a + ) b * 2;
  int y = x * b;
  return y + ;
}
EOF
clang -fsyntax-only panic.c
cat > panic.go <<'EOF'
package p

func f(a, b int) int {
    x := a + ) b * 2
    y := x * b
    return y +
}
EOF
gofmt -e panic.go

Output (complete; both commands exit with status 1 or 2):

panic.c:2:15: error: expected expression
    2 |   int x = a + ) b * 2;
      |               ^
panic.c:4:14: error: expected expression
    4 |   return y + ;
      |              ^
2 errors generated.
panic.go:4:11: expected operand, found ')'
panic.go:6:2: expected ';', found 'return'
panic.go:7:1: expected operand, found '}'
panic.go:7:3: expected ';', found 'EOF'
panic.go:7:3: expected ';', found 'EOF'
panic.go:7:3: expected '}', found 'EOF'

What to notice: two real mistakes. Clang reports exactly two errors: after expected expression it skips ) b * 2 with Parser::SkipUntil(tok::semi, …) [CLANG-Parser], resynchronizes on ; (in FOLLOW of the declaration's initializer) and parses line 3 normally. Go's parseOperand calls (*parser).advance(stmtStart) [GO-Parser], whose synchronizing set is statement keywords (§6), so it skips the correct line 5 entirely and then reports a cascade, expected ';', found 'return'; the three errors at end of file are cascades of the second mistake. Both are Algorithm 2.7.3 with different SYNC sets (Definition 2.7.1).

Phrase-level recovery

Algorithm 2.7.4 (RecoverPhrase)

  • Input: an error configuration, a table routine[X, a] of hand-designed corrections (wildcards allowed).
  • Output: a new configuration (stack and/or input edited) and one recorded error with the routine's message.
  • Precondition: each routine inserts a token, deletes the lookahead, or pops; unmatched entries fall back to panic mode.
  • Postcondition: at most one insertion per input position before a token is consumed (the guard); termination (Proposition 2.7.10).
  • Invariant: if no routine pops, the tokens matched so far, with the inserted ones, form a prefix of a sentence.

Oracle: phrase_level_parse.

function RecoverPhrase(stack, input, i, routine):
    record an error at position i with the routine's message
    (message, action) ← routine[X, a], or panic mode if there is none
    if action = insert t:
        if something was already inserted at position i: action ← delete   # termination guard
        else: insert t into the input before position i;  return
    if action = delete and a ≠ $: remove t(i) from the input;  return
    pop X                                                    # action = pop, or delete at $

Clang and rustc insert the missing ;

Reproduce (run with clang 23.1.2 and rustc 1.94.1; the rustc source pointer [RUSTC-Parser] is pinned at 1.90.0; any OS):

cat > semi.c <<'EOF'
int g(int a) {
  int x = a + 1
  return x;
}
EOF
clang -fsyntax-only -fdiagnostics-parseable-fixits semi.c
cat > semi.rs <<'EOF'
pub fn g(a: i32) -> i32 {
    let x = a + 1
    x
}
EOF
rustc --edition 2021 --crate-type lib --emit=metadata -o /dev/null semi.rs

Output (complete):

semi.c:2:16: error: expected ';' at end of declaration
    2 |   int x = a + 1
      |                ^
      |                ;
fix-it:"semi.c":{2:16-2:16}:";"
1 error generated.
error: expected `;`, found `x`
 --> semi.rs:2:18
  |
2 |     let x = a + 1
  |                  ^ help: add `;` here
3 |     x
  |     - unexpected token

error: aborting due to 1 previous error

What to notice: both front ends run a phrase-level routine for the cell (end of a declaration or let, lookahead return / x): insert ; and continue as if it had been there, so the next line parses without a cascade. The fix-it: line is the machine-readable record of that insertion (an edit of Definition 2.7.2 at line 2, column 16), which IDEs apply with one click; in Clang it comes from Parser::ExpectAndConsume / ExpectAndConsumeSemi [CLANG-Parser], in rustc from expected_one_of_not_found [RUSTC-Parser].

Insertion/deletion repair

Algorithm 2.7.5 (RecoverLocal: single-token repair)

  • Input: an error configuration with top \(X\), lookahead \(a = t_i\) and next token \(b = t_{i+1}\).
  • Output: a new configuration: \(a\) deleted, \(X\) popped as "missing", or a panic-mode step.
  • Precondition: \(M\) conflict-free.
  • Postcondition: every action consumes an input token or pops a stack symbol (Proposition 2.7.12).
  • Invariant: as for panic mode: nothing is pushed.

Oracle: local_repair_parse.

function RecoverLocal(stack, i):                       # top X, lookahead a = t(i), next b = t(i+1)
    record an error
    if X is a terminal:
        if b = X:                          delete a;  return                   # single-token deletion
        if CanContinue(stack without X, a): pop X;  report "missing X";  return # single-token insertion
        pop X;  return                                                          # panic
    if X is a nonterminal and M[X, b] has one production: delete a;  return
    RecoverPanic(stack, i)

function CanContinue(stack, a):                        # could the parser move on a?
    Y ← top(stack)
    return (Y = $ and a = $) or (Y is a terminal and Y = a) or (Y ∈ N and |M[Y, a]| = 1)

Algorithm 2.7.6 (MinEditCost: global minimum-distance repair [AP72])

  • Input: \(G\) and \(w = t_1 \cdots t_n\).
  • Output: \(d(w)\).
  • Precondition: \(L(G) \neq \emptyset\).
  • Postcondition: returns \(\mathrm{cost}(S, 0, n) = d(w)\) (Theorem 2.7.11).
  • Invariant: every table entry is an upper bound on the true cost of its span (an edit sequence of that cost exists), and the entries only decrease.

Oracles: min_edit_cost and the brute-force min_repair.

function MinEditCost(G, t1 … tn):
    cost(t, i, j) ← (j − i − 1) if t occurs in t(i+1) … t(j) else (j − i + 1)   # keep one, delete the rest
    cost(A, i, j) ← ∞ for all A, i ≤ j
    repeat                                             # least fixed point: equal spans recurse
        for each span length ℓ = 0 … n, each i, j = i + ℓ, each A → Y1 … Yk:
            c ← min over split points i = m0 ≤ m1 ≤ … ≤ mk = j of Σr cost(Yr, m(r−1), mr)
            (for k = 0, c ← j − i: delete everything)
            cost(A, i, j) ← min(cost(A, i, j), c)
    until nothing changed
    return cost(S, 0, n)

ANTLR 4's single-token deletion and insertion

Reproduce (ANTLR 4.13.2 complete jar, OpenJDK 21.0.10):

cat > ExprLL1.g4 <<'EOF'
grammar ExprLL1;
s  : e EOF ;
e  : t e1 ;
e1 : '+' t e1 | ;
t  : f t1 ;
t1 : '*' f t1 | ;
f  : '(' e ')' | ID ;
ID : [a-z]+ ;
WS : [ \t\r\n]+ -> skip ;
EOF
for input in 'a + * b )' '( a + b' 'a b + c'; do
  echo "== $input"; echo "$input" > err.txt
  java -cp antlr-4.13.2-complete.jar org.antlr.v4.gui.Interpreter ExprLL1.g4 s -tree err.txt
done

Output (complete):

== a + * b )
line 1:4 extraneous input '*' expecting {'(', ID}
line 1:8 extraneous input ')' expecting <EOF>
(s:1 (e:1 (t:1 (f:2 a) t1:2) (e1:1 + (t:1 (f:2 * b) t1:2) e1:2)) ) <EOF>)
== ( a + b
line 2:0 missing ')' at '<EOF>'
(s:1 (e:1 (t:1 (f:1 ( (e:1 (t:1 (f:2 a) t1:2) (e1:1 + (t:1 (f:2 b) t1:2) e1:2)) <missing ')'>) t1:2) e1:2) <EOF>)
== a b + c
line 1:2 mismatched input 'b' expecting {<EOF>, '+', '*'}
(s:1 (e:1 (t:1 (f:2 a) t1:2) e1:2) b + c)

What to notice: the first input is §3's running example (with ID for id): "extraneous input" is a single-token deletion (DefaultErrorStrategy.singleTokenDeletion [ANTLR4-ErrorStrategy]), applied twice, giving the repair "delete *, delete )" of §3, whose length 2 equals \(d(w)\). The second input gets a single-token insertion ("missing ')'", singleTokenInsertion), recorded in the tree as <missing ')'>. The third input needs neither (after a the parser is at the end of e, and neither deleting b nor inserting one token lets it continue), so ANTLR falls back to panic mode: recover consumes tokens until one in the error-recovery set (getErrorRecoverySet, the FOLLOW of the rule invocation stack), here <EOF>.

3. Worked example

Running example: the Dragon expression grammar (tests/ch02/Inputs/expr-ll1.grammar) and the input id + * id ): one missing operand and one stray parenthesis. \(\mathrm{FOLLOW}(E) = \mathrm{FOLLOW}(E') = \{), \$\}\), \(\mathrm{FOLLOW}(T) = \mathrm{FOLLOW}(T') = \{+, ), \$\}\), \(\mathrm{FOLLOW}(F) = \{+, *, ), \$\}\).

flowchart LR
  err{{"error: top X, lookahead a"}} -->|panic| skip["skip a / pop X"]
  err -->|phrase level| fix["routine(X, a): insert, delete or pop"]
  err -->|repair| edit["cheapest edit that lets the parser continue"]
  skip --> resume([resume the driver])
  fix --> resume
  edit --> resume

Panic mode on the running example

Every step (oracle ll1_parse(..., recover=True); the C++ ll1 parse --recover prints the same derivation and errors):

step stack input action
1 $ E id + * id ) $ output (1) E → T E'
2 $ E' T id + * id ) $ output (4) T → F T'
3 $ E' T' F id + * id ) $ output (8) F → id
4 $ E' T' id id + * id ) $ match id
5 $ E' T' + * id ) $ output (6) T' → ε
6 $ E' + * id ) $ output (2) E' → + T E'
7 $ E' T + + * id ) $ match +
8 $ E' T * id ) $ **error: M[T, *] is empty; * ∉ FOLLOW(T): skip ***
9 $ E' T id ) $ output (4) T → F T'
10 $ E' T' F id ) $ output (8) F → id
11 $ E' T' id id ) $ match id
12 $ E' T' ) $ output (6) T' → ε
13 $ E' ) $ output (3) E' → ε
14 $ ) $ error: extra input ); skip to $
15 $ $ stop: $ meets $ after 2 error(s); reject

Two errors, both real; tokens 3 and 5 skipped. On id * + id the other rule fires: at \(T' \to * F T'\) the parser has \(F\) on top and + as lookahead; \(+ \in \mathrm{FOLLOW}(F)\), so \(F\) is popped ("missing operand") and parsing continues with + id.

Phrase-level recovery on the running example

Routines designed for this grammar: on \(E\), \(T\) or \(F\) with an operator, ) or $ ahead: "missing operand", insert id; on \(E'\) or \(T'\) with id or ( ahead: "missing operator", insert +; ) expected at $: "missing )", insert ); $ on top with ) ahead: "unbalanced )", delete it (oracle phrase_level_parse):

step stack input action
1 $ E id + * id ) $ output (1) E → T E'
2 $ E' T id + * id ) $ output (4) T → F T'
3 $ E' T' F id + * id ) $ output (8) F → id
4 $ E' T' id id + * id ) $ match id
5 $ E' T' + * id ) $ output (6) T' → ε
6 $ E' + * id ) $ output (2) E' → + T E'
7 $ E' T + + * id ) $ match +
8 $ E' T * id ) $ error (missing operand): insert id
9 $ E' T id * id ) $ output (4) T → F T'
10 $ E' T' F id * id ) $ output (8) F → id
11 $ E' T' id id * id ) $ match id
12 $ E' T' * id ) $ output (5) T' → * F T'
13 $ E' T' F * * id ) $ match *
14 $ E' T' F id ) $ output (8) F → id
15 $ E' T' id id ) $ match id
16 $ E' T' ) $ output (6) T' → ε
17 $ E' ) $ output (3) E' → ε
18 $ ) $ error (unbalanced )): delete )
19 $ $ stop after 2 error(s)

The parser effectively parsed id + id * id: the messages say what is wrong ("missing operand" at token 3, "unbalanced )" at token 5), and the corrected program is a sentence. With every error cell covered by an insert/delete routine, the effective input is always a sentence (Proposition 2.7.10; checked on 500 random inputs by test_recovery_strategies_terminate).

Insertion/deletion repair on the running example

Local single-token repair (oracle local_repair_parse):

step stack input action
1 $ E id + * id ) $ output (1) E → T E'
2 $ E' T id + * id ) $ output (4) T → F T'
3 $ E' T' F id + * id ) $ output (8) F → id
4 $ E' T' id id + * id ) $ match id
5 $ E' T' + * id ) $ output (6) T' → ε
6 $ E' + * id ) $ output (2) E' → + T E'
7 $ E' T + + * id ) $ match +
8 $ E' T * id ) $ **error: M[T, *] empty, M[T, id] is not: delete *** (single-token deletion)
9 $ E' T id ) $ output (4) T → F T'
10 $ E' T' F id ) $ output (8) F → id
11 $ E' T' id id ) $ match id
12 $ E' T' ) $ output (6) T' → ε
13 $ E' ) $ output (3) E' → ε
14 $ ) $ error: extra input; delete )
15 $ $ stop after 2 error(s)

The repair is "delete *, delete )", giving id + id, the same two deletions ANTLR makes (real-world box in §2). The global minimum-distance computation confirms that two edits are necessary: MinEditCost returns 2, and a breadth-first search over all inputs 0, 1 and 2 edits away finds its first sentence at distance 2.

MinEditCost in full on a smaller input, id ) (positions 0–2). One row per span; the entries are \(\mathrm{cost}(A, \text{span})\) after the fixed point, reached in 4 relaxation passes (the last one changes nothing):

span tokens E E′ T T′ F
0..0, 1..1, 2..2 ε 1 0 1 0 1
0..1 id 0 1 0 1 0
1..2 ) 2 1 2 1 2
0..2 id ) 1 2 1 2 1

\(E\) over the empty span costs 1 (insert id); \(E\) over id ) costs 1: keep id for \(T\) and let \(E' \to \varepsilon\) delete ). So \(d(\mathtt{id}\ {)}) = 1\).

Try it

./course drill predict-trace --seed 9 --difficulty hard gives an erroneous input: trace panic mode (skipped tokens, popped symbols) and compute the repair distance.

4. Invariants and correctness

Panic mode with FOLLOW-based synchronizing sets

Lemma 2.7.7 (Immediate error detection of LL(1) tables)

Let \(M\) be the conflict-free table of an LL(1) grammar. If a symbol \(Y\) is pushed by an expansion while the lookahead is \(a\), then \(Y\) is never the top of an error configuration while the lookahead is still \(a\).

Proof

Let the expansion be \(X \to Y_1 \cdots Y_k\) at lookahead \(a\), so \(a \in \mathrm{PREDICT}(X \to Y_1 \cdots Y_k)\), and suppose \(Y_j\) becomes the top while the lookahead is still \(a\). By induction on the number of pushes, \(Y_1, \dots, Y_{j-1}\) were removed without an error and without consuming input, so each derived \(\varepsilon\) by expansions at lookahead \(a\); in particular each is nullable. Moreover no \(Y_l\) (\(l < j\)) has \(a \in \mathrm{FIRST}(Y_l)\): if it had, the unique production of \(M[Y_l, a]\) would have \(a\) in its FIRST set (a nullable alternative is only there via FOLLOW, and two alternatives cannot share the cell), and by the same argument down the tree \(a\) would eventually be matched, i.e. consumed. Hence \(a \in \mathrm{PREDICT}(X \to Y_1 \cdots Y_k)\) gives either \(a \in \mathrm{FIRST}(Y_j \cdots Y_k)\), or \(Y_j \cdots Y_k\) nullable and \(a \in \mathrm{FOLLOW}(X) \subseteq \mathrm{FOLLOW}(Y_j)\). In the first case, \(a \in \mathrm{FIRST}(Y_j)\) (so \(M[Y_j, a] \ne \emptyset\), or \(Y_j = a\) if it is a terminal), or \(Y_j\) is nullable and \(a \in \mathrm{FIRST}(Y_{j+1} \cdots) \subseteq \mathrm{FOLLOW}(Y_j)\), so \(Y_j\)'s nullable alternative is in \(M[Y_j, a]\). In the second case, likewise. In every case \(Y_j\) is not in an error configuration.

Lemma 2.7.8 (Recovery never pushes)

Every action of Algorithm 2.7.3 either consumes an input token or pops a stack symbol, and none pushes.

Proof

By inspection of the four branches: skipping the rest of the input and skipping \(a\) consume tokens; the other two branches pop \(X\).

Theorem 2.7.9 (Panic mode terminates and reports the true first error)

For an LL(1) grammar, the driver of Algorithm 2.5.4 with Algorithm 2.7.3 terminates on every input in \(O(n)\) moves (for a fixed grammar), and its first reported error is the error of the plain parser, which satisfies the correct-prefix property.

Proof

First error: until the first error the two parsers perform the same moves, so the first error is the plain parser's, and Theorem 2.5.9 gives the correct-prefix property. Termination and \(O(n)\): reuse the burst counting of Proposition 2.5.15. By Lemma 2.7.7 an error never happens on a symbol pushed at the current lookahead, so every error is on an old symbol that is on top before any expansion of it (a burst that ends in an error made no expansion), and recovery pushes nothing (Lemma 2.7.8). Each recovery action either skips input (at most \(n + 1\) times) or removes an old symbol; as in Proposition 2.5.15, old symbols are the start symbol and those left by the at most \(n\) bursts that end in a match, so there are at most \(1 + n\, r\, c_G\) removals of old symbols in total, by bursts or by recovery. A burst starts at a new lookahead or after such a removal, so there are at most \(2 + 2n + n\, r\, c_G\) bursts, each with at most \(c_G\) expansions. Adding the \(n\) matches and the recovery actions gives \(O(n)\) moves for a fixed grammar.

Why FOLLOW: popping \(A\) when \(a \in \mathrm{FOLLOW}(A)\) models "the programmer left out the text that \(A\) derives", after which \(a\) can legally appear. When it breaks: later errors can be cascades (popping the wrong \(A\)), and a synchronizing token inside a nested construct (a ) that belongs to an outer expression) can make the parser skip a lot; Go's keyword-based SYNC skips a correct statement in the real-world box of §2. Real parsers suppress reports until a few tokens have been matched after a recovery (§6).

Phrase-level recovery

Proposition 2.7.10 (Phrase-level recovery terminates; with insert/delete routines it repairs)

With the insertion guard, the driver with Algorithm 2.7.4 terminates on every input. If every routine inserts or deletes (never pops), the tokens matched by the parser form a sentence of \(L(G)\) and the number of errors is an upper bound on \(d(w)\).

Proof

Termination: deletions consume input; pops shrink the stack and push nothing; an insertion at position \(i\) is followed either by progress (the inserted token is matched) or, at the same position, by a forced deletion (the guard), which consumes \(t_{i+1}\). So at each position there is at most one insertion, and between edits the argument of Theorem 2.7.9 bounds the moves. Repair: if there are no pops, every move after an edit is a move of the plain parser on the edited input, and the parse ends in \((\$, n)\) with accept: by Theorem 2.5.9 the edited input, which is exactly the matched tokens, is a sentence. Each error performed one edit, so \(d(w) \le\) the number of errors.

When it breaks: routines that insert a token which cannot be matched next loop forever without the guard; routines are grammar-specific and must be revised whenever the grammar changes.

Insertion/deletion repair

Theorem 2.7.11 (MinEditCost computes the repair distance)

Algorithm 2.7.6 terminates and \(\mathrm{cost}(S, 0, n) = d(w)\).

Proof sketch (full proof: [AP72])

Characterization: an edited string derived from \(A\) comes from a production \(A \to Y_1 \cdots Y_k\) and a split of the original span among \(Y_1, \dots, Y_k\) (every original token that survives lies in exactly one part; deleted tokens can be charged to the part they fall in; inserted tokens are charged to the symbol that derives them), and the parts are edited independently. So \(\mathrm{cost}(A, i, j)\) is the minimum over productions and splits of the sum of the parts' costs, and for a terminal \(t\) it is "keep one occurrence of \(t\) and delete the rest" or "delete all and insert \(t\)". These min-plus equations have equal-span dependencies (a nullable symbol takes an empty part), so their solution is the greatest fixed point below \(\infty\) reached by relaxation from \(\infty\), which is the least in the reversed order. Termination: costs are non-negative integers bounded by \(n + \min_w \lvert w \rvert\) once finite, and every pass except the last lowers some entry. The implementation agrees with the brute-force search on 120 random inputs (test_edit_distance_dp_matches_search).

Proposition 2.7.12 (Local repair terminates)

The driver with Algorithm 2.7.5 terminates on every input.

Proof

Every branch consumes a token (a deletion, or panic's skip), pops the terminal \(X\) (single-token insertion and the terminal fallback), or runs panic mode; none pushes. The argument of Theorem 2.7.9 applies unchanged.

When it breaks: the minimum edit is not always the intended edit (the programmer may have meant id + id * id, which is also two edits away).

5. Complexity

Variables: \(n\) tokens, \(\lvert G \rvert\) grammar size, \(\lvert P \rvert\) productions, \(r\) longest right side, \(e\) number of errors.

Technique Time (worst) Time (typical) Space Variables
Panic mode \(O(n)\) extra over the parse negligible none —
Phrase-level \(O(1)\) per error plus the routine negligible a routine table \(O(\lvert N \rvert \cdot \lvert T \rvert)\) —
Insertion/deletion repair local: \(O(1)\) table probes per error; global DP: \(O(\lvert P \rvert \cdot r \cdot n^{3})\) per pass, \(O(n)\) passes local: negligible; global: fine for statements, not whole files global: \(O(\lvert N \rvert \cdot n^{2})\) as above

Proposition 2.7.13 (Cost of recovery)

(a) Panic mode adds at most \(n\) skips and at most (number of pushes) pops to a parse. (b) One relaxation pass of Algorithm 2.7.6 costs \(O(\lvert P \rvert \cdot r \cdot n^{3})\) with the split minimum computed symbol by symbol, and \(O(n)\) passes suffice.

Proof

(a) Each skip consumes one of the \(n\) tokens; each pop removes a symbol that some expansion pushed. (b) As in Proposition 2.1.15, the minimum over split points is computed incrementally over the right side (a prefix table per production suffix and span), costing \(O(n)\) per (production position, span) pair: \(O(\lvert G \rvert \cdot n^{2} \cdot n)\). Within a pass, spans are processed shortest first, so only equal-span dependencies (through nullable symbols) need repeated passes; each such chain has length at most \(\lvert N \rvert\) per span, and costs decrease by integers bounded by \(O(n)\).

Pathological input: for panic mode, a missing ( early in a long expression: every later ) is "extra input" at the outermost level, so panic mode skips to the end and reports errors the programmer did not make. For global repair, the cost grows as \(n^{3}\): minimum-distance repair of a whole file is impractical, which is why Burke and Fisher repair within a bounded window around the error [BF87].

At scale: Go's parser bounds the damage of cascades explicitly: (*parser).advance in src/go/parser/parser.go (Go 1.23) [GO-Parser] returns at a synchronization token only if the parser made progress since the last sync, or at most 10 times without progress, "to avoid an endless parser loop".

6. Variants and refinements

Panic mode with FOLLOW-based synchronizing sets

  • FIRST-based and keyword sync sets (Go's stmtStart, statement keywords): synchronize on tokens that start a construct rather than follow it — trade-off: resynchronizes at the next statement, loses the rest of the current one, and can skip a correct statement (real-world box in §2).
  • Error-count suppression (report a new error only after \(k\) tokens were matched): removes most cascades — trade-off: a genuine second error close to the first is not reported.

Phrase-level recovery

  • Error productions [ALSU07 §4.1.4]: add rules such as Stmt → Expr error ; that match common mistakes — trade-off: precise messages for known mistakes, but they enlarge the grammar and can introduce conflicts.
  • Fix-it hints (Clang, rustc; real-world box in §2): the routine records the edit so an IDE can apply it — trade-off: must be right, or it misleads.

Insertion/deletion repair

  • Insertion-only LL(1) repair [FMQ80]: precompute the cheapest insertion string for every (stack symbol, lookahead) pair — trade-off: linear-time and always succeeds on "insert-correctable" grammars, but cannot delete stray tokens.
  • Bounded-window global repair [BF87]: try all edits in a window of a few tokens around the error, pick the one that lets the parser go furthest — trade-off: much better repairs than single-token ones, at a bounded cost.

7. In real compilers

Panic mode with FOLLOW-based synchronizing sets

  • Clang (LLVM 23.1.2) clang/lib/Parse/Parser.cpp — Parser::SkipUntil, with flags StopAtSemi and StopBeforeMatch declared in clang/include/clang/Parse/Parser.h: skip tokens until a synchronizing token, balancing (), [] and {} while skipping; BalancedDelimiterTracker in clang/include/clang/Parse/RAIIObjectsForParser.h keeps the delimiter counts [CLANG-Parser] (real-world box in §2).
  • GCC gcc/c/c-parser.cc — c_parser_skip_until_found; gcc/cp/parser.cc — cp_parser_skip_to_end_of_statement (GCC 15) [GCC-CParser, GCC-CPParser].
  • rustc compiler/rustc_parse/src/parser/diagnostics.rs — Parser::recover_stmt and recover_stmt_ (Rust 1.90.0) skip to the end of the statement or block [RUSTC-Parser]. Go src/go/parser/parser.go — (*parser).advance(stmtStart) [GO-Parser].

Find where LLVM does it. Open clang/include/clang/Parse/Parser.h (LLVM 23.1.2) and read enum SkipUntilFlags. Question: which flag makes SkipUntil also stop at a ;? (quiz clang-skipuntil)

Phrase-level recovery

  • Clang clang/lib/Parse/Parser.cpp — Parser::ExpectAndConsume and Parser::ExpectAndConsumeSemi: when the expected token is missing, report "expected ';'" with a fix-it that inserts it, and continue as if it had been there (a phrase-level insertion; real-world box in §2) [CLANG-Parser].
  • swift-syntax Sources/SwiftParser/Recovery.swift — canRecoverTo and RecoveryConsumptionHandle (601.0.1): recovery decides how many unexpected tokens to consume, based on token precedence, and stores them in the tree as "unexpected" nodes [SWIFTSYNTAX-Recovery].
  • rustc diagnostics.rs — expected_one_of_not_found builds "expected one of … found …" messages with suggestions [RUSTC-Parser].

Insertion/deletion repair

  • ANTLR 4 runtime/Java/src/org/antlr/v4/runtime/DefaultErrorStrategy.java (4.13.2): recoverInline tries singleTokenDeletion then singleTokenInsertion; sync and recover fall back to consumeUntil(getErrorRecoverySet()) (panic mode with a context-based FOLLOW set) [ANTLR4-ErrorStrategy] (real-world box in §2).
  • Global minimum-distance repair is not used in production compilers (cubic cost); bounded-window variants [BF87] appear in research LR tools and in Menhir-style generated error messages (Ch 3).

8. Comparison

Technique Power / precision Speed (asymptotic · practical) Output / error quality Implementation effort Typical use
Panic mode (FOLLOW sync) Always terminates; may skip large parts of the input O(n) extra · negligible Correct first error; later ones may be cascades Low: a FOLLOW test and a skip loop Clang SkipUntil, GCC, rustc, Go, pebblec
Phrase-level recovery Local, grammar-specific corrections; complete if every error cell has a routine O(1) per error Precise messages and fix-its when routines are well designed High: one routine per error entry, maintained with the grammar Hand-written parsers' "expected ';'" (Clang ExpectAndConsumeSemi), swift-syntax
Insertion/deletion repair Local: one-token edits; global: the true minimum distance Local O(1) per error · global O(|P|·r·n³) per pass Best messages ("insert ')'"), but the minimum edit may not be the intended one Medium (local) to high (global) ANTLR 4 DefaultErrorStrategy; research tools; bounded windows

Choose panic mode when you need a robust default that cannot loop (Theorem 2.7.9): every production parser has it underneath. Choose phrase-level routines when you know the common mistakes of your users (missing ;, missing )) and want fix-its. Choose single-token repair when you want good messages for the most frequent one-token typos at no cost; choose global or windowed repair only for tools that must produce a best-effort tree (IDEs, formatters) and can afford the search.

9. Assessment

Technique Quiz ids (solutions/quizzes/ch02.yaml) Drill Flashcard tag Exercises
Panic mode panic-mode-trace, clang-skipuntil ./course drill predict-trace --difficulty hard panic-mode E6
Phrase-level recovery phrase-level-effective, repair-distance — see note phrase-level — (oracle phrase_level_parse)
Insertion/deletion repair repair-distance, phrase-level-effective ./course drill predict-trace --difficulty hard (asks the repair distance) repair — (oracle local_repair_parse, min_edit_cost)

Phrase-level recovery has no drill: its routines are designed by hand for one grammar, so there is no single right answer to generate and grade; the quiz asks about its termination guard and guarantees (Proposition 2.7.10) instead.

Recovery must never push

Popping a nonterminal on a FOLLOW token is not always progress: if the parser pops \(A\), the same token is an error again for the next stack symbol, and a careless implementation that pushes anything during recovery (for example "expand \(A\) with its first production") can loop forever. Recovery actions must only pop or consume (Lemma 2.7.8).

References

See the chapter references.