Write the PHPDoc language down as a grammar, and fuzz from it - #309
Open
ondrejmirtes wants to merge 2 commits into
Open
Write the PHPDoc language down as a grammar, and fuzz from it#309ondrejmirtes wants to merge 2 commits into
ondrejmirtes wants to merge 2 commits into
Conversation
The lookahead telling the `&` of `Foo &$bar` from the `&` of `Foo&Bar` was written to look for a variadic among the things that may follow a reference, and looked for a single dot instead of the three a variadic is written with. A float written without its leading zero starts with a dot as well, so `Foo & .5` was read as a reference, and the type ended at the `&`. The three dots are what the comment above the pattern says it looks for, so that is what it now looks for. `Foo &...$bar` reads as it did, and `Foo & .5` now reads the way `Foo & 5` and `Foo | .5` always have. This is also what kept the printer from printing an intersection that can be read again: it writes `&` with no space around it, so `Foo & .5[]` came back out as `Foo&.5[]` and could not be read a second time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GavWEzia8ZVUYEe6JEde9i
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | ||
|
|
||
| - name: "Install PHP" | ||
| uses: "shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240" # v2 |
Comment on lines
+160
to
+161
| - name: "Checkout" | ||
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
ondrejmirtes
force-pushed
the
phplrt-grammars
branch
from
August 30, 2026 14:32
399b6ec to
c332343
Compare
doc/grammars holds the language this library reads, written in the PP3 format the phplrt compiler reads: the tokens, the type language, the constant expressions and the PHPDoc itself, down to its Doctrine annotations. The rules are named after the methods of PhpDocParser, TypeParser and ConstExprParser they stand for and are written in the order those methods try things in, so the two can be read side by side. What the grammars describe is a PHPDoc that is written correctly. The parser reads a broken one as well, by turning whatever it cannot read into an InvalidTagValueNode carrying the very error it has raised, and a grammar has no way of writing that error down. So a place the parser raises an error at is written as something the grammar cannot recognize, and everything the grammars describe is something the parser has to read in full. That is what FuzzyTest now asks of it. tools/phplrt/fuzz.php compiles a grammar, walks its rules the other way round and writes down what comes out; the test reads every one of them and asks for a type to come back as the very same type once it has been printed. It replaces the abnfgen fuzzer, which needed a C program built from a tarball and only covered the type language and the constant expressions. The toolchain asks for PHP 8.4, which this library still runs without, so it is a development dependency of its own and FuzzyTest skips itself wherever it is missing. Only the new Grammars job runs it for real. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GavWEzia8ZVUYEe6JEde9i
ondrejmirtes
force-pushed
the
phplrt-grammars
branch
from
August 30, 2026 14:54
c332343 to
8fef332
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
doc/grammarsnow holds the language this library reads, written in the PP3 format the phplrt compiler reads: the tokens, the type language, the constant expressions and the PHPDoc itself, down to its Doctrine annotations. The rules are named after the methods ofPhpDocParser,TypeParserandConstExprParserthey stand for and are written in the order those methods try things in, so the two can be read side by side.It replaces
type.abnf,phpdoc-param.pegandphpdoc-method.peg, none of which described the whole language and none of which anything read.lexemes.pp3common.pp3types.pp3TypeParserconst-expr.pp3ConstExprParserphpdoc-block.pp3type.pp3,constant-expr.pp3,phpdoc.pp3What the grammars describe
A PHPDoc that is written correctly. The parser reads a broken one as well, by turning whatever it cannot read into an
InvalidTagValueNodecarrying the very error it has raised, and a grammar has no way of writing that error down. So a place the parser raises an error at is written as something the grammar cannot recognize, and everything the grammars describe is something the parser has to read in full.Most of those are written as a
!predicate forbidding whatever the error would have been raised on. A name followed by<has to go on into a generic type or a callable, becauseFoo<is an error rather than the typeFoofollowed by something else:The same predicate is what keeps a rule from giving back what it has read.
@template T ofis an error rather than a template namedTwith the descriptionof, so the bound is written as "either a bound or noofat all".Three corners are left out on purpose, each written up where the rule that skirts it is written: a description that ends at a tag written in the middle of a line, the same on a line after the first (where the parser reads that line twice), and a tag whose value a rule reads written with a parenthesis after it.
The tokens are not read by phplrt
A grammar here is not read by the lexer it declares — it is read by the very tokens
PHPStan\PhpDocParser\Lexer\Lexerproduces, handed over bytools/phplrt/Fuzzer/TokenStream.php. The%tokendeclarations therefore name the tokens and document the language without being what reads it, andTokenStreamis what tells apart the things the lexer does not read as a token of its own: a word the parser compares by value (is,array,covariant, …), a tag whose value a rule of its own reads, a bracket or asterisk whose neighbouring whitespace decides what it means, a tag a space is written before, and a<opening what the parser recognizes as an HTML tag. Telling them apart there is what lets the grammars be written without semantic predicates, which PP3 has none of.FuzzyTest
A grammar says what a PHPDoc may be written as, so it can be walked the other way round and asked for PHPDocs instead of being asked about one.
tools/phplrt/fuzz.phpcompiles a grammar, walks its rules and writes down what comes out;FuzzyTestreads every one of them and asks for a type to come back as the very same type once it has been printed.Two things are thrown away before an input is written down, and neither of them is the parser being wrong: one whose text does not read back as the very tokens it was written of, and one the grammar itself does not recognize (a walk over the rules says what the language is written of, while a parser reads the alternatives of a rule in the order they are written and stops at the first one that fits).
This replaces the
abnfgenfuzzer, which needed a C program built from a tarball attests/abnfgen-0.20.tar.gzand only covered the type language and the constant expressions.build-abnfgen.shand the tarball are gone.A whole PHPDoc is only asked to be read in full, not to survive being printed and read again: a description written across several lines is printed as it was read rather than as the lines of a PHPDoc, which 8 of the 318 PHPDocs in this project's own test corpus already run into.
Running it
The compiler asks for PHP 8.4, which this library still runs without, so it is a development dependency of its own under
tools/phplrtandFuzzyTestskips itself wherever it is missing:There is no corpus to keep and none to refresh when a rule changes:
FuzzyTestrunsfuzz.phpitself, once per grammar, every time it runs, so what is generated changes the moment the grammar is read again. The inputs of the last run are left intemp/fuzzyto be looked at.Only the new Grammars job of
build.ymlruns it for real, on PHP 8.4 and 8.5.Rule coverage
Every
.pp3is read —phpdoc.pp3includes all the others — and the three roots exist soTypeParserandConstExprParserare driven directly and get the print round trip. Measuring which named rules the walk actually reaches, over 8000 accepted inputs per root:type.pp3constant-expr.pp3phpdoc.pp3Ten of the eleven are referenced only from inside a
!/&predicate, which reads nothing, so the walk correctly never descends into them; the seven undertype.pp3are the array-literal rules that!ArrayLiteral()forbids a type from starting with, and they are fully covered fromconstant-expr.pp3. The one left over is covered fromtype.pp3.What it found
9b0ba2c— the lookahead inLexerseparating the&ofFoo &$barfrom the&ofFoo&Barlists[.,=)], and the comment above it says the dot stands forTOKEN_VARIADIC. A single dot also starts a float written without its leading zero, soFoo & .5lexed as a reference and the type ended at the&— whileFoo & 5andFoo | .5both read fine. It is now\.\.\., which is what the comment already claims;Foo &...$bar,callable(Foo&),callable(Foo&,andcallable(Foo&=)lex as before.It was also what kept the printer from printing an intersection that can be read again:
&is printed with no space around it, soFoo & .5[]came back out asFoo&.5[].Happy to split that commit off into a PR of its own if you would rather have it separately.
And one bug in the grammar itself, found by measuring the coverage above.
DoctrineTagwas written asso the grammar said
@\Foo(with no closing parenthesis is a tag with no arguments whose description happens to begin with a(. The parser commits to reading arguments the moment a parenthesis follows the tag, and errors. The no-arguments branch now forbids it, the wayGenericTagalready did.It had been invisible because the fuzzer never wrote a Doctrine tag at all: the literal it used,
@Foo_Bar, does not lex as one —TOKEN_PHPDOC_TAGis tried first and matches@Foo, leaving_Baras a name, so every input carrying one was thrown away by the re-lexing filter. A tag reads as a Doctrine one only where a\or_follows the@straight away. With@\Fooinstead, Doctrine annotations appear in about a tenth of the generated PHPDocs and the whole Doctrine section of the grammar is exercised for the first time.The three fixes of #308 came out of the same work.
Verification
4741 tests, phpcs, PHP-Parser lint and PHPStan all clean. Beyond the suite, a sweep of 60 seeds — 180,000 generated inputs across the three grammars — reads every one in full, with the type and constant-expression ones surviving the print round trip.
🤖 Generated with Claude Code
https://claude.ai/code/session_01GavWEzia8ZVUYEe6JEde9i