041. Opt-In Missing Literal Recovery
- Status: Accepted for the Closure whole-input Node/recognition slice; other backends and APIs remain proposed
- Author: @ornew
- Date: 2026-10-11
Summary
This design introduces explicit, bounded insertion of a missing literal for editor parses. A missing token has an empty range and
distinct value and diagnostic; it does not pretend that source text exists. Ordinary parsing remains strict unless
the caller enables insertion. Existing consuming #recover(skip=...) retains its current behavior.
The nonempty-literal syntax and Closure whole-input option below define the initial slice. The remaining backend and generated-type APIs in this record are proposals:
rule main = name ";" #missing $$
rule name = [a-z]+
node, err := parser.Parse("name", pego.WithMissingTokens(16))
Here the missing semicolon is represented at the actual EOF position and accompanies a recovered diagnostic. Without the option, this input fails as it does today.
Motivation and Scope
Current recovery rewinds a failed expression and consumes a nonempty skip.
Both design 006 and recoverAttr in
runtime/runtime.go deliberately reject an empty skip. Removing that check
would neither identify an expected token nor give repetitions, memoization or backtracking an insertion contract.
The first stage accepts #missing with no arguments on a nonempty literal only. It inserts that entire literal,
not a guessed prefix after partial matching. Classes, arbitrary expressions, synthesized identifiers, automatic
repair searches and changes to ordered choice are outside this stage. An annotation permits insertion wherever
its literal fails; grammar authors can constrain that point with explicit lookahead. It does not implicitly prefer
a later strict alternative over an earlier recovering alternative.
Design
Invocation, Values and Errors
Propose WithMissingTokens(n) as a Go parse option on the engine and generated Go. Zero, including omission,
disables insertion; a positive value bounds synthesis attempts during one invocation; a negative effective value
is a configuration error before execution. Options resolve once, in order, with the last value winning. Generated
TypeScript proposes ParseOptions.missingTokens, a nonnegative safe integer, on its existing options-aware APIs.
There is no mutable global switch or generation-time change to the strict default.
An annotated literal first attempts an ordinary match. After failure it restores input, captures, variables and
recovery state to the literal's start, then may insert. No input is consumed. Propose reserved node type Missing
with Start == End, empty Text, and string fields expected (the literal) and message (the diagnostic).
The error uses the insertion position, line and column in the invocation's position unit. Literal contents remain
the original string; an empty Text is never replaced with those contents. Anchors and positional actions observe
the real position. A surrounding @error supplies its label to the insertion diagnostic by the same explicit
label-scope rules used for failures; composition with outer #recover must not duplicate a diagnostic on success.
Successful whole-input parsing returns a tree and the existing SyntaxErrors collection, in committed encounter
order, including insertion diagnostics. If the whole parse fails, return only the final ordinary syntax error as
with current recovery. Exceeding the attempt budget is instead a distinct fatal MissingTokenLimitError, exposing
limit and position: return no batch tree and never turn resource exhaustion into successful recovery.
Recognition can use insertion when explicitly enabled and returns the same diagnostics with no public tree.
Recognition still evaluates actions needed to supply predicate values; it skips unrelated actions.
Add Missing to the reserved type list and inference as a join of the literal's ordinary Match and Missing.
Propose the existing Error-style assignability rule for node destinations. Go exports a typed Missing value
with Span, Text, Expected and Message; TS exports its corresponding type:"Missing" interface. Explicit node
joins and open/_ conversions preserve it; a destination concrete struct/terminal that cannot hold Missing
receives its existing nil/null recovery representation, with the diagnostic retained. A known list converts its
elements with these same rules, rather than dropping or inventing source tokens. Aliases use their canonical
representation. Document this distinction so consumers needing missing-token identity declare a join or retain
the Node tree. Top-level scalar/action restrictions remain unchanged. Reserving the new name requires a reported
grammar migration for a preexisting user type named Missing and deterministic generated-helper collision tests.
Progress, Rollback and Caches
Maintain a rollback-aware ledger keyed by insertion site identity and absolute position on the current candidate path. A second insertion at the same site and position fails ordinarily. Marks, choice resets and Operators Rule candidate restoration undo ledger additions and diagnostics together with captures; cut retains its existing choice scope. Lookahead always disables insertion, including callees, so recovery cannot make a negative lookahead unexpectedly fail or leak a Missing value into a capture made by a positive lookahead.
Keep a separate monotonic attempt counter which rollback cannot replenish. Each new synthesis and each memo replay that installs an insertion event on the path consumes one attempt. Revisiting an event already installed on that path installs nothing. Budget costs can depend on attempted alternatives, including abandoned ones; this is a resource limit, not a guarantee that a tree with at most n missing nodes will be accepted. Diagnostics and ledger entries from abandoned paths remain invisible. Budget exhaustion aborts even on an abandoned alternative.
Retain the language's repetition rule: the first successful iteration without input progress counts once and then stops, including generated, projected, resumed and streamed repetitions. An insertion never supplies input progress. LR may establish an initial successful empty seed; further growth must advance beyond the already successful seed. Operators Rule prefix/postfix parts must consume input, but an infix part may be empty under the existing longest-candidate rules. A successful infix application with a nullable RHS stops after its first zero-progress application. Insertion supplies no consumed input and must preserve these distinctions; do not tighten existing nullable grammar acceptance or let a Missing value alone justify another growth round.
Initially disable completed memo reuse for insertion sites and their transitive callers when insertion is enabled. Keep mandatory LR growing seeds, carrying the selected seed's insertion events with its errors; replay validates the ledger and charges new installations before returning the seed. Each growth attempt starts with the caller's saved ledger and keeps only the winning seed's events. Never publish provisional seed state. This conservative policy avoids a completed memo answer depending on a different insertion history or remaining budget. Strict mode retains normal memo behavior. Any later contextual memo optimization needs its own differential gate.
Document reparses start with a fresh ledger and counter. Insertion-dependent cached rules are not reused in enabled mode; other entries still obey examined-range, positional-value and recovered-error invalidation rules. Abort or cancellation discards provisional LR/memo/resumption state before retry. Returned Missing nodes obey ordinary Node alias ownership: mutable Document reuse may move earlier trees; Clone preserves retained versions.
EOF, Streams and Backend Gates
Actual EOF permits insertion; a stream temporarily lacking bytes does not. Wait for enough input to establish literal failure or for actual EOF. After insertion inside a stream element, ordinary commit rules apply; no later repair can retract a delivered element. Recovery wrapping a streaming repetition remains forbidden. Release discarded input only after captures, diagnostic text and inserted values are detached from parser scratch.
| Backend | Proposed gate |
|---|---|
| Closure, recursive VM, iterative VM | Both CodePoints and Bytes; one semantic reference matrix |
| Generated Go Node and typed direct/conversion | Same option, ledger, progress and conversion contracts |
| Generated TS Node and typed conversion | Both units, string/Uint8Array inputs, exact option validation |
| Current engine Document and streams | Fresh per-run insertion state; reader EOF and commit tests |
| Generated Document/streams | Supported only when their separately proposed APIs land |
Add syntax/JSON AST validation, formatting, typing and lint checks together. Saved AST records need a compatible new file-format version for the new construct. VM support needs a new instruction-set version with a specified insertion instruction/site operand and structural validation; do not reinterpret RECOVER or permit its empty skip. Old files retain their behavior; old loaders reject new versions. Compiled recognition modules must carry the same sites and option semantics. The numeric versions are allocated against the then-current specification.
Implementation Sequence and Validation
The initial slice implements #missing syntax and opt-in Closure whole-input
Node parsing and recognition. Focused source-parser and canonical grammar
projection tests, the generated-parser suite, site checks and the full root
suite pass. Its measured performance cost is recorded below.
The remaining implementation sequence is to specify and version VM encoding, add both VMs with saved/load round trips and malformed site/operand rejection, then establish generated Go/TypeScript parity and evaluate Document and stream integration. Do not advertise a backend until its full matrix passes; unsupported enabled insertion remains an explicit error.
Proposed tests cover absent/zero/positive/negative limits, callback options once, EOF and partial literals,
multibyte/invalid UTF-8, partial reader prefixes, chained insertions, nested repetitions and minima, choices/cuts,
lookahead, labels, consuming recovery, LR cycles and Operators Rule longest-match ties. Check budget exhaustion after
backtracking and during seed replay; compare diagnostics and values across backends and fresh versus edited
Document parses. Exercise join/_/CST/list/alias/concrete nil mappings, retained results, concurrent/reentrant
calls, callback stop, cancellation and subsequent pooled retry. Property tests must establish termination and
that strict mode has exactly the old acceptance set.
Alternatives and Performance Gates
Keep consuming recovery only as the baseline. Treating an empty skip as recovery lacks a site identity, expected token and budget; reject that shortcut. A search for minimum-cost edits can offer better repairs but changes PEG selection and requires a separate design. Fabricating a typed terminal with expected text would simplify callers but falsely reports source text; choose an explicit Missing value. Always enabling annotated insertion removes the strict baseline; prefer the invocation option.
Ten serialized alternating pairs on Apple M3 Max with Go 1.27.1 and GOMAXPROCS=1 compared the candidate with
main 12af6ec. Across JSON, left-recursive arithmetic, Operators Rule arithmetic and recovery inputs in both
position units, strict Closure parse medians were about 2–4% slower; bytes/op and allocations/op were essentially
unchanged. On a small literal fixture, enabled-valid parsing added 64 B and one allocation over annotated strict
parsing. Enabled-missing parsing used about 19 allocations and 36.5 KB/op, versus about 3 allocations and 35.8 KB/op
in strict mode; this small-run result is noisy. These measurements cover the tested Closure whole-input paths only.
The measured strict-path cost was accepted for this opt-in feature, with further optimization tracked separately. Broader backends, large editor-invalid workloads, live heap, attempt/committed counts and Document edit/reparse remain unmeasured. Keep insertion attempts bounded and do not infer behavior or cost for unsupported paths from this focused comparison.
Initial Closure Implementation and Validation
The initial implementation accepts #missing on a nonempty literal and exposes
WithMissingTokens(n) for Closure whole-input Node parsing and recognition, in
both position units. Zero leaves matching strict; a negative effective value is
invalid. The grammar's Missing name is reserved even when insertion is disabled.
The Node carries empty text and range plus expected and message fields. The
separate exported MissingTokenLimitError reports fatal limit exhaustion.
Insertion state belongs to a single parser invocation. Ordinary marks, failed unmemoized calls, selected Operators Rule candidates and LR seeds restore their ledger with their diagnostics. Value-free twins share their annotated literal's site identity. Lookahead disables insertion and uses separate LR memo keys, so it cannot import an insertion-dependent growing seed from its enclosing parse. Completed memo results are not reused for insertion-dependent rules and their transitive callers while insertion is enabled; unrelated rules retain reuse. First-character choice guards are bypassed in enabled mode. Strict mode retains its original matching and memo policy.
A candidate's synthesis is charged when attempted. Restoring the winning Operators Rule candidate does not charge that synthesis again. Installing a missing event by LR seed replay consumes another attempt unless the event is already on the path. Winning seeds keep owned event snapshots; rejecting a zero-progress growth round discards its added events. Returning the selected seed restores its events and can itself exhaust the remaining budget.
An enclosing @error supplies the insertion diagnostic's explicit label. As
with nested ordinary failures, the outermost applicable explicit label wins.
This insertion-specific scope does not change labels on successful consuming
#recover. @diagnostic retains its existing inside-out failure binding.
The initial slice rejects Document and stream entry points, both VMs, generation
of Go/TypeScript parsers and compiled binary serialization for grammars using
#missing or the reserved Missing type. Positive insertion options also fail
on unsupported parse entry points, including grammars without annotations.
Validation happens before input decoding, Reader access or stream delivery.
Internal bytecode module construction has a guard against silently dropping the
annotation. No instruction or binary file version is allocated by this slice.
Generic grammar AST JSON and formatting retain the existing attribute shape.
Generated typed Missing values/conversion and backend parity remain deferred.
Regression coverage includes strict/zero/negative limits, EOF,
partial literals, both units and malformed UTF-8, rollback/cuts, replay budget
exhaustion, value-free twins, lookahead, nullable repetitions, LR cycles and
Operators Rule candidates, labels, recognition predicates and ordinary actions,
retained values, concurrent/reentrant calls, cancellation/retry, AST round trips
and unsupported entry points. The focused source-parser and canonical projection
tests, generated-parser suite, site checks and tagged focused suites pass. The
full root suite and go generate ./parsers ./bench pass. Benchmark evidence and
its scope are recorded above.