Your CI check for a config format reports the first syntax error in a file and exits. The author fixes that line, pushes, and waits for the next run to find the second one. A file with three typos costs three round trips, and the editor plugin built on the same parser underlines only the first of them.
What you get
You will end up with a parser that lists every error in a file in one pass, and a recovery that keeps its place inside nested brackets. A test with one error at each of three depths proves both. This is for you if you own a linter, an editor plugin, or a CI check.
Short answer
Turn on the parser’s error recovery and collect errors in a listener rather than throwing on the
first: chevrotain with recoveryEnabled, the DefaultErrorStrategy in ANTLR, tree-sitter’s ERROR
and MISSING nodes, or a Lark on_error handler. Recovery repairs one token where it can and
otherwise skips ahead to a token that may follow, which can be a comma inside a nested object.
Test with an error at each of three nesting depths.
You will need
Node 22.18 or later, Python 3.8 or later for Lark, and Java 11 or later only to regenerate the
ANTLR parser. Verified 2026-10-15 against Node 22.22.0, Python 3.13, chevrotain 13.2.0, antlr4
4.13.2, web-tree-sitter 0.26.13 with tree-sitter-json 0.24.8, peggy 5.1.0 and Lark 1.3.1. The
generated ANTLR parser is committed, and npm ci installs Lark into vendor/, which is why the
Python commands carry PYTHONPATH=vendor. The antlr4 type declarations resolve under
moduleResolution: "bundler", and not
under "nodenext", whose rules reject their imports for leaving out the file extension.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| ANTLR4 | A grammar file shared across languages, and a recovery strategy you can subclass | A Java step to generate the parser, and a re-sync that can land in a nested object | No Java in the build, or the grammar is small |
| chevrotain | A TypeScript grammar for editor tooling that must keep parsing through errors | The same re-sync behavior as ANTLR, tuned through hook methods rather than a strategy class | The grammar must be readable by people outside TypeScript |
| Lark | A Python tool where you want to decide each repair yourself | Recovery is your handler: drop a token, or feed the parser one, and nothing more | You want sensible recovery without writing it |
| peggy | One error at a time is acceptable, or a wrapper is | Stops at the first error, so every further error is another full parse | Users expect every error from one run |
| tree-sitter | Editors, where a tree for the whole file matters more than exact messages | Error nodes instead of messages, and spans that can start a line before the real fault | You need one precise message per error |
chevrotain and ANTLR repair single tokens on their own, and they share the weakness this page is about. tree-sitter never fails a parse, at the price of describing errors as spans rather than messages.1 Lark recovers only as well as the handler you write, and peggy needs a wrapper that parses once per error.
Collect errors instead of throwing
ANTLR reports every syntax error to its error listeners and recovers. The default listener prints to the console, so replace it with one that keeps the reports.
class Collect extends ErrorListener<unknown> {
seen: Diagnostic[] = []
syntaxError(_r: unknown, _token: unknown, line: number, column: number, message: string) {
this.seen.push({ line, column: column + 1, message })
}
}
function parseOnce(src: string, strategy: Strategy) {
const collect = new Collect()
const lexer = new JSONLexer(CharStreams.fromString(src))
lexer.removeErrorListeners()
lexer.addErrorListener(collect)
const parser = new JSONParser(new CommonTokenStream(lexer))
parser.removeErrorListeners()
parser.addErrorListener(collect)
Register the listener on the lexer as well as the parser, or a character the lexer rejects never reaches your list. ANTLR columns start at 0, so add one.
chevrotain needs one option, super(tokens, { recoveryEnabled: true }), and then leaves every error
in parser.errors. An error at the end of the input arrives on a token whose line is -1, so
chevrotain-json.ts places it where the input ends, as ANTLR and peggy do. tree-sitter needs
nothing: the walk in tree-sitter-json.ts collects nodes whose isError or isMissing is true,
and skips any branch whose hasError is false.
The parser in antlr/ was generated once and committed, so the sample needs no Java:
java -jar antlr-4.13.2-complete.jar -Dlanguage=JavaScript -no-listener -o antlr JSON.g4
Measure recovery on three depths
repairable.json has one error at each depth: a stray number in the top-level object, a missing
colon one level down, and a doubled colon two levels down. Each can be fixed by inserting or
deleting one token. resync.json has the same three depths, but its depth-2 error is two stray
tokens in front of a nested object, which no single-token repair can fix.
node compare.ts
repairable.json, real errors at 3:16 6:12 9:14
parser found bogus reported at
chevrotain, recoveryEnabled 3 of 3 0 3:16 6:12 9:14
ANTLR, DefaultErrorStrategy 3 of 3 0 3:16 6:12 9:14
ANTLR, BailErrorStrategy 1 of 3 0 3:16
ANTLR, balanced re-sync 3 of 3 0 3:16 6:12 9:14
tree-sitter, ERROR and MISSING 3 of 3 0 3:16-3:19 6:5-6:24 9:14
peggy, blank the line, 4 parses 3 of 3 0 3:16 6:12 9:14
resync.json, real errors at 5:15 7:14 12:16
parser found bogus reported at
chevrotain, recoveryEnabled 2 of 3 1 5:15 7:14 11:4
ANTLR, DefaultErrorStrategy 2 of 3 1 5:15 7:14 11:4
ANTLR, BailErrorStrategy 1 of 3 0 5:15
ANTLR, balanced re-sync 3 of 3 0 5:15 7:14 12:16
tree-sitter, ERROR and MISSING 2 of 3 5 4:5-5:17 7:14 11:4 12:9 12:20 13:13 14:1
peggy, blank the line, 6 parses 2 of 3 3 5:15 7:14 11:4 12:3 13:3
ANTLR messages for repairable.json
3:16 extraneous input '8081' expecting {',', '}'}
6:12 missing ':' at '"svc_orders"'
9:14 extraneous input ':' expecting {'true', 'false', 'null', '{', '[', STRING, NUMBER}
On the repairable file every recovering parser finds all three, with no extras. chevrotain and
ANTLR agree to the column on both files, including the error that is not there.2
BailErrorStrategy is the opposite setting: it throws at the first error, and its
ParseCancellationException carries no position in the JavaScript runtime, so antlr-json.ts
reads the parser’s current token instead.3
The second file is where recovery earns its trust or loses it. The section on failure below
explains the 11:4 that both chevrotain and ANTLR report, and the 12:16 that both miss.
Write the repair yourself in Lark
Lark calls on_error with the exception and resumes if the handler returns True. Resuming drops
the token that did not fit. A better handler first tries feeding the parser one invented token.
def on_error(e):
nonlocal last
last = e
found.append((e.line, e.column, describe(e)))
if insert and isinstance(e, UnexpectedToken):
p = e.interactive_parser
for name, text in INSERTABLE.items():
if name not in p.accepts():
continue
trial = p.copy()
trial.feed_token(Token(name, text))
if e.token.type in trial.accepts():
# One invented token makes the bad one acceptable: feed both.
p.feed_token(Token(name, text))
p.feed_token(e.token)
return True
if isinstance(e, UnexpectedToken) and e.token.type == "$END":
return False # nothing left to drop, so stop here
return True # resume, dropping the token that did not fit
INSERTABLE holds the comma and the colon only. A handler that may invent values reports an
error and then parses something the author never wrote.
PYTHONPATH=vendor python3 lark_recover.py
repairable.json, real errors at 3:16 6:12 9:14
on_error found bogus reported at
drop the token 3 of 3 2 3:16 6:12 6:24 7:5 9:14
insert, then drop 3 of 3 0 3:16 6:12 9:14
resync.json, real errors at 5:15 7:14 12:16
on_error found bogus reported at
drop the token 3 of 3 17 5:15 5:17 6:7 6:12 6:14 7:14 11:4 12:3 and 12 more
insert, then drop 3 of 3 15 5:15 5:17 6:7 7:14 11:4 12:3 12:9 12:11 and 10 more
Dropping alone turns the missing colon into three errors, because the parser drops each following
token until a colon arrives, and the colon it finds belongs to pool. On resync.json both
handlers lose the nested object’s opening brace, and the rest of the file is parsed against the
wrong structure. The 3 of 3 there is a match by
position: the 12:16 report says it expected the end of the document.
Re-parse a parser that stops at the first error
peggy throws on the first failure and has no recovery. The wrapper blanks the failing line and parses again, which resumes the parse from the next line.
export function peggyErrors(src: string, maxParses = 100): { errors: Diagnostic[]; parses: number } {
const lines = src.split('\n')
const errors: Diagnostic[] = []
let parses = 0
while (parses < maxParses) {
parses++
try {
parser.parse(lines.join('\n'))
break
} catch (err) {
if (!(err instanceof parser.SyntaxError)) throw err
const { line, column } = err.location.start
// Failing where the last parse failed means blanking made no progress: an
// unclosed bracket at the end of the input fails there however many lines
// are blanked, so report it once.
const last = errors[errors.length - 1]
if (last && last.line === line && last.column === column) break
errors.push({ line, column, message: err.message })
// A blank line cannot be blanked again, and parsing the same text twice
// reports the same error forever.
if (lines[line - 1].trim() === '') break
lines[line - 1] = ' '.repeat(lines[line - 1].length)
}
}
return { errors, parses }
}
Spaces rather than deletion keep every later line and column where it was. The cost is one full
parse per error, four parses for three errors. An unclosed bracket at the end of the file fails at
the same place however many lines are blanked. A parse that fails where the last one did therefore
ends the loop, and the error is reported once. The wrapper misses a second error on a line it
blanks. It also breaks on a line that holds a bracket: blanking line 5 of resync.json deletes an
opening brace, and the three bogus errors after it come from that.
Check it worked
Ten tests pin the behavior with the Node test runner, including the failure in the next section, so a version that changes it shows up as a red test.
test('a re-sync on a nested comma loses the depth-1 error and invents one', () => {
const src = read('resync.json')
for (const reported of [chevrotainErrors(src), antlrErrors(src, 'default')]) {
assert.deepEqual(at(reported), ['5:15', '7:14', '11:4'])
}
})
test('a balanced re-sync reports all three errors in resync.json and nothing else', () => {
assert.deepEqual(at(antlrErrors(read('resync.json'), 'balanced')), FIXTURES['resync.json'])
})
node --test errors.test.ts
# tests 10
# suites 0
# pass 10
# fail 0
The Lark handler has its own three tests, run with
unittest:
PYTHONPATH=vendor python3 -m unittest -v test_lark_recover 2>&1
test_dropping_alone_cascades_after_a_missing_colon (test_lark_recover.LarkRecovery.test_dropping_alone_cascades_after_a_missing_colon) ... ok
test_insert_then_drop_reports_each_real_error_once (test_lark_recover.LarkRecovery.test_insert_then_drop_reports_each_real_error_once) ... ok
test_the_end_of_input_is_reported_once (test_lark_recover.LarkRecovery.test_the_end_of_input_is_reported_once) ... ok
When it goes wrong
The error list ends early, with an error that is not in the file. In resync.json the stray
1 2 sits in front of {, so no single-token repair works, and the parser re-syncs by skipping
to the first token that can follow a member. The first comma it meets is inside the nested pool
object, so the parse resumes a level too deep. The nested object’s } then closes database, the
} of database closes the document, and 11:4 is a complaint about the rest. The depth-1
error on line 12 is never checked.
The re-sync in ANTLR is a method you can override. This strategy steps over a bracketed group whole:
class BalancedErrorStrategy extends DefaultErrorStrategy {
skipped: Group[] = []
consumeUntil(recognizer: Parser, set: IntervalSet) {
const next = () => recognizer.getTokenStream().LA(1)
while (next() !== Token.EOF && !set.contains(next())) {
if (!OPEN.has(next())) {
recognizer.consume()
continue
}
const first = recognizer.getCurrentToken()
let stop = first.start
let depth = 0
let deepest = 0
do {
if (OPEN.has(next())) deepest = Math.max(deepest, ++depth)
if (CLOSE.has(next())) depth--
stop = recognizer.getCurrentToken().stop
recognizer.consume()
} while (depth > 0 && next() !== Token.EOF)
// Read the text from the stream the tokens came from. Token offsets count
// code points, not the UTF-16 units src.slice counts, and inside a group
// checked on its own they index the padded copy, not src.
const text = first.getInputStream().getText(first.start, stop)
this.skipped.push({ text, line: first.line, column: first.column, deepest })
}
}
}
A group stepped over is not checked, so antlrErrors parses each one again on its own, padded to
keep its positions, and the error inside pool comes back. That costs a parse per skipped group,
so it checks at most 100 groups and none nested deeper than 64. The group’s text is read from the
token’s own character stream, because the offsets ANTLR records count code points: slicing the
JavaScript string by them misplaces a group after the first emoji. The stream also serves a group
found inside another group, whose offsets point into the padded copy rather than the file. The
JavaScript runtime has no setErrorHandler; its type declarations expose the _errHandler field
instead.
The fix has a limit, and a test records it. An error in the top-level object that needs more than one token skips to the end of the file, because only the end can follow the top-level value.
When not to do this
Do not turn recovery on for a file a program wrote. A serializer that emits broken JSON has one bug, so a list of forty errors describes that one bug forty times.
Do not show errors after a re-sync with the same confidence as the first. Mark them as possibly caused by an earlier error, or stop at the first error whose repair needed more than one token.
Do not write a recovery strategy before the three-depth test exists. Every parser here passes the repairable file, so only a file that forces a re-sync before a nested object tells them apart.
Do not keep peggy’s wrapper for large files that people edit by hand. A file with a hundred errors costs a hundred and one full parses, which is affordable for a config file but not for a 10,000-line file.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0, Python 3.13, chevrotain 13.2.0, antlr4 4.13.2, web-tree-sitter 0.26.13, tree-sitter-json 0.24.8, peggy 5.1.0 and Lark 1.3.1. The ANTLR parser was generated with the 4.13.2 tool. Every output block is what the command preceding it printed.
Footnotes
-
The tree-sitter front page lists six research papers that shaped its design, and two of them are about recovering from errors in LR parsers. Its answer is the one in the table: never fail, and mark what did not fit. ↩︎ Back to text
-
The agreement has a lineage. The chevrotain tutorial on fault tolerance says its recovery heuristics generally follow those used in ANTLR 3. The ANTLR 4 DefaultErrorStrategy documents the same single-token insertion and deletion, so the two libraries make the same repair and the same mistake at
11:4. ↩︎ Back to text -
The Java documentation for BailErrorStrategy names two uses, both for programs rather than people. One is the first stage of two-stage parsing, which gives up early so a second stage can try. The other is silent validation, where errors are not reported at all. It also says to call
getCause()for the original exception, and in the JavaScript runtime the exception has no cause to return. ↩︎ Back to text