Generating Parsers With pego gen
pego gen turns a grammar into Go source files: a parser package you commit to your repository, build with your program, and
call like any other package. By default the generated Go package depends only on the standard library: it carries a
copy of the PEGO runtime in its internal/runtime directory, and it does not load or compile the grammar at run time.
This guide shows how to generate a parser, what the generator supports compared with the engine (the pego.Parser of
Running Parsers), how to keep generated code up to date, and when it is the right choice. The API
of the generated Go package is in The Generated Go API, and the options that add typed values,
documents and streams have guides of their own, linked below. pego gen -lang ts generates
TypeScript modules with the same behavior instead; it has a guide of its own, TypeScript parsers.
- Why generate
- Quick start
- The
pego gencommand - What is supported
- Keeping generated code up to date
- Trade-offs
- Recipes
Why Generate
Engine (pego.CompileSource) |
Generated parser | |
|---|---|---|
| Speed | Closure backend by default | Generated Go takes 0.44–0.61× closure time in CodePoints and 0.50–0.66× in Bytes; direct typed values take 0.69–0.98× generated Parse time (benchmarks, analysis) |
| Dependencies | github.com/ornew/pego |
Standard library only (the runtime is copied into the package; -runtime import imports it from github.com/ornew/pego/runtime instead) |
| Grammar at run time | Loaded and compiled (or loaded from .pegoc) |
Gone: it is compiled into Go code |
| Grammar changes | Edit the grammar, restart | Regenerate and rebuild |
| Recognition (no tree) | RecognizeOnly |
Recognize, with -recognize |
| Streaming, incremental, backends | Yes | Go Node ParseStream with -stream (-types adds ParseStreamAST); Go Node Document with -document; -types adds Document.ParseAST, using direct incremental matching for eligible grammars and Node conversion otherwise. No backend choice (see what is supported) |
Generate a parser when the grammar is fixed, the parser is on a hot path or in a small binary, or you want no
dependency on PEGO at run time. Use the engine when the grammar is data. For streams of Nodes, generate a Go
ParseStream with -stream; for incremental Node parsing, generate a Go Document with -document. The engine offers
the broader stream and Document APIs.
A good deal of the time you can use both: the engine in tools and tests, and the generated code in production
(see Recipes).
The generated parser returns the same tree and the same errors as the engine, so switching between them does not
change what your code sees. The test suite checks this for every grammar in examples/
(design record 008).
Quick Start
A complete, runnable setup. The grammar, pairs.pego, is the one used in the Running Parsers:
type Pair = { Key node[string]; Value node[string] }
@start(default)
rule main = ws ps:pair+ $$ => $ps
rule pair Pair = k:key "=" v:value ";"? ws => Pair{Key: $k, Value: $v}
@start
rule key = [a-zあ-ん]+ #atomic
rule value = [0-9]+ #atomic
rule ws = [ \t\n]*
The layout is a module with the grammar next to the code that generates from it, and the generated parser in its own package (see why its own package):
example.com/pairs
├── go.mod
├── pairs.pego
├── generate.go
├── main.go
└── pairsparser/ # created by go generate
├── parser.go
├── api.go
└── internal/runtime/ # and, with -types, types/types.go
Record pego as a tool dependency of the module, so that go generate runs the pinned version and nobody needs a
separate install:
go get -tool github.com/ornew/pego/cmd/pego
This adds a tool github.com/ornew/pego/cmd/pego line to go.mod. Then declare the generation step with a
go:generate directive; pego gen creates the output directory:
// generate.go
package main
//go:generate go tool pego gen -g pairs.pego -module example.com/pairs/pairsparser -o pairsparser
go generate ./...
-module is the import path of the generated package, which the copy of the runtime in pairsparser/internal/runtime
is imported by; -o is the output directory, which pego gen creates. The result is pairsparser/parser.go, with the
code generated from the grammar, pairsparser/api.go, which re-exports the runtime names the users of the parser need
(Node, SyntaxError and so on), the runtime copy and, with -types, pairsparser/types/types.go, the package of the Go types of the grammar (the -module path is then required). parser.go starts with:
// Code generated by pego. DO NOT EDIT.
// Package pairsparser is a parser generated from a PEGO grammar.
package pairsparser
Now use it:
// main.go
package main
import (
"encoding/json"
"errors"
"fmt"
"log"
"example.com/pairs/pairsparser"
)
func main() {
node, err := pairsparser.Parse("abc=12; あい=3")
if err != nil {
log.Fatal(err)
}
fmt.Println(node)
for _, pair := range node.Elems() {
key := pair.Field("Key").(*pairsparser.Node)
fmt.Printf("%s %q at %d-%d\n", pair.Type(), key.Text(), key.Start(), key.End())
}
// Positions in UTF-8 bytes.
node, _ = pairsparser.Parse("あい=3", pairsparser.WithUnit(pairsparser.Bytes))
fmt.Println(node.Elem(0).End())
// Any rule marked @start can be the start rule.
node, err = pairsparser.ParseRule("key", "abc")
fmt.Println(node, err)
_, err = pairsparser.ParseRule("nope", "abc")
fmt.Println(err)
// Errors have the same fields as the engine's, but are types of the generated package.
_, err = pairsparser.Parse("abc=x")
var se *pairsparser.SyntaxError
fmt.Println(errors.As(err, &se), se.Line, se.Col, se.Expected, err)
out, _ := json.Marshal(node)
fmt.Println(string(out))
}
[Pair{Key: "abc", Value: "12"} Pair{Key: "あい", Value: "3"}]
Pair "abc" at 0-3
Pair "あい" at 8-10
8
"abc" <nil>
rule nope is not defined
true 1 5 [[0-9]] 1:5: syntax error: expected [0-9]
{"start":0,"end":3,"value":"abc"}
The output is exactly what the engine prints for the same grammar and input.
The pego gen Command
pego gen -g <grammar> -module <import path> [-pkg <package>] [-runtime embed|import|only] [-s <rule>] [-o <directory>] [-types] [-document] [-stream] [-recognize] [-observability] [-work-controls] [-default-value] [-nodoc] [-max-depth <n>]
| Flag | Default | Meaning |
|---|---|---|
-lang |
go |
The language: go, or ts for TypeScript (see TypeScript parsers) |
-g |
(required, except with -runtime only) |
The grammar: .pego or .json |
-module |
(required for Go with -runtime embed) |
The import path of the generated package. The copy of the runtime is imported by <module>/internal/runtime. With -runtime import, -pkg can stand in for it, since the package then needs no import path of its own. |
-pkg |
last element of -module, without a major version suffix (example.com/m/v2 gives m) |
The package name of the generated code. It must be a valid Go identifier; a module path whose last element is not one (my-parser) needs -pkg. |
-runtime |
embed |
How the generated code reaches the runtime: embed writes a copy (Go: internal/runtime; TypeScript: runtime.ts), import imports it (Go: github.com/ornew/pego/runtime; TypeScript: the module of -runtime-module), and only writes just the copy, to refresh it without regenerating the parser. only takes only -o; a grammar or any other generation flag is an error |
-runtime-module |
(required for TypeScript with -runtime import) |
The module specifier that the generated TypeScript imports the runtime from, for example ../shared/runtime.ts. An error otherwise, and for Go |
-s |
the default start rule | The start rule of the generated Parse function |
-o |
(required) | The output directory (it is created). Generated files that the new run does not write again are removed, but -runtime only never removes parser.go, api.go, types/types.go, parser.ts or types.ts, and no file without the // Code generated by pego. header is replaced or removed: pego gen stops with an error instead. |
-types |
off | Also generate the Go types for the grammar's types, in the package <module>/types, and ParseAST (see typed values) |
-document |
off | Also generate the Go incremental Node Document API; with -types, it also exposes Document.ParseAST (see incremental documents); cannot be combined with -lang ts |
-stream |
off | Also generate the Go Node ParseStream (see streams); the start rule must have a #stream repetition; with -types it also generates ParseStreamAST, and it cannot be combined with -lang ts |
-typed-only |
off | Go only; needs -types. Generate only the typed entry points: ParseAST and ParseRuleAST, without Parse, ParseRule and Recognize and without the code that builds untyped *Node values, which about halves the source of a large grammar. It cannot be combined with -recognize, -document, -stream or -lang ts. Entry points that convert the Node of their rule keep the unexported Node rule functions they run |
-recognize |
off | Also generate Recognize, which checks input without building a tree (see recognition) |
-work-controls |
off | Generate the work-controlled form of the parser, which supports WithContext and WithWorkLimits and exports them from the package; in TypeScript it supports the signal, maxInputWork and maxSteps options (see TypeScript). Without it the Go parser holds the ordinary form of its functions and nothing that charges work, which makes parser.go 15% to 50% smaller (the plain parser.go is 0.50 to 0.85 of the controlled one for the grammars of parsers/; larger grammars save more), and a parse that passes a control option returns an error (see cancellation and work limits). A parser has one form: with the option it is the controlled one, and a parse that sets no limit runs the same code (a few inline tests of the control take the unmetered path), so generate two parsers if you need the faster plain form as well. A TypeScript parser generated without the option emits no code that charges work and sets no control, and its options type and exports have no cancellation or limits. With -document and without the option, the Document form has no charges |
-observability |
off | Emit opt-in tracing and profiling for Go or TypeScript parsers; combines with -types, -recognize, and Go -document and -stream (see generated parser tracing and profiling) |
-max-depth |
0 |
Default nested rule-call limit in the generated parser; zero selects 100,000, a positive value overrides it, and negatives fail generation |
-default-value |
off | Make the parser return the default node tree: value actions and declared rule types are dropped, predicates still run (see Default Values). Cannot be combined with -types |
-nodoc |
off | Leave out the package comment (// Package p is a parser generated from a PEGO grammar.), for a package that has its own documentation in another file |
Details:
-
Both languages need
-o, because the output is several files. TypeScript writesparser.ts,runtime.tsand, with-types,types.ts. -
The output is formatted with
gofmt, passesgo vet, and is deterministic: generating twice gives identical files. That is what makes the up-to-date check possible. -
Grammar errors are reported with positions, before any file is written. A rule with no expression is a syntax error:
// broken.pego @start rule main =pego gen -g broken.pego -module example.com/pairs -o /dev/nullpego: broken.pego:4:1: expected an expression, found end of fileAn action that reads a capture the rule does not have is a compile error:
// typeerr.pego @start rule main = x:[a-z]+ => $ypego gen -g typeerr.pego -module example.com/pairs -o /dev/nullpego: typeerr.pego:3:25: undefined capture $ypego gen -g pairs.pego -module example.com/pairs -s nope -o /dev/nullpego: pairs.pego: rule nope is not definedpego gen -g pairs.pego -module example.com/pairs -pkg foo-bar -o /dev/nullpego: pairs.pego: invalid package name "foo-bar" -
A
.pegoccannot be used, because code generation needs the grammar and a compiled grammar holds only bytecode:pego: pairs.pegoc: a compiled grammar holds only the bytecode of one mode; gen needs the grammar source (.pego or .json). -
Without
-s, the grammar's default start rule is used.
From Go
The same generator is available from Go, in package github.com/ornew/pego/gen, for build tools that produce parsers programmatically:
g, err := pego.ParseGrammar(src)
if err != nil {
log.Fatal(err)
}
files, err := gen.Go{Module: "example.com/pairs/pairsparser"}.Generate(g, "main") // grammar, start rule
if err != nil {
log.Fatal(err)
}
for _, f := range files { // parser.go, api.go, types/types.go with Types, and internal/runtime/*.go
path := filepath.Join("pairsparser", filepath.FromSlash(f.Path))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
log.Fatal(err)
}
if err := os.WriteFile(path, f.Content, 0o644); err != nil {
log.Fatal(err)
}
}
The fields of gen.Go are the flags of pego gen: Module is -module; Types: true is -types; Document is -document; Stream is -stream; Recognize is -recognize; TypedOnly is -typed-only; WorkControls is -work-controls; Observability is -observability; SourceInfo is -source-info; DefaultValue is -default-value; OmitPackageDoc is -nodoc; Runtime: gen.RuntimeImport is -runtime import; PackageName: "p" is -pkg p; and MaxDepth: 600_000 is -max-depth 600000. Omitted, zero and explicit 100,000 defaults generate identical source. Observability supports standalone Go Node and typed batch parsers, including ParseAST and ParseRuleAST, Go Documents and streams, and TypeScript batch parsers with or without -types. Generated Go Node, typed and Document packages expose WithProfile; TypeScript batch packages expose Profile through per-call options. Typed direct execution and Node-conversion fallback report their actual rule calls; conversion itself has no separate event. Invalid defaults return an error before generated output is written.
pego gen is a thin wrapper around gen.Go (and gen.TypeScript with -lang ts, whose Generate returns the files too); both implement gen.Generator. Because a grammar built with the grammar package (see the
Running Parsers) is just an AST, it can be generated from, too.
When -observability is enabled, the generated Go or TypeScript package
includes opt-in rule tracing and profiling. See generated Go Node and typed
traces, generated
TypeScript traces, and generated
TypeScript profiling
for the contracts and examples.
The ordinary generated source is unchanged when this option is off. Enabling
it adds generated code and some parse overhead even when no callback is passed;
delivering events costs more. Focused Node measurements showed no-callback
parse ratios around 1.01–1.06× and recognition around 1.02–1.20×. Typed
no-callback ratios ranged from 0.995–1.075×; scalar-store callbacks ranged
from 1.169–3.164× depending on workload and execution path. These are synthetic
measurements, not workload-wide estimates. Focused WithProfile runs were
3.8–26.9× ordinary time and added about 45–395 KB/op and 25–50 allocations/op
across the measured cases; see
design 043
for scope and generation costs. Focused TypeScript profiles measured 1.58–4.02×
ordinary time, depending on API and fixture; see the TypeScript profile
measurements in design 043 for conditions and limits.
What Is Supported
The generator supports the whole grammar language: Rule Expressions, Operators Rules, left recursion, Predicates,
variables, actions, types, built-in functions, @error and #recover. What differs from the engine is the runtime
machinery, which the generated code only has when you ask for it:
-stream,-documentand-recognizeaddParseStream,DocumentandRecognize(and-typestheir typed forms). Without-stream,#streamis an ordinary repetition. TypeScript has neither streams nor documents.ParseRulereplacesWithStartfor another start rule, andWithUnitandWithMaxDepthwork as in the engine;-max-depthsets the generated default.- Cancellation and work limits are
WithContextandWithWorkLimitsin the engine and in generated Go with-work-controls, and thesignal,maxInputWorkandmaxStepsoptions in TypeScript with-work-controls; see Cancellation and Work Limits and the specification. - There is no choice of backend, because the generated code is a backend of its own, and no
Parser.Grammar(), saving or loading, because the grammar is gone at run time. #missingworks only in the engine's Closure backend for whole-input parsing and recognition, and the generator rejects grammars that use it.
The specification has the complete table.
Notes:
- Depth. Generated code uses the Go stack for rule calls and fails with
nesting too deep: more than 100000 rule callsat the default limit.WithMaxDepthchanges the limit for one invocation, and-max-depthchanges the generated default. With the nested-parentheses grammarrule nested = "(" nested ")" / "x", 50,000 levels parse and 200,000 levels fail with that error. If you must accept deeper nesting beyond the host stack, the engine'sBytecodeIterativebackend withWithMaxDepthis the tool (Choosing a Backend). - Memory. Generated parsers memoize in the same way as the engine, so memory still grows with the input. For input
larger than memory, generate
ParseStreamwith-streamor use the engine'sParseStream. - Using a stream grammar with the generator is fine:
Parsetreats the#streampragma as an ordinary repetition, so the same grammar file serves both streaming and whole-input parsing.
Keeping Generated Code Up to Date
Generated code is source: commit it, review its diffs, and regenerate it whenever the grammar changes or you upgrade
PEGO (the embedded runtime comes from the version of pego that generated it). Three habits keep it honest.
1. Generate with go generate. Put the directive next to the grammar, as in Quick start, and run go generate ./.... Pin the generator with
go get -tool as shown above. (go install github.com/ornew/pego/cmd/pego@latest and
//go:generate pego gen ... works, too, but every developer then has to install a matching version.) With several grammars, add one directive per grammar.
2. Test that it is up to date. A test regenerates in memory and compares with the committed file; it fails with a reminder when somebody changed the grammar and forgot to regenerate:
//go:embed pairs.pego
var grammarSrc string
// The files of pairsparser are up to date with pairs.pego.
func TestGeneratedIsUpToDate(t *testing.T) {
g, err := pego.ParseGrammar(grammarSrc)
if err != nil {
t.Fatal(err)
}
want, err := gen.Go{Module: "example.com/pairs/pairsparser"}.Generate(g, "main")
if err != nil {
t.Fatal(err)
}
for _, f := range want {
got, err := os.ReadFile(filepath.Join("pairsparser", filepath.FromSlash(f.Path)))
if err != nil {
t.Fatal(err)
}
if string(got) != string(f.Content) {
t.Errorf("pairsparser/%s is out of date; run go generate ./...", f.Path)
}
}
}
If you change pairs.pego (here: rule value = [0-9a-f]+ #atomic ) and skip regeneration, the test fails. Among the output:
--- FAIL: TestGeneratedIsUpToDate (0.00s)
generated_test.go:49: pairsparser/parser.go is out of date; run go generate ./...
FAIL
Run go generate ./... in your project and check that the generated files have no diff (for example, with git diff --exit-code). This makes stale output fail in your own CI.
3. Test that it behaves like the engine. The engine is the reference implementation, and a one-time parity test protects you from the two diverging (for example after an upgrade):
func TestGeneratedMatchesEngine(t *testing.T) {
p, err := pego.CompileSource(grammarSrc, "main")
if err != nil {
t.Fatal(err)
}
for _, in := range []string{"abc=12", "abc=12; あい=3", "", "abc=", "abc=1 x"} {
want, werr := p.Parse(in)
got, gerr := pairsparser.Parse(in)
wj, _ := json.Marshal(want)
gj, _ := json.Marshal(got)
if string(wj) != string(gj) || fmt.Sprint(werr) != fmt.Sprint(gerr) {
t.Errorf("%q:\n engine %s %v\n generated %s %v", in, wj, werr, gj, gerr)
}
}
}
Comparing JSON and error text covers the whole tree (with positions) and all error details. Include inputs that fail, and inputs that exercise each feature of your grammar.
Trade-Offs
- Speed. In the current benchmark results, generated Go takes 0.44–0.61× closure time in
CodePoints and 0.50–0.66× in Bytes across eight workloads. Direct
ParseASTvalues take 0.69–0.98× generatedParsetime; conversion workloads that include generic nodes can take longer. Standard-library comparisons produce different outputs and remain faster where they apply. These measurements are from one machine and commit; rungo run ./bench/reportto measure yours. - Binary and repository size. Each generated parser adds source that you commit and that compiles into your
binary:
parser.goandapi.go, with-typestypes/types.go, and with-runtime embeda copy of the runtime ininternal/runtime(the same for every grammar;-runtime importshares one copy throughgithub.com/ornew/pego/runtimeinstead). It does not pull in PEGO's compiler, analyzer or VMs. - Start-up. There is none: no grammar to parse or compile. If start-up time is the only concern and a source file
is acceptable, loading a
.pegocis the cheaper alternative. - Flexibility. The grammar is frozen into the binary. Changing it means regenerating, rebuilding and redeploying; if users supply grammars at run time, use the engine.
- Features. No backend choice; streaming requires
-streamand incremental Node parsing-document(see what is supported). - Review noise. Large diffs on regeneration (particularly after upgrading PEGO) are expected. Mark the files as
generated for your review tool: the first line,
// Code generated by pego. DO NOT EDIT., follows the Go convention that tools recognize. - Typed AST.
Parsereturns generic*Nodetrees like the engine, which is what makes the two interchangeable. For Go types matching the grammar's types, generate with-typesand callParseAST; for types of your own, write a small conversion from*Nodeor from the typed values, as parsers/json does (ToAny).
Recipes
One Grammar, One Package
Each generated package defines Node, SyntaxError, Parse and the rest at package level, so two generated parsers in
the same package collide:
both/b.go:17:6: Node redeclared in this block
both/a.go:17:6: other declaration of Node
Give every grammar its own package (and directory), as parsers/* and internal/bench/gen/* do. If you also
want several entry points of one grammar, use ParseRule rather than generating it twice.
Engine in Development, Generated in Production
Keep one .pego file. Use pego parse and the engine (CompileSource) in tools, experiments and tests, where start-up
and features matter more than speed; generate the production parser from the same file; and let the
parity test prove they agree.
Choosing the Start Rule
Mark every rule where parsing may begin with @start, and the usual entry rule with @start(default), so that
Parse is the common case; -s (for example pego gen -g lang.pego -module example.com/lang -s program) selects
another start rule for Parse. Use ParseRule("expr", input) for the other start rules, for example for an
expression evaluator embedded in a larger tool.
Wrapping the Result in Your Own Types
If the grammar declares the types you want, -types generates them. Otherwise: the generated
Node is the same loose shape for every grammar. A thin layer keeps that out of the rest of your program:
type Pair struct{ Key, Value string }
func Pairs(input string) ([]Pair, error) {
node, err := pairsparser.Parse(input)
if err != nil {
return nil, err
}
var out []Pair
for _, n := range node.Elems() {
out = append(out, Pair{
Key: n.Field("Key").(*pairsparser.Node).Text(),
Value: n.Field("Value").(*pairsparser.Node).Text(),
})
}
return out, nil
}
Design the grammar's types and actions (Declaring Types, Actions) so that this layer is thin, and unit-test it with the engine and the generated parser alike.
Related Topics
- The Generated Go API:
Parse, recognition, call options, cancellation and work limits. - Typed Values in Generated Go, Incremental Documents in Generated Go
and Streams in Generated Go: the packages that
-types,-documentand-streamadd. - Generated TypeScript Parsers: the same generator for TypeScript.
- Compiled Grammars: the other way to skip compiling at run time.
- Using the Ready-Made Parsers: parsers made with this generator.