061. Explicit Start Rules
- Status: Implemented
- Author: @ornew
- Date: 2026-10-22
Summary
A grammar names the rules where parsing may begin with the declaration
attribute @start, written on the lines before rule. Only those rules, the
start rules, can be entered: by default parsing, by name, by compiled grammars,
documents, streams, prefix parsing and generated parsers alike. One start rule
is the default; when there are several, @start(default) marks it.
@start(default)
rule file = stmt*
@start
rule stmt = expr ";"
rule expr = [0-9]+
Here file is the default start rule, stmt can be entered by name, and
expr can only be called from other rules.
Motivation
When a grammar does not declare its entries, any rule can be entered, and the
default start rule is a convention of each tool, such as a rule named main
or the first rule. A helper rule can then be entered from outside and its
value relied on, the same grammar starts at different rules in different
tools, and a generated parser carries a named entry, with its dispatch case and
typed converters, for every rule, although most rules are only called from
other rules. The tools cannot tell which rules a grammar means as entries.
Goals
- The entries of a grammar are declared in the grammar, next to the rules.
- The default start rule does not depend on declaration order.
- Every way into a grammar accepts only start rules and names the rule in its error, with the interpreter, compiled grammars and generated Go and TypeScript parsers agreeing.
- Generated parsers emit entry dispatch only for start rules.
Non-Goals
- Removing rules that no start rule reaches, or code kept only for external entry; see Limitations.
- Start rules for Operators Rule levels.
Design
Public Behavior and Contracts
@start and @start(default) are declaration attributes of a rule, defined
in Grammar Files. The argument of
@start is the bare identifier default and nothing else. A grammar is
invalid when:
- no rule is marked
@start; - several rules are marked and none is
@start(default); - more than one rule is
@start(default); - a rule is marked
@starttwice; @starthas any other argument;- a hidden rule, whose name begins with
_, is marked@start; - a rule has a declaration attribute other than
@start.
These errors are reported once the grammar is otherwise valid, so that a grammar under construction shows its other errors first.
Parsing without a rule name uses the default start rule. Naming a rule that is not a start rule is an error saying that the rule is not a start rule; naming a hidden rule says that it is hidden, and naming one that does not exist says that it is not defined. The hidden rules behind inline actions are never start rules. The compile and generate functions take a start argument, and every interface that takes the name of a start rule accepts an empty name: it selects the default start rule of the grammar, and any other name must be a start rule.
Implementation and Invariants
The compiler resolves the start rules into the program, in declaration order, with the default. Every parse checks its start rule before it prepares a backend, so the check is the same for the closure engine and both bytecode models and does not depend on which tables a backend has built.
A compiled grammar stores the start rules and the default in the bytecode module, after the rule table, and the loader validates them: at least one start rule, distinct and in rule-table order, and the default among them. The compiled grammar holds no syntax tree, so the module's start rules are the only record of them in the file.
Generated parsers record the start rule names. The Go ParseRule and the
TypeScript parseRule look a name up among them and report the same errors as
the interpreter; the typed Go ParseRuleAST has a case, and converters, only
for start rules.
Compatibility and Backend Support
Every grammar marks its start rules, and a grammar without @start does not
compile. The start rules are part of the bytecode module under its ISA
version, so a compiled grammar of an earlier ISA version is rejected and is
compiled again. A rule that a generated parser enters by name is marked
@start.
Alternatives Considered
- Every rule is an entry, and the default is a convention. It needs no annotation, but each tool chooses its own default, and generated parsers pay for every rule as an entry.
- The first rule is the default. It needs no annotation either, but declaration order would carry meaning, and moving a rule would change what parsing does.
- A grammar-level start declaration, such as
start main. It would name the start rules in one place, away from the rules themselves. A declaration attribute keeps the information on the rule, and moves and renames with it. - Treating unmarked grammars as having all rules as start rules. It needs no marks in grammars that do not care, but keeps the implicit entries and makes the set of entries depend on whether any rule is marked.
Testing
Compile errors are checked with their positions, including that other errors come first. The restriction is checked on parsing, streams, iteration, documents, prefix parsing and compiled-grammar encoding, on the closure engine and both bytecode models, with and without recognition, and after a compiled grammar round trip with and without its syntax tree. Module validation rejects missing, out-of-range, repeated and misordered start rules and a default that is not a start rule. Generated Go and TypeScript parsers reject non-start rules with the interpreter's messages.
Performance and Results
A per-rule entry of a generated Go parser, its ParseRuleAST case and typed
converters, costs bytes in parser.go, and only start rules have one. For the
bundled grammars, generated with pego gen -types -recognize -runtime import
(DuckDB SQL, TypeScript and JSON also with -nodoc), the entries of the rules
that are not start rules would cost:
| Grammar | parser.go bytes |
Entries of other rules, bytes | Share with them |
|---|---|---|---|
| DuckDB SQL | 13,381,886 | 485,337 | 3.50% |
| TypeScript | 5,416,888 | 146,819 | 2.64% |
| XML | 468,457 | 18,245 | 3.75% |
| minilang | 253,370 | 8,687 | 3.31% |
| JSON | 117,837 | 8,671 | 6.85% |
| calc_lr | 64,417 | 3,518 | 5.18% |
| outline | 44,587 | 4,028 | 8.29% |
| CSV | 43,617 | 567 | 1.28% |
| calc | 41,045 | 1,693 | 3.96% |
Generated TypeScript parsers have no per-rule typed entries; their table of start rules costs a few bytes.
Limitations and Open Questions
- Generated parsers contain rules that no start rule reaches, and standalone entry bodies of some rules that no start rule enters directly.