048. Inline Grammar Actions
- Status: Accepted
- Author: @ornew
- Date: 2026-10-12
Summary
The source form (expression => term) is an inline grammar action. It behaves
exactly as a call of a hidden rule whose body is the expression and whose
whole-rule action is the term, written at that position. The compiler lowers it
that way, so every backend runs it with the semantics of a rule.
type Number = { Text node[string] }
type Word = { Text node[string] }
type Expr ~ Number | Word
type Main = { Value Expr }
rule number Number = t:([0-9]+) #atomic => Number{Text: $t}
rule word Word = t:([a-z]+) #atomic => Word{Text: $t}
rule item Expr = (n:number => $n) / (w:word => $w)
rule main Main = item:item => Main{Value: $item}
The whole-rule action remains => term after the complete ordinary rule
expression. Operators Rule actions apply separately to operand and operator
entries. Operator-entry actions keep their specialized $lhs, $rhs and $op
context. A declaration-level action on an Operators Rule may be omitted or use
the explicit => ... default-CST form; any other action is rejected at compile
time. Only a single-term Value Action can be inline; an inline braced Value
Action block is a compile-time error, as a whole-rule one is.
Hidden rules
A rule whose name begins with an underscore is hidden. Hidden rules are rules in every other respect: they are memoized, may recur (also on the left), and a cut or a recovery inside them is scoped to the rule. They differ in three ways.
- Not an entry point. A hidden rule cannot be the start rule of a grammar, a
compiled grammar, a schema or a generated parser, and cannot be parsed by name
(
ParseRule,Parse(name, ...)). Each of these is an error that says the rule is hidden.Program.Rulesdoes not list them. - No node of their own. A hidden rule without an action, not even
=> ..., makes no node. A call of it contributes what its expression would contribute written in its place as a group: its elements join the sequence that calls it, and its value, where it takes one element, is that of the group. The captures made inside it end with the rule and are not visible to the caller. A hidden rule with an action, which includes every inline action, yields the value of the action. - Not a type. A hidden rule without an action adds no type to the schema, the AST or the typed value, and cannot declare a result type. Where a recursive call of one is an element of its own, it has the type of the group.
A sequence of elements that a hidden rule would contain in itself, as in
rule _a = "x" _a, has no fixed number of elements and is a compile-time error.
A call inside a choice, an option, a repetition or a capture is an element of its
own and may recur.
Contract
(e => A) in a rule r means the same as a call of the hidden rule
_r_n = e => A, and with rule a = _ => A and rule b = a => B, the rule b is the same as
rule b = (_ => A) => B. The rules below follow from this.
- Scope. Inside
Aonly what a rule bodye => Asees is visible: the captures made insidee, and its items. The captures of the enclosing rule are not visible. The captures made insideeare scoped to the hidden rule and end at the closing parenthesis. The captures made before the parentheses are not closed: the expressions after them and the whole-rule action can still read them. - Value and type. The inline action is one positional element of the
enclosing sequence, like a rule reference, and its value is the result of
A.Areturns a node orniland is checked at compile time like the action of a rule; the result type is the type ofA, or the default value type for=> .... The typed value, the AST and the schema treat the item as a reference to a rule of that type. - Matching.
Aruns afterematches. A failingeruns no action, and its failure, expectations and error position are those of the failing rule. A choice selects its branch by PEG matching before the action runs. An action error is fatal and does not retry another branch or enter recovery. - Cut, recovery, recursion, depth. The boundary is a rule boundary. A cut inside the parentheses commits only within them, a recovery inside them belongs to the hidden rule, left recursion through them is left recursion through a rule, and the call counts against the maximum depth.
- Memoization. The hidden rule is memoized like any other rule where the analysis allows; memoization is not observable.
- Unused values. An inline action in a lookahead (
&,!) or a discard (-) is a compile-time error with the message "an inline action is not allowed in a lookahead or a discard, whose value is not used". - Name. The hidden rule of the nth inline action of the rule
r, inner actions first, is_r_n, with a trailing underscore added while a rule of that name is declared. A reference to a rule resolves against the rules declared in the grammar as written, so the name of a hidden rule that the compiler made never satisfies one. The name is shown where the program runs the rule: trace events, profiles and source information. A compile-time error and the text of a failed action name the inline action by the rule that holds it (the inline action in rule r, at the position of the action). Inside a type in a message the inline action stands for the type of its result (node[(int, int)]), andpego sample -coveragereports its rule asthe inline action in rule r(inline action n in rule rwhenrholds several) and its alternatives as written.
Implementation
LowerInlineActions (internal/compile/inline_actions.go) replaces each inline
action by a reference to a hidden rule placed after the rule that holds it,
before attribute normalization. It runs once, in the compiler, so the closure
and bytecode VMs and the generated Go (generic, typed and direct) parsers need
no case of their own for it. The program keeps the grammar as written
(Program.SourceGrammar, returned by Parser.Grammar, in every mode including
default values) and serializes it: the inline actions stay in the AST of a
compiled grammar, and a loader lowers them in the same way before applying the
stored rule flags, which are those of the lowered rules.
A hidden rule without an action is a splice rule (rule.splice). The shape
functions (value_shape.go) and the value types treat a call of it as the group of
its body, and the rule itself returns its body's result as it is: the tuple node
of its elements, which its callers flatten, or its one element. The bytecode
instruction SPREAD pushes the elements of that result after the call, the
closure matcher and the generated code flatten the children of the tuple node, and
a node that takes the tuple as its own takes a copy, since a memo entry shares it.
Rules record the text that names them in a failed action (RuleInfo.Where) and
whether they splice (RuleInfo.Splice).
The differential tests compare each grammar written with inline actions against the same grammar with explicit hidden rules, and each hidden rule without an action against its expression written in place, on every VM backend, and run the grammars in the corpus of generated parsers.
Alternatives
- Whole-rule actions only. This is simpler, but cannot reduce a matched sequence before later sequence elements.
- A suffix on one primary. It cannot name the complete preceding sequence as the reduction input.
- Braced local actions. Braces overlap with type constructors and declaration bodies. Parentheses delimit the expression and action term.
- A dedicated instruction and backend support. An inline instruction that reduces the wrapped value and clears the captures needs its own frame handling, capture rules and recovery behavior in each backend, and these rules differ from a rule's. Lowering to a rule reuses the rule machinery and its tests.
Limitations
An inline braced Value Action block is rejected. The name of the hidden rule is visible in trace events, profiles and source information.