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, becausePhplrt\Parser\Parseris. A subclass has to bereadonlytoo. -
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.
CompiledJsonParseris generated and committed;JsonParseris 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.
$thisreducers only work in a generated parser. A grammar loaded at runtime withgetParser()compiles its reducers into plain closures with nothing to bind to, and the rule fails withUsing $this when not in object contextthe first time it is reduced. Keep$thisout 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.