ZeroParse

129. Expected-Token Sets Are Recorded on Request

  • Status: Implemented; no measurable change in parse time, smaller generated code
  • Date: 2026-10-31

Problem and Mechanism

Every failed match of a literal, class or predicate recorded what the parser expected at the farthest failure position, in the closure and bytecode engines and in generated Go and TypeScript. Most callers read only the position of the error, yet each parse paid for the record, for the descriptor table of the generated parser, and for carrying the record through memo entries, repetition resume points and Document results.

Recording is now a choice made by the caller. Parsing records nothing by default and reports the farthest failure position with an empty expected list. The WithExpectations parse option (and the Expectations field of ParseOptions) turns it on for the interpreters. A generated parser needs both steps: the Expectations generation option of gen.Go and gen.TypeScript (the -expectations flag of zp gen) gives it the descriptor table and a parse option (WithExpectations in Go, expectations in TypeScript), and a parse records only when it passes that option. A Document fixes the choice when it is created. @error messages are part of the record, so they follow the same choice.

When recording is off, a failure still advances the farthest position and keeps a single placeholder in the record instead of identifiers. The placeholder lets memoized results, resumed repetitions, Documents and the merge of isolated records carry the farthest position exactly as before, so error positions, recovered trees and incremental results are the same in both modes; only the text of the expected list and of @error messages disappears. A generated parser without the flag has no descriptor table.

Results

Parse time, medians of twelve interleaved rounds against the previous commit (benchstat; no difference is significant at p < 0.05). The machine load average was 4 to 11 during the run, above the usual limit of 8 in the first rounds. The inputs have no syntax errors, so they exercise the cost of the record on the failures inside alternations.

Workload Base Default mode Recording on
JSON closure, bytes 17.46 ms 17.23 ms (~) 17.42 ms (~)
JSON bytecode, bytes 21.32 ms 20.45 ms (~) 21.23 ms (~)
JSON generated, bytes 7.41 ms 7.31 ms (~) 7.48 ms (~)
JSON geomean of ten benchmarks 12.23 ms 11.93 ms (-2.4%, ~) 12.21 ms (-0.2%, ~)
Go parser (ParseAST) 29.35 ms 28.45 ms (~) 29.63 ms (~)

The Go parser allocates 4.7% fewer bytes per parse (5.766 to 5.495 MiB, p < 0.001) and 1.2% fewer objects in the default mode. The difference with recording on is within noise of the base.

Generated size (bytes of parser.go):

Parser Base Default mode Recording on
JSON (benchmark) 58762 58381 58795
Go 524159 516868 524192
DuckDB 1588057 1586491 1586491

The saving is the descriptor table; call sites still pass identifiers, which the default-mode runtime ignores.

Cost

(*Parser).ExpectOrdinary serves the rule bodies of parses without budget controls and without a Document. It takes the placeholder path of a parse that does not record through the body that records, which raises the cost of the whole body to 84 against the inlining budget of 80. The early return for a silent position or one before the farthest failure is therefore an inlinable guard, and the recording sits in expectOrdinarySlow, which is not inlined. The method stays in the hot-path inlining test.

Measured on the Go parser (ParseAST of parsers/golang, which is the row that runs these rule bodies), 12 interleaved runs per variant, at a machine load average that fell from 53 to 24 during the run, so differences of a few percent are noise:

ParseAST Base Without the guard With the guard
Default (no recording), ms/op 28.27 27.58 (~) 27.31 (-3.4%, p=0.03)
With WithExpectations(), ms/op 28.27 28.79 (~) 28.73 (~)

The guard against the version without it shows no difference (p=0.38 default, p=0.93 recording). B/op falls by 5.2% and allocs by 1.2% in the default mode. The JSON benchmark rows above ran before the guard was restored; they do not run these rule bodies for their closure and bytecode rows, and the generated rows show no difference.

The ready-made parsers whose grammars define @error messages (Go, Python, TypeScript, XML) are generated with expectations, because their messages are part of what they report. Their compatibility entry points that print messages (ParseFile, ParseModule, Decode) pass the parse option themselves; ParseAST records only when the caller passes it. The others are generated without.