PEGO

Chapter 1

Parsing

The preceding chapters define what a grammar means rule by rule. This chapter defines a parse as a whole: how it starts, what the input and its positions are, what a parse produces, how a syntax error is determined and reported, what memoization can and cannot change, what a parse costs in time and memory, and the limits and modes of a parse.

Start Rule

A parse starts at a rule, the start rule of the parse. It MUST be one of the start rules of the grammar, the rules marked @start (see Start Rules). When no rule is named, the parse uses the default start rule of the grammar. On every interface that takes the name of a start rule, an empty name selects the default start rule of the grammar; it does not select the start rule a parser was compiled or generated for.

Interface How the start rule is selected
Go API The start argument of pego.Compile and pego.CompileSource, where an empty name selects the default start rule; Parser.WithStart makes a parser with another start rule for whole-input parsing; Parser.ParsePrefix selects a start rule for one prefix parse without changing the configured start rule. Both take an empty name for the default start rule
Command line pego parse -s <rule>. It defaults to the start rule saved in a compiled grammar (.pegoc), and otherwise to the default start rule
Generated parsers Parse uses the start rule selected when the parser was generated, the default start rule unless another was named; ParseRule and ParseRuleAST (parseRule and parseRuleWithOptions in TypeScript) take the name of a start rule, or an empty name for the default start rule

Naming a rule that is not a start rule is an error that names the rule and says it is not a start rule; naming an undefined rule is an error that says it is not defined. The start rule is called at the beginning of the input with no binding level, so an Operators Rule in it parses the operators of all levels (see Level-restricted calls).

Input and Positions

The input of a parse is a sequence of Unicode characters. The input is given as text encoded in UTF-8; a byte that is not part of a valid UTF-8 sequence is read as U+FFFD (REPLACEMENT CHARACTER) occupying one position.

Positions

A position is a number that identifies a place between two characters of the input: 0 is the beginning, and the length of the input is the end. The range of a node is a pair of positions [start, end).

Positions are measured in a position unit, which is selected for each parse. The unit applies to node ranges, the .Start and .End members and the len function in actions, and the line and column of syntax errors.

Position unit A position is the number of ... from the beginning of the input
Code points (default) Unicode code points
Bytes bytes of the UTF-8 encoding

The unit affects only how positions are counted. Matching is always performed one code point at a time: a character class or . matches exactly one code point in either unit. In bytes, a code point that is encoded in several bytes advances the position by that many, and each byte that is not part of a valid UTF-8 sequence counts as one position (and one character U+FFFD) in either unit.

This decoding also applies to literals. A U+FFFD in a literal matches either its valid UTF-8 encoding or one invalid input byte, in either position unit. For example, rule main = "�" accepts the single byte 0xff; the matched range ends at position 1. Two invalid bytes require two replacement characters. Position units do not change which Unicode characters match. The text of the string node is the matched input: byte mode keeps the invalid byte, while code-point mode substitutes U+FFFD. The literal's spelling need not have the same byte length as the matched input.

The unit is selected with pego.WithUnit(pego.Bytes) in the Go API and with pego parse -unit bytes on the command line.

Lines and Columns

Lines and columns in syntax errors are 1-based. A new line starts after each line feed (U+000A); a carriage return does not start a line. The column is counted in the position unit. (This differs from grammar source files, where a carriage return can end a line; see Line terminators.)

Whole Input

Whole-input entry points such as Parser.Parse succeed only if the start rule succeeds and consumes the entire input. Parsing evaluates the start rule at position 0. If the rule fails, the parse fails with a syntax error. If the rule succeeds without reaching the end of the input, end of input is recorded as expected at the position where it stopped, and the parse fails with a syntax error. The error is reported at the farthest position that parsing reached, which can be beyond where the start rule stopped.

rule main = "a" ("b" "c")?

For the input abd a whole-input parse fails at 1:3 expecting "c"; for the input ad it fails at 1:2 expecting "b" or the end of input. Parser.ParsePrefix(name, input, ...) instead evaluates the named rule at position 0 without an implicit end-of-input check. A successful prefix returns both the rule value and the consumed cursor in the selected position unit and original input bytes; an unconsumed suffix is allowed. An explicit anchor $$ inside that rule still requires the actual end of the supplied input. The API does not change the parser's configured start rule or shift positions to an enclosing document.

Results

A parse has one of the following outcomes.

Outcome What the parse returns
Success The value of the start rule, which is a node or nil
Success with recovered errors The value, together with the list of syntax errors that #recover recovered from
Syntax error No value; the syntax error. If errors were recovered before, only the final syntax error is returned
Run-time error No value; the error of an action or a limit that was exceeded. It aborts the whole parse

The value of the start rule is nil if the rule's value is an optional that did not match. For example, rule main = "a"? on empty input succeeds with nil. A rule whose expression has no valued element, such as rule main = -"a", has the unit () as its value.

Nodes

The value of a rule without an action is a node. The properties of a node, the Go type pego.Node, traversal, and the text and JSON renderings are defined in Nodes and Values.

Prefix Parsing

Parser.ParsePrefix(name, input, opts...) selects the rule name for one call and returns its successful endpoint without requiring the rule to consume the whole input. It does not change the start rule that the parser was configured with. The rule MUST be defined; an undefined name is an error. The input is decoded as for a whole-input parse.

The result is a PrefixResult:

Field Meaning
Node The value of the selected rule, as for a whole-input parse (a node, a value node or nil)
Consumed The successful cursor in the parser's position unit (code points by default)
ConsumedBytes The endpoint in bytes of the original UTF-8 input

Each malformed UTF-8 byte counts as one replacement code point in Consumed, while ConsumedBytes still refers to the original bytes. Positions inside Node are local to input; a caller that passes a slice of a larger source adds the slice's starting byte offset to ConsumedBytes to find the endpoint in the source. Both consumption fields come from the parser's cursor and are independent of the span of Node: a rule whose action returns a smaller node, or nil, still reports everything it consumed. With the Bytes unit, node spans and both consumption fields are in bytes. A node span in code points is converted to bytes before a base offset is added. A zero-width success returns zero consumption, and a nil Node does not imply failure. Only the runtime Parser provides ParsePrefix; generated parsers do not.

Outcomes:

Outcome Result
The rule matches The PrefixResult and a nil error
The rule matches after #recover The PrefixResult and SyntaxErrors, as a whole-input parse returns a node with recovered errors
Syntax failure, including a failure after an earlier recovery, or a fatal error The zero PrefixResult and the error. No speculative endpoint is exposed

A zero result alone cannot distinguish failure from a successful zero-width rule that returns nil; the error does.

ParsePrefix accepts position units, backends, recognition, tracing, nesting limits, context and work limits. Stream diagnostic options are rejected, as for Parse. A grammar that contains #missing is rejected, even if insertion is not enabled; a positive WithMissingTokens limit is rejected for every grammar. Zero keeps strict prefix matching. Options resolve in order. An explicit $$ sees the actual end of the entire supplied string, so a fragment is confined by slicing both its beginning and its end. Cuts and left recursion keep their ordinary meaning. Prefix parsing shares no memo entries with a Document, does not shift positions to absolute coordinates and does not map escaped or noncontiguous fragments. It can run concurrently on one parser. A compiled grammar supports it on the bytecode backends in the mode that serves the parse: a value parse in parse mode, recognition in recognize mode and a traced parse in document mode (see Compiled Grammars).

Incremental Documents

A Document owns mutable text and the reuse state of earlier parses. A parse of a document returns what a parse of the current text from scratch returns. The document API, versions, snapshots, interruption and change notifications are defined in Incremental Documents.

Syntax Errors

When the input does not match the grammar, the parser reports the farthest position that parsing reached, together with the items that were expected at that position: literals, character classes, any character (for .) and anchors (beginning of input, end of input, beginning of line, end of line).

1:5: syntax error: expected "(", "-", [0-9]
  • The position is given as a 1-based line and column. The column is counted in the position unit (code points by default).
  • The expected items are listed in sorted order, without duplicates.
  • Failures inside lookahead and inside the skip of an Operators Rule are not included. Predicates and bottom (_|_) contribute no expected items.
  • If the start rule matches but input remains, end of input is expected at the position where the start rule stopped (see Whole input).
  • If nothing was expected at the farthest position, the message is just syntax error.
  • A @error attribute replaces the expected items inside an expression with a message. If a message applies at the farthest position, the message is reported instead of the expected items.

Errors in Go and on the Command Line

In the Go API, a failed parse returns a *pego.SyntaxError with the fields Pos (the position in the position unit), Line, Col, Expected (the sorted expected items) and Messages (the messages of @error attributes), and the methods Error(), which returns the text above with its position, and Message(), which returns it without the position. A parse that recovered errors returns the node and an error of type pego.SyntaxErrors, a list of *SyntaxError; errors.As tells it apart from a fatal *SyntaxError, and the node is valid. The command pego parse prints the tree and then each error on its own line, and exits with status 1 in both cases. Run-time errors of actions, nesting-limit errors and interruptions are other error values, not SyntaxError.

Memoization

An implementation memoizes the result of a rule at a position, so that the rule is not evaluated twice there (packrat parsing). Memoization has the following observable consequences, and no others.

  • It never changes the result of a parse. The value, the recovered errors and the syntax error are the same with and without memoization. Memoization changes only the cost.
  • Node identity is unspecified. Whether two calls of a rule at the same position return the same node, or equal nodes, depends on memoization. To compare what two nodes matched, compare their text or their fields (see Operators).
  • Rules that read variables are memoized per combination of values. The result of a rule that reads variables directly or through the rules it calls depends on their values when it is called, so a memoized result is reused only if the variables have the same values; see Predicates and Variables.
  • Actions run when a rule is evaluated. An action is evaluated every time the body of its rule is evaluated and matches, even if the enclosing alternative later fails, and also inside a lookahead, under #atomic and under -. An action has no effect other than its value and its run-time errors, so how many times it runs is not observable, but whether it runs is: an error in an action aborts the parse even in a branch that is abandoned.
  • Left recursion is evaluated by growing a seed, which relies on the memo (see Left recursion). While a rule grows at a position, every call of it there reads the growing result, whatever the values of the variables it reads.
  • The nesting limit can depend on it. A call that is answered from the memo does not nest, so whether a parse that comes close to the nesting limit succeeds can depend on memoization.

Time and Memory

The cost of a parse depends on the grammar as well as on the length n of the input. Memoization bounds how often a rule body is evaluated at a position, but some expressions do work that the memo does not share. This section defines how that work is measured, what holds for every grammar, and the classes of growth that a grammar can have.

Work

The work of a parse is the sum of the following:

  • the rule bodies that are evaluated, counting each step of a growing left-recursive rule;
  • the input that terminals, repetitions of character classes and lookaheads examine, counted again each time it is examined;
  • the iterations of repetitions and the operators that an Operators Rule applies;
  • the size of the values that actions and predicates build.

Time is proportional to work. The memory that a parse allocates is at most proportional to its work plus the input, because each memo entry, node and value is built by work that is counted. Work is a model of cost: the counters of work limits measure it in units that differ between backends.

Termination

A parse of a finite input terminates, unless a reader or a callback that the caller supplies does not return.

  • A call of a left-recursive rule at a position where that rule is growing reads its current result instead of evaluating its body, whatever the values of variables (see Left Recursion). A rule therefore cannot call itself at the same position without end.
  • A repetition stops at the first iteration that consumes no input, an infix operator applied without consuming input ends its Operators Rule expression, and a recovery applies only when its skip expression consumes input.
  • Actions have no recursion: a lambda is only an argument of a built-in function, which visits a list once.

No bound that is a polynomial in n holds for every grammar.

Evaluations of Rules

In the reference implementation and the generated parsers, a rule body is evaluated at most twice at a position for each binding level and each combination of the values of the variables that the rule reads, with these exceptions.

  • A rule that calls no other rule, or that is called from one place in the grammar, is not memoized in an ordinary parse. It is evaluated as often as that place is evaluated.
  • A rule of a left-recursion cycle is not memoized, except the rule that the cycle grows. It is evaluated at every call, and again at every step of the growth.
  • A growing rule evaluates its body once for each step of the growth, and the number of steps is at most two more than the length of the input it consumes.

Expressions That Repeat Work

The memo shares the result of a rule at a position. It does not share work inside a rule body, nor between results at different positions. The following expressions do work that grows with the input they cover, and repeat it when they are evaluated again over the same input.

Expression Work of one evaluation Repeated when
A repetition Its iterations, up to the input it matches The body that contains it is evaluated again at an earlier position, so the repetition covers the same input again
A lookahead The work of its expression It is evaluated at each of many positions and its expression examines the input after them
#recover(skip=e) The work of the base expression and of e Its result is abandoned and parsing continues inside the skipped input
A growing left-recursive rule One evaluation of its body per growth step It is called at many positions of one input, because its result at one position is not built from its result at the next
An Operators Rule One step per operator it applies As for left recursion
A rule that reads variables As for any rule, once for each combination of values Calls at one position bind its variables to many different values
An action or predicate Proportional to the sizes of the values it reads and builds A value grows from its own earlier value, as s + s does

Classes of Growth

A grammar's work as a function of n falls into one of these classes. A class describes the worst input of each length.

Class Work Example grammar Input
Linear At most proportional to n main = (A / "a")* $$; A = "a" "c" a repeated n times
Linear given an environment bound Proportional to n times the number of combinations of variable values that occur at one position A rule that reads indent, bound from the length of a captured run of spaces Lines of increasing indentation
Polynomial of degree k At most proportional to n^k main = (A / "a")* $$; A = "a"* "c" (k = 2) a repeated n times
Exponential Can double when n grows by a constant main = C $$; A = C "+" / "n"; C = "(" C ")" "y" / "(" C ")" "z" / A ( n times, n, then )z n times

The following grammars have the class shown on the inputs shown.

Grammar Input Class Cause
main = (E "q" / "x")* $$; E = "x" E / "x" x n times Linear The result of E at a position is built from its memoized result at the next
main = (E "q" / "x")* $$; E = E "x" / "x" x n times Degree 2 E grows to the end of the input at every position, and "q" then fails
main = (E "q" / "x")* $$ with E an Operators Rule with the operand "x" and the infix operator "" at one left-associative level x n times Degree 2 As above, for the operators that E applies
main = ("a" !Rest)* "a"* $$; Rest = "a"* "c" a n times Degree 2 The lookahead scans to the end of the input at every position
main = (X / "a")* $$; X = (Y #recover(skip=[a]+)) "c"; Y = "b" a n times Degree 2 The skip consumes the rest of the input, "c" then fails, and the recovery is abandoned
main = (A / "a")* $$; A = (B / "a")* "c"; B = "a"* "d" a n times Degree 3 A repetition that can restart inside another one that can restart
main = C $$; A = C "+" / "n"; C = "(" C ")" "y" / "(" C ")" "z" / A ( n times, n, )z n times Exponential A and C form a left-recursion cycle grown at A, so C is not memoized and each C evaluates the next one twice
var v string; R = "(" {v := v + "0"} R ")" "y" / "(" {v := v + "1"} R ")" "z" / "n" {len(v) >= 0} As above Exponential The two calls of R at a position see different values of v, so neither reuses the other's result
main = xs:"x"* => len(foldl("a", $xs, (acc, x) => acc + acc)) x n times Linear evaluations, exponential value size The accumulator doubles for each element

The same grammars with A = "n", or with the bindings {v := "0"} and {v := "1"}, are linear.

Conditions for Linear Work

A grammar does work at most proportional to n, times a constant that depends only on the grammar, when all of the following hold. The conditions are sufficient, not necessary.

  1. Variables. No variable that a memoized rule reads is bound, along a recursion of rule calls, from an expression that reads that variable's own earlier value. When variables are bound from captures instead, the work is linear given an environment bound.
  2. Left-recursion cycles. A rule of a left-recursion cycle other than the rule the cycle grows is never evaluated twice at one position outside the growth of its cycle.
  3. Repeated expressions. No expression of the table in Expressions That Repeat Work can be abandoned and then evaluated again at a position strictly inside the input that it covered. An expression can be abandoned when it is in an alternative that is not the last of its choice, when a later part of its sequence or repetition element can fail, when it is inside a lookahead, and when it is in the base of a #recover, including through the rules that call it.
  4. Values. No action or predicate builds a value whose size can double from one evaluation to the next.

When condition 3 fails, the degree of the polynomial is one more than the number of such expressions nested in one another, through rule calls. When condition 1 or 2 fails, or a value doubles, work can be exponential.

Memory

  • Memo table. It holds at most one entry per memoized rule, position, binding level and combination of variable values.
  • Values. A parse keeps the value of its start rule. Values built by abandoned alternatives are not kept, but their construction is work.
  • Call stack. It holds at most as many calls as the nesting limit allows.
  • Streams and Documents. A stream parse discards memo entries and input before the elements that it has delivered. A Document keeps memo entries and repetition records for its whole text between parses.

Bounding Work

Work limits bound the work of any parse, whatever its class. In the engine backends, MaxInputWork counts the input that terminals, repetitions of character classes and lookaheads examine, including input examined again, and MaxSteps counts rule calls, repetition iterations, growth steps, and the strings and lists that actions and predicates build, in proportion to their size. A value that grows without reading the input is therefore bounded by MaxSteps and not by MaxInputWork.

Limits

An implementation MAY limit the nesting depth of rule calls, so that input of unbounded nesting cannot exhaust the memory of the host. When the limit is exceeded, the parse fails with a run-time error and no value. In the reference implementation the error is nesting too deep: more than N rule calls; the default limit is 100,000 nested calls (10,000,000 for the iterative bytecode VM), and the Go API changes it with pego.WithMaxDepth. The operand of a prefix operator and the right operand of an infix operator of an Operators Rule nest like rule calls; chains of left-associative operators do not nest.

Cancellation and Work Limits

The reference Go API accepts invocation-local cancellation and input-work and execution-step limits through WithContext and WithWorkLimits; generated Go exposes corresponding package-local options. These controls are cooperative and backend-local. A zero limit disables that counter. Each counter is monotonic for one invocation and resets on the next call. A reservation is checked before the corresponding work begins; if it exceeds the remaining allowance, that work is not performed. Counts are implementation-specific and are not elapsed-time, memory, or buffered-input limits.

Cancellation and exhausted limits are fatal interruptions, not ordinary syntactic mismatches. Ordered choice, predicates, recovery, and other grammar constructs cannot absorb them. An interrupted batch parse returns no partial Node or typed value. In streaming mode, elements already delivered to the caller remain delivered; the parser does not retract them. A blocked reader or caller callback cannot be preempted by a synchronous parser; cancellation is observed after the call returns.

For a Document, a cancellation or option error detected before execution has touched reusable state preserves its valid caches and last statistics. Once execution starts, an interruption clears execution memo/repetition state before the next attempt, because entries can depend on unfinished left-recursion work. Fully completed input tables may be retained, but incomplete tables are never published and are discarded. A retry with fresh invocation controls must produce the same result as parsing the current text from scratch.

In the Go API, WithContext(ctx) supplies the context and WithWorkLimits(WorkLimits{MaxInputWork, MaxSteps}) the limits. Input work counts inspected elements of the input representation; steps count backend execution work. Charges are not rolled back by backtracking, and their values depend on the backend, the position unit and the generated artifact. For a given artifact, input and options they are the same on every run: the storage that an earlier parse left in a reused parser, and when a collection releases it, never change them. Reading input MAY charge only input work: a loop that only scans input, such as a repeated character class, can run without charging steps, so MaxSteps alone does not bound scanning and MaxInputWork is the limit that does. A cancelled context returns the error of the context (errors.Is(err, context.Canceled) or context.DeadlineExceeded); an exhausted limit returns an error that matches pego.ErrWorkLimit, and errors.As with *pego.WorkLimitError gives the details. Stream parses and documents are specified in Streaming and Incremental Documents.

Modes

The same grammar can be run in several modes. Except where stated, each mode gives the results that an ordinary parse gives.

Mode Difference from an ordinary parse
Backends The reference implementation has three backends (closure, recursive bytecode and iterative bytecode). They give identical values, errors and positions, and differ in speed and in the limit of nesting depth. Generated Go and TypeScript parsers implement the same matching semantics; the entry points, features and limits that differ are defined in Generated Parsers
Recognition The parse only tells whether the input matches. It gives the same success or failure and the same syntax errors, builds no tree, and skips actions whose values are not needed by predicates. Actions needed by predicates still run and can report run-time errors. The captures that predicates read are still built
Stream A parse that reads its input incrementally and hands each element of the #stream repetition of the start rule to the caller as it matches. The elements are not part of the value. See Streaming. Generated TypeScript parsers have no stream mode
Incremental A document that is edited and reparsed reuses results of the previous parse. The result of a parse is the same as that of a parse of the whole new text. See Incremental Documents. Generated TypeScript parsers have no document, and generated Go parsers support a subset
Prefix A named rule is parsed at the beginning of the input without requiring the whole input to be consumed. See Prefix Parsing