# How to recover from syntax errors and report several in one parse

> Turn on error recovery in chevrotain, ANTLR, tree-sitter or Lark, collect every error in one pass, and test that recovery keeps its place inside nested brackets.

Source: https://voxgig.com/howto/report-multiple-syntax-errors-in-one-parse

- Audience: platform-team
- Level: advanced
- Languages: typescript, python
- Verified: 2026-10-15
- Published: 2026-10-15

## 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"`](https://www.typescriptlang.org/tsconfig/#moduleResolution), 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](https://www.antlr.org/api/Java/org/antlr/v4/runtime/DefaultErrorStrategy.html) | 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](https://chevrotain.io/docs/tutorial/step4_fault_tolerance.html) | 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](https://lark-parser.readthedocs.io/en/stable/classes.html) | 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](https://peggyjs.org/documentation.html) | 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](https://tree-sitter.github.io/tree-sitter/using-parsers/queries/1-syntax.html) | 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.

```ts title="antlr-json.ts"
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:

```bash
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.

```bash
node compare.ts
```

```text output
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.

```python title="lark_recover.py"
    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.

```bash
PYTHONPATH=vendor python3 lark_recover.py
```

```text output
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.

```ts title="peggy-json.ts"
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](https://nodejs.org/api/test.html),
including the failure in the next section, so a version that changes it shows up as a red test.

```ts title="errors.test.ts"
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'])
})
```

```bash
node --test errors.test.ts
```

```text output
# tests 10
# suites 0
# pass 10
# fail 0
```

The Lark handler has its own three tests, run with
[`unittest`](https://docs.python.org/3/library/unittest.html):

```bash
PYTHONPATH=vendor python3 -m unittest -v test_lark_recover 2>&1
```

```text output
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:

```ts title="antlr-json.ts"
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

- [Attach parse actions to grammar rules by name](/howto/attach-parse-actions-by-rule-name)

- [Remove left recursion from a PEG grammar](/howto/remove-left-recursion-from-a-peg-grammar)

- [Write a recursive descent parser by hand in TypeScript](/howto/recursive-descent-parser-in-typescript)

## 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.

[^1]: The [tree-sitter front page](https://tree-sitter.github.io/tree-sitter/) 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.

[^2]: The agreement has a lineage. The [chevrotain tutorial on fault
tolerance](https://chevrotain.io/docs/tutorial/step4_fault_tolerance.html) says its recovery
heuristics generally follow those used in ANTLR 3. The ANTLR 4
[DefaultErrorStrategy](https://www.antlr.org/api/Java/org/antlr/v4/runtime/DefaultErrorStrategy.html)
documents the same single-token insertion and deletion, so the two libraries make the same repair
and the same mistake at `11:4`.

[^3]: The [Java documentation for BailErrorStrategy](https://www.antlr.org/api/Java/org/antlr/v4/runtime/BailErrorStrategy.html)
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.