phplrt 4.0

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.