PHP in a Grammar
A grammar on its own only answers "is this valid?". To get a value out of a parse - a number, an AST node, a configuration array - you attach PHP to a rule. That piece of PHP is called a reducer, and it runs when the rule matches.
A Block of Code
Put it between -> and the rule body:
1Number -> { return (int) $children->value; } 2 : <T_DIGIT> 3 ;
Whatever it returns becomes the value of the rule. The code is ordinary PHP - loops, conditionals, whatever you need:
1Expression -> { 2 if (!\is_array($children)) { 3 return $children; 4 } 5 6 $result = \array_shift($children); 7 8 while ($children !== []) { 9 $operator = \array_shift($children); 10 $right = \array_shift($children); 11 12 $result = $operator->value === '+' 13 ? $result + $right 14 : $result - $right; 15 } 16 17 return $result; 18} 19 : Term() ((<T_PLUS> | <T_MINUS>) Term())* 20 ;
Braces inside strings are safe - the block is read by a real PHP lexer, so
"{" is a string, not the end of the block.
Building A Node
A block of code is the only form a reducer takes, so a rule that maps onto a node class builds it there:
1Number -> { return new \App\Ast\NumberNode($offset, (int) $children->value); } 2 : <T_DIGIT> 3 ;
1namespace App\Ast; 2 3final class NumberNode 4{ 5 public function __construct( 6 public readonly int $offset, 7 public readonly int $value, 8 ) {} 9}
Passing the node exactly what it needs is a little more typing than handing the whole context over, and it keeps the node a plain value object that knows nothing about the parser.
The Variables
Inside a code block, these are available:
| Variable | What it is |
|---|---|
$children |
What the rule matched. This is the important one. |
$ctx |
The full Context object |
$token |
The last token the rule read, or null |
$offset |
Where that token starts, in bytes |
$source |
The source being parsed |
$content |
Its contents, already read |
$rule |
The id of the rule being reduced |
All except $children and $ctx are shorthands the compiler expands for
you - $offset becomes $ctx->token->offset, and so on. They are only
declared if you use them, so there is no cost to the ones you do not.
1Number -> { return new \NumberNode($offset, (float) $children->value); } 2 : <T_NUMBER> 3 ;
Keeping an offset on every node is a habit worth forming. It is what lets a later stage - a type checker, an evaluator, a linter - point at the right place in the source when it finds a problem.
What $children Holds
A sequence (a concatenation or a repetition) gives you an array:
1Pair : <T_DIGIT> <T_DIGIT> ; // $children = [Token, Token] 2List : <T_DIGIT>+ ; // $children = [Token, Token, ...]
Anything else gives you a single value:
1Number : <T_DIGIT> ; // $children = Token 2Choice : Number() | Name() ; // $children = whatever matched
A rule that can match one thing or several will hand you one thing or several, which is why reducers so often start with:
1Rule -> { 2 if (!\is_array($children)) { 3 return $children; 4 } 5 6 // ... 7}
The Results and Reducers page goes into how nested values are combined.
Returning Nothing
Return null and the children pass through untouched, as if the reducer were
not there:
1Debug -> { 2 \error_log('reached rule ' . $rule . ' at ' . $offset); 3 4 return null; // leave the result alone 5} 6 : <T_NAME> 7 ;
An empty block (-> {}) is the same as writing no reducer at all.
In Generated Code
When you generate a parser, reducers become real methods, named after the rule they belong to:
1private static function reduceNumber(\Phplrt\Parser\Context $ctx, mixed $children): mixed 2{ 3 return (float) $children->value; 4}
Two practical consequences.
Your code appears verbatim in the generated file. It is debuggable and steppable, and a syntax error in a reducer is a syntax error in that file - so run the generator as part of your build, not at deploy time.
A grammar file has no use statements, so how a short class name resolves
depends on where the reducer ends up - the global namespace when the grammar
is read on the fly, the generated file's namespace when it is generated. The
safe answer is to write class names fully qualified:
1// ✔ works either way 2Number -> { return new \App\Ast\NumberNode($offset, $children->value); }
If the fully qualified names make a big grammar unreadable, you can declare the imports on the generated file instead:
1new Compiler() 2 ->load(new File(__DIR__ . '/grammar.pp3')) 3 ->generate() 4 ->withNamespaceName('App\Parser') 5 ->withClassImport('App\Ast\NumberNode') 6 ->save(__DIR__ . '/Parser.php');
Number -> { return new NumberNode($offset, $children->value); }
The trade-off: that grammar now only works when generated. Pick one approach per project rather than mixing them.