Chapter 4
Streaming
A stream parse reads its input from an io.Reader and hands each element of a
#stream repetition to the caller as soon as the element matches. The parser
then forgets the element and the input before it, so the memory a parse retains
depends on the size of one element and not on the size of the input. This
chapter defines the grammar construct, the Go entry points and their results.
The #stream pragma itself is listed in Pragmas.
Scope and Terminology
An element is the value that one iteration of a #stream repetition
contributes to the list of an ordinary parse: the value of the rule if the element
is a rule with an action, and a node (a string, tuple or list node) otherwise. An
element whose expression has no valued element, for example (-"a")* #stream, is
the unit ().
A stream parse is a parse started with Parser.ParseStream or
Parser.Iterate, or with pego parse -stream. The streaming repetition is
the #stream repetition of the start rule.
Contract
Grammar
| Property | Definition |
|---|---|
| Placement | #stream MUST annotate a repetition (*, + or #repeat(n,m)) that is the body of a rule, an item of the body's top-level sequence, or the expression captured by such an item (items:record* #stream); groups and attributes other than #recover and #missing around the body, an item or a capture do not change what is top level |
| Arguments | #stream takes none |
| Multiplicity | A rule body has at most one #stream |
| Streaming rule | Only the start rule of the parse, at call depth 1, streams. In every other call of a rule that contains #stream, the repetition is an ordinary repetition |
| Suffix | After the repetition ends, the rest of the start rule MUST match as in an ordinary parse |
| Finite bounds | #repeat(n,m) delivers at most m elements and fails if fewer than n match. #repeat(0) executes no elements. Input beyond the maximum is left for the suffix: "a" #repeat(1,2) #stream "a" $$ accepts aaa and delivers two elements. A later suffix or minimum failure does not retract elements already delivered |
| Empty elements | If the maximum permits an element, the first element that matches the empty string is delivered once and ends the repetition. It counts toward the minimum; if the total is still below the minimum, the parse fails without retracting delivered elements. For example, _ #repeat(2) #stream on empty input delivers once and fails, as batch parsing fails after one empty iteration |
| Recovery | #recover MUST NOT wrap the #stream repetition, in either pragma order, because rewinding could reach discarded input. #recover inside each repeated element or its rule is allowed. @error MAY label the streaming repetition. A compiled grammar that contains the unsafe wrapper is rejected when it is loaded |
| Ordinary parses | Parse and every other non-stream entry point treat #stream as an ordinary repetition |
| Recognition | Recognition treats #stream as an ordinary repetition and invokes no callbacks. Captures that element predicates read are still built; element values are not collected |
Entry Points
func (p *Parser) ParseStream(r io.Reader, emit func(*Node) error, opts ...ParseOption) error
func (p *Parser) Iterate(r io.Reader, opts ...ParseOption) (*StreamIterator, error)
ParseStream calls emit once per element, in input order, and returns only an
error: it returns no tree. The values of whatever precedes or follows the
streaming repetition (a header, a footer) are matched and checked and then
discarded. The start rule MUST contain a streaming repetition; otherwise
ParseStream returns the error rule main has no #stream repetition (with the
start rule's name), even if a rule that the start rule calls has one. Another
rule streams when it is made the start rule with Parser.WithStart.
pego parse -stream prints each element on its own line (JSON by default, an
S-expression with -f sexpr) as it is delivered, then reports the final error,
if any. It cannot be combined with -check.
Iterate is the pull form. It validates the entry point without reading.
StreamIterator.Next advances the parse to one committed element or to the
terminal result and returns (node, ok, err); ok distinguishes a successful
nil element from the end of the stream. After completion, repeated calls
return the same terminal error or a clean end. Close is idempotent; after an
early Close, Next returns ErrStreamClosed. The iterator does not close its
reader. One goroutine owns an iterator: calls to Next and Close MUST NOT
overlap, including from a callback. Close cannot interrupt a blocked Read.
Delivery and Finality
- Each element is delivered as soon as it matches, in the middle of the parse and
before the next element is tried. The parser reads nothing from the reader while
emitruns. - An element is final. Once
emithas seen it, the parser never backtracks to before it. What the caller receives is provisional until the parse returns a nil error, because input after the last element can still be invalid. - The parser reads input only as far as needed. An element that ends in a fixed
literal is delivered when its last character arrives. An element that ends in a
repetition (
+,*,?) is delivered after the parser has seen one further character, because that character shows that the repetition ended. This holds in both position units: decoding an ASCII character or a complete UTF-8 sequence does not wait for unrelated later bytes. An incomplete UTF-8 prefix waits for the remaining bytes or for the reader's end of input. - PEG lookahead and buffering MAY cause the parser to read beyond the element
that a
Nextcall returns.
Positions and Text
Node.Start, Node.End, .Start and .End in actions, and SyntaxError.Pos,
Line and Col count from the start of the whole input, regardless of how much
has been discarded; the parser keeps a running line and column for the discarded
part. The position unit option changes all of them to bytes.
The parser keeps no text for the caller. String nodes (literals, classes,
(...) #atomic) carry their text in Node.Text; tuple and list nodes do not. text(...) in an action of the start rule, which runs after elements have
been discarded, returns only the part of the node's input that is still held,
possibly the empty string.
After an element is delivered, the input before it is discarded except for the one
character before it, so ^ (beginning of line) still holds at the start of the next
element. No construct of the language looks further back.
Options
| Option | Effect in a stream parse |
|---|---|
WithUnit, WithBackend, WithMaxDepth |
Apply |
WithContext, WithWorkLimits |
Apply to the whole stream invocation, including diagnostic callbacks and the work to copy a bounded tail |
RecognizeOnly |
An error |
WithDiagnosticCallback, WithDiagnosticLimit |
Stream-only; see Recovered errors |
A Parser supports any number of concurrent stream parses; each has its own state.
Validation and Errors
The following are compile-time errors for #stream. A grammar can report more
than one, and the order in which they are reported is not specified.
#stream is only allowed at the top level of a rule bodya rule body can have only one #stream#recover cannot wrap a #stream repetition; recover inside each element instead#stream must be attached to a repetition (*, +, or #repeat(...))#stream takes no arguments. Source text cannot express this case, because#stream(1)is a syntax error; it arises only in a grammar AST or JSON.
A stream parse ends in one of the following ways.
| Cause | Result |
|---|---|
| The input does not match | A *SyntaxError with the absolute position, line and column. Elements delivered earlier stay delivered. The repetition ends where an element stops matching; the parser then expects either another element or the rest of the start rule, so a trailing $$ makes unparsable input an error and not a silent stop |
emit returns an error |
Parsing stops at once, no further element is delivered and ParseStream returns that error unchanged (errors.Is and == both work) |
| The reader fails | A non-EOF error from Read is returned as it is. Elements completed before the failure are delivered; an element cut by the failure is not |
| Errors were recovered | ParseStream returns the recorded errors as SyntaxErrors after every element was delivered |
| Cancellation or a work limit | The interruption error is returned instead of a completed diagnostic result; elements already delivered remain delivered. A reader or callback blocked in caller code cannot be preempted |
emit or the reader panics |
The panic propagates with its original value on every backend, including values that implement runtime.Error. Delivered elements stay valid; the parser does not close the reader |
Recovered Errors
With #recover inside an element, a bad element becomes an error value that is
delivered like any other element, and parsing continues. At the end the parse
returns the recorded errors as SyntaxErrors, a result that means success with
diagnostics and is distinguished from a fatal *SyntaxError by errors.As. By
default all recovered errors are retained until the end of the input.
WithDiagnosticCallback(func(*SyntaxError) error) receives each committed
recovery immediately before the element it belongs to; Next invokes it while
advancing to that element. Recoveries that were backtracked are not reported.
The callback receives a copy that it may keep or modify. If it returns an error,
parsing stops with that error and the element is not delivered. Recoveries in a
successful suffix are reported before the terminal result.
WithDiagnosticLimit(n) retains only the latest n committed recoveries, in
order of occurrence. A positive limit bounds the retained tail, while the callback
still receives every committed recovery for as long as it returns nil. If errors
were dropped, the terminal error is *TruncatedSyntaxErrors with the fields
Errors, Total, Dropped and CountsSaturated; its Unwrap exposes the
retained errors as SyntaxErrors. Counts saturate and do not wrap; when
CountsSaturated is true, Total and Dropped are lower bounds. If the total
does not exceed the limit, the terminal error is an ordinary SyntaxErrors. A
limit of zero keeps the unbounded behavior. A negative limit is invalid. The two
diagnostic options are rejected by Parse, ParsePrefix and Document; any
value, including zero, is a stream-only option. A limit does not bound what the
callback stores. With tracing enabled and a positive limit, the successful
start-rule exit event exposes the retained tail through TraceEvent.Recovered();
only the terminal TruncatedSyntaxErrors has the total and dropped counts.
Memory
An element is matched in memory as a whole, so the memory held for the input and
the tree of an element depends on the size of that element. Once the callback has
returned, the parser keeps no reference to the element, and the input and memo
entries before it are released as parsing continues, so the memory held does not
grow with the number of elements delivered. Recovered errors are retained until the
end of the input unless a diagnostic limit is set. An action
that returns nil delivers nil to the caller. Variable values, active rollback
points and memoization keys keep their own data alive.
Compatibility and Support
Streaming is supported by the engine on all three backends. Generated Go parsers
provide ParseStream and Iterate when generated with streaming enabled, and
typed variants with typed values; see Generated Parsers.
Generated TypeScript parsers have no stream entry point and treat #stream as an
ordinary repetition. A Document is always parsed as a whole and does not stream.
Grammars that use #missing cannot be used in a stream parse.
Conformance
The behavior is established by the stream tests of the repository
(stream_iterator_test.go, stream_diagnostics_test.go,
stream_diagnostic_tail_test.go) and by the backend equivalence tests, which
require identical elements and errors from every backend.