phplrt 4.0

Best Practices

What follows is a list of decisions that make a parser pleasant to work with and cheap to run: where your own code goes, how the grammar reaches it, and how much the runtime is willing to say about what goes in and what comes out.

Despite the title, this is closer to tips and tricks than to rules. None of it is required to write a working parser, and none of it has to be decided up front - each one is something to reach for on the day the grammar alone stops being enough.

The examples build on the JSON example - a parser returning a JsonValue for every rule.

Keep Your Code Out Of The Generated File

The generated file says what it is right at the top:

1/**
2 * @internal This code has been generated by the phplrt compiler and should not
3 *           be edited by hand
4 */

It is regenerated whenever the grammar changes, and anything you write into it is lost the next time the build runs.

 1// bin/build-parser.php
 2use Phplrt\Compiler\Compiler;
 3use Phplrt\Source\File;
 4
 5new Compiler()
 6    ->load(new File(__DIR__ . '/../resources/grammar.pp3'))
 7    ->generate()
 8        ->withNamespaceName('App\Json')
 9        ->withClassName('CompiledJsonParser')
10        ->save(__DIR__ . '/../src/Json/CompiledJsonParser.php');

What comes out is a parser that works as it is, and for a good many grammars that is the end of the story:

1new App\Json\CompiledJsonParser()
2    ->parse(new Source('{"a": 1}'));

When there is something of yours to add - a setting, a helper the grammar calls, a friendlier signature - the generated code is deliberately not final, so that something goes in a class of your own next to the generated one:

1namespace App\Json;
2
3final readonly class JsonParser extends CompiledJsonParser
4{
5    // Everything the grammar cannot say on its own
6}

Two things the compiler decides for you, and one convention worth adopting:

  • The generated class is readonly, because Phplrt\Parser\Parser is. A subclass has to be readonly too.
  • Its constructor takes no arguments and builds the whole grammar, so your own constructor has to call parent::__construct().
  • Name the two classes differently on purpose. CompiledJsonParser is generated and committed; JsonParser is written by hand. Anyone reading a stack trace can tell which is which.

Let The Grammar Reach Your Settings

A reducer is compiled into a method of the parser, so $this inside a code block is the parser itself. Anything the object knows, the grammar can ask for:

1Number -> { return $this->number($children->value); }
2  : <T_NUMBER>
3  ;
 1namespace App\Json;
 2
 3final readonly class JsonParser extends CompiledJsonParser
 4{
 5    public function __construct(
 6        protected bool $bigIntAsString = false,
 7    ) {
 8        parent::__construct();
 9    }
10
11    protected function number(string $raw): JsonValue
12    {
13        $value = 0 + $raw;
14
15        // An integer too large for the platform arrives as a float, so the
16        // digits it was written with survive only as text
17        $isBigInt = \is_float($value) 
18            && !\str_contains($raw, '.');
19
20        if ($this->bigIntAsString && $isBigInt) {
21            return new JsonValue($raw);
22        }
23
24        return new JsonValue($value);
25    }
26}
1new JsonParser()
2    ->parse(new Source('9223372036854775808'))
3    ->value;
4// float(9.223372036854776E+18)
5
6new JsonParser(bigIntAsString: true)
7    ->parse(new Source('9223372036854775808'))
8    ->value;
9// string(19) "9223372036854775808"

The grammar still describes nothing but the language: T_NUMBER is a number in JSON whatever your settings say. What that number becomes is a decision, and decisions live in PHP.

Everything the grammar touches must be protected or public. The reducer that calls it is a method of the generated class, and a private member of the subclass is not visible from there:

Call to private method App\Json\JsonParser::number() from scope App\Json\CompiledJsonParser

The same goes for constants, class fields and properties. A private helper is fine as long as the grammar does not name it.

$this reducers only work in a generated parser. A grammar loaded at runtime with getParser() compiles its reducers into plain closures with nothing to bind to, and the rule fails with Using $this when not in object context the first time it is reduced. Keep $this out of grammars you intend to read on the fly - see Code Generation.

Keep State In An Object, Not In A Property

A parser is readonly, so a property cannot be reassigned after construction. The object a property holds is another matter, and that is where anything accumulating across a parse belongs - an interning pool, a symbol table, a counter:

1String -> { return $this->intern($children->value); }
2  : <T_STRING>
3  ;
 1namespace App\Json;
 2
 3final class StringPool
 4{
 5    /**
 6     * @var array<non-empty-string, JsonValue>
 7     */
 8    private array $values = [];
 9
10    public function get(string $raw): JsonValue
11    {
12        return $this->values[$raw] ??= new JsonValue(\json_decode($raw));
13    }
14}
 1final readonly class JsonParser extends CompiledJsonParser
 2{
 3    private StringPool $strings;
 4
 5    public function __construct()
 6    {
 7        $this->strings = new StringPool();
 8
 9        parent::__construct();
10    }
11
12    protected function intern(string $raw): JsonValue
13    {
14        return $this->strings->get($raw);
15    }
16}

A document repeating the same key a thousand times now unescapes it once. The pool lives as long as the parser does, which is the point - and the reason to keep an eye on it: a long-running process parsing untrusted input wants a bounded cache, or a parser per request.

Narrow The Result Type

parse() returns mixed on purpose. A grammar may build an AST, a scalar, an array or nothing at all, and the runtime has no business guessing which.

Your parser does know, and there are two ways to say so - you may use both:

 1namespace App\Json;
 2
 3use Phplrt\Contracts\Source\ReadableInterface;
 4
 5/**
 6 * @template-extends CompiledJsonParser<JsonValue>
 7 */
 8final readonly class JsonParser extends CompiledJsonParser
 9{
10    #[\Override]
11    public function parse(ReadableInterface $source): JsonValue
12    {
13        return parent::parse($source);
14    }
15}

The generated class carries a TResult template parameter, so @template-extends tells a static analyser what a parse produces - including through analyze(), whose result is a SuccessfulResult<TResult> or a FailureResult:

1$result = $parser->analyze($source);
2
3if ($result instanceof SuccessfulResult) {
4    $result->value; // JsonValue, not mixed
5}

The explicit return type does the same for everything that does not read docblocks: an IDE, an instanceof check, and PHP itself. Narrowing mixed down to a class is a legal override, and it fails loudly the day a reducer starts returning something else.

Widen The Input If You Want To

The lexer and the parser accept a ReadableInterface and nothing else. That is not an oversight - a bare string is ambiguous. Is "config.json" a JSON string source or the name of a file?

In 3.x the parser had a factory inside it, private and implicit. In 4.x the contract is stated in the open, which means widening it is your call:

 1namespace App\Json;
 2
 3use Phplrt\Contracts\Source\ReadableInterface;
 4use Phplrt\Source\Source;
 5
 6final readonly class JsonParser extends CompiledJsonParser
 7{
 8    #[\Override]
 9    public function parse(ReadableInterface|string $source): JsonValue
10    {
11        if (is_string($source)) {
12            $source = new Source($source);
13        }
14
15        return parent::parse($source);
16    }
17}
1$parser->parse('{"a": 1}');                    // a string is the document
2$parser->parse(new File(__DIR__ . '/a.json')); // a file is a file

For the "figure out what this is" behaviour of 3.x, hand the job to SourceFactory, which turns a string, an SplFileInfo or a stream resource into a source:

 1namespace App\Json;
 2
 3use Phplrt\Contracts\Source\ReadableInterface;
 4use Phplrt\Source\SourceFactory;
 5
 6final readonly class JsonParser extends CompiledJsonParser
 7{
 8    private SourceFactory $sources;
 9
10    public function __construct()
11    {
12        $this->sources = SourceFactory::createDefault();
13
14        parent::__construct();
15    }
16
17    #[\Override]
18    public function parse(mixed $source): JsonValue
19    {
20        // The factory builds a source out of something that is not one yet
21        // and passes through what already is one
22        $readable = $this->sources->create($source);
23    
24        return parent::parse($readable);
25    }
26}

As a result, you can pass any set of data you may need to your parser, like in phplrt 3.x:

1$parser->parse('{"a": 1}');
2$parser->parse(new \SplFileInfo(__DIR__ . '/a.json'));
3$parser->parse(\fopen('php://stdin', 'rb'));

Widening a parameter is a legal override, and the contract survives it: every ReadableInterface a caller could pass before is still accepted. If your code reaches for analyze() as well, give it the same treatment - it takes the same argument.