How-to › Parse, validate and transform data

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.

Audience
Platform team
Level
advanced
Topic
Write a grammar and parser
Languages
TypeScript and Python
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
ANTLR4A grammar file shared across languages, and a recovery strategy you can subclassA Java step to generate the parser, and a re-sync that can land in a nested objectNo Java in the build, or the grammar is small
chevrotainA TypeScript grammar for editor tooling that must keep parsing through errorsThe same re-sync behavior as ANTLR, tuned through hook methods rather than a strategy classThe grammar must be readable by people outside TypeScript
LarkA Python tool where you want to decide each repair yourselfRecovery is your handler: drop a token, or feed the parser one, and nothing moreYou want sensible recovery without writing it
peggyOne error at a time is acceptable, or a wrapper isStops at the first error, so every further error is another full parseUsers expect every error from one run
tree-sitterEditors, where a tree for the whole file matters more than exact messagesError nodes instead of messages, and spans that can start a line before the real faultYou 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.

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

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

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

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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.