Results and Reducers
A parser does two things: it decides whether the input is valid, and it builds something out of it. The first part is the grammar. The second part is reducers.
What You Get Without Reducers
A grammar with no reducers still returns something - the tokens it kept, nested the way the rules were:
Sum : <T_DIGIT> (::T_PLUS:: <T_DIGIT>)* ;
1$parser->parse(new Source('2 + 3')); 2// [Token("2"), Token("3")]
That is occasionally enough. Usually you want numbers, or AST nodes, or a configuration array - and for that you attach a reducer.
Attaching A Reducer
A reducer is a block of PHP that runs when its rule matches:
1Number -> { return (int) $children->value; } 2 : <T_DIGIT> 3 ;
Whatever it returns becomes the value of that rule, and gets handed to the rule above.
The same thing through the builder:
1use Phplrt\Parser\Context; 2 3$number->setReducer(static fn(Context $ctx, mixed $children): int 4 => (int) $children->value);
What $children Contains
This is the part worth reading twice.
A rule that recognizes a sequence - a concatenation or a repetition - gets an array:
1Pair : <T_DIGIT> <T_DIGIT> ; // $children = [Token, Token] 2List : <T_DIGIT>+ ; // $children = [Token, Token, ...]
Any other rule gets the single value it recognized:
1Number : <T_DIGIT> ; // $children = Token 2Choice : Number() | Name() ; // $children = whatever matched
And the arrays are flattened into the parent. If a nested rule returns a list, its items are spliced into the list of the rule above rather than nested inside it:
1Root : <T_A> Pair() <T_B> ; 2Pair : <T_DIGIT> <T_DIGIT> ; 3 4// Root's $children = [Token(a), Token(1), Token(2), Token(b)] 5// not [Token(a), [Token(1), Token(2)], Token(b)]
This is deliberate - it keeps the result flat and predictable - but it means that a rule which should produce a group must say so by returning a value of its own. A reducer returning an object or a scalar is never flattened:
1Pair -> { return $children; /* an array */ } // still flattened 2Pair -> { return new PairNode($children); } // stays one value ✔
Because a rule can match one thing or a list depending on the input, reducers often start with a check:
1Expression -> { 2 // Just one operand: nothing to fold 3 if (!\is_array($children)) { 4 return $children; 5 } 6 7 // ... 8} 9 : Number() ((<T_PLUS> | <T_MINUS>) Number())* 10 ;
Building An AST
Here is the whole pattern. Define node classes:
1abstract class Node 2{ 3 public function __construct( 4 public readonly int $offset, 5 ) {} 6} 7 8final class NumberNode extends Node 9{ 10 public function __construct(int $offset, public readonly float $value) 11 { 12 parent::__construct($offset); 13 } 14} 15 16final class BinaryNode extends Node 17{ 18 public function __construct( 19 int $offset, 20 public readonly string $operator, 21 public readonly Node $left, 22 public readonly Node $right, 23 ) { 24 parent::__construct($offset); 25 } 26}
Then build them in the grammar:
1%skip T_WHITESPACE \s++ 2%token T_NUMBER \d++(?:\.\d++)? 3%token T_PLUS \+ 4%token T_MINUS \- 5 6%pragma root Expression 7 8Expression -> { 9 if (!\is_array($children)) { 10 return $children; 11 } 12 13 $node = \array_shift($children); 14 15 // Fold left: 1 + 2 - 3 => ((1 + 2) - 3) 16 while ($children !== []) { 17 $operator = \array_shift($children); 18 $right = \array_shift($children); 19 20 $node = new \BinaryNode($node->offset, $operator->value, $node, $right); 21 } 22 23 return $node; 24} 25 : Number() ((<T_PLUS> | <T_MINUS>) Number())* 26 ; 27 28Number -> { return new \NumberNode($offset, (float) $children->value); } 29 : <T_NUMBER> 30 ;
Parsing 1 + 2 - 3 gives you a tree:
1BinaryNode(-) 2├── BinaryNode(+) 3│ ├── NumberNode(1) 4│ └── NumberNode(2) 5└── NumberNode(3)
Note $offset in the Number reducer - that is one of the
variables the compiler provides. Keeping an offset on
every node is what lets you point at the right place in the source when
something goes wrong later, during type-checking or evaluation.
The Context
Every reducer receives a Context as its first argument, describing where the
analysis is:
1static function (Context $ctx, mixed $children): mixed { 2 $ctx->rule; // int - the id of the rule being reduced 3 $ctx->token; // the last token this rule read, or null 4 $ctx->source; // the source being parsed 5 $ctx->content; // its content, already read 6 7 return null; 8}
In a .pp3 reducer you rarely touch $ctx directly, because the common
fields have shorter names - $token, $offset, $source. See
PHP in a Grammar.
Returning Nothing
A reducer that returns null leaves the value alone: the children are passed
up as if there were no reducer at all. That makes null useful for reducers
that only observe:
1Debug -> { 2 \error_log('matched at ' . $offset); 3 4 return null; // do not touch the result 5} 6 : <T_NAME> 7 ;
Reducers Run After Parsing
One thing to be aware of: reducers do not run while the input is being read. The parser first recognizes the whole source, then walks what it recognized and reduces it bottom-up.
This means:
- a reducer never sees a rule that was tried and rejected - no wasted work, no side effects from a branch that did not win;
-
analyze()inMode::Fastnever runs reducers at all; - a reducer cannot influence parsing. It cannot look ahead, change what is matched next, or fail the parse to force a different alternative. If a decision depends on the input, express it in the grammar - that is what predicates are for.