Skip to content

Commit 399b6ec

Browse files
ondrejmirtesclaude
andcommitted
Write the PHPDoc language down as a grammar, and fuzz from it
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
1 parent 9b0ba2c commit 399b6ec

26 files changed

Lines changed: 3970 additions & 410 deletions

.gitattributes

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
*.abnf text eol=crlf
21
*.php text eol=lf
2+
*.pp3 text eol=lf
33

44
.github export-ignore
55
apigen export-ignore
66
phpcs.xml export-ignore
77
doc export-ignore
88
tests export-ignore
9+
tools export-ignore
910
tmp export-ignore
1011
.editorconfig export-ignore
1112
.gitattributes export-ignore
1213
.gitignore export-ignore
13-
build-abnfgen.sh export-ignore
1414
CLAUDE.md export-ignore
1515
CODE_OF_CONDUCT.md export-ignore
1616
Makefile export-ignore

.github/workflows/build.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,44 @@ jobs:
140140
- name: "Tests"
141141
run: "make tests"
142142

143+
grammars:
144+
name: "Grammars"
145+
runs-on: "ubuntu-latest"
146+
147+
strategy:
148+
fail-fast: false
149+
matrix:
150+
php-version:
151+
- "8.4"
152+
- "8.5"
153+
154+
steps:
155+
- name: Harden the runner (Audit all outbound calls)
156+
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
157+
with:
158+
egress-policy: audit
159+
160+
- name: "Checkout"
161+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
162+
163+
- name: "Install PHP"
164+
uses: "shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240" # v2
165+
with:
166+
coverage: "none"
167+
php-version: "${{ matrix.php-version }}"
168+
tools: composer:v2
169+
170+
- name: "Install dependencies"
171+
run: "composer update --no-interaction --no-progress"
172+
173+
# The compiler reading doc/grammars asks for PHP 8.4, so the corpus is
174+
# only written here. Everywhere else FuzzyTest skips itself.
175+
- name: "Install the grammar toolchain"
176+
run: "make grammars-install"
177+
178+
- name: "Tests"
179+
run: "make tests"
180+
143181
static-analysis:
144182
name: "PHPStan"
145183
runs-on: "ubuntu-latest"

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/docs
22
/temp
3-
/tools
3+
/tools/phplrt/vendor
44
/tests/tmp
55
/build-cs
66
/vendor

Makefile

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,21 @@ phpstan:
3434
.PHONY: phpstan-generate-baseline
3535
phpstan-generate-baseline:
3636
php vendor/bin/phpstan --generate-baseline
37+
38+
# ---------------------------------------------------------------------------
39+
# The grammars
40+
# ---------------------------------------------------------------------------
41+
42+
# The tool writing a corpus out of doc/grammars is a development dependency of
43+
# its own, because the grammar compiler asks for a PHP this library still runs
44+
# without.
45+
.PHONY: grammars-install
46+
grammars-install:
47+
composer install --no-interaction --working-dir tools/phplrt
48+
49+
# Writes down what a grammar says is well-formed, which is what FuzzyTest reads.
50+
.PHONY: fuzz
51+
fuzz: grammars-install
52+
php tools/phplrt/fuzz.php doc/grammars/type.pp3 temp/fuzzy/Type 1000
53+
php tools/phplrt/fuzz.php doc/grammars/constant-expr.pp3 temp/fuzzy/ConstantExpr 1000
54+
php tools/phplrt/fuzz.php doc/grammars/phpdoc.pp3 temp/fuzzy/PhpDoc 1000

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,19 @@ $newPhpDoc = $printer->printFormatPreserving($newPhpDocNode, $phpDocNode, $token
166166
echo $newPhpDoc; // '/** @param Ipsum $a */'
167167
```
168168

169+
## The grammars
170+
171+
The language this library reads is written down as a grammar in
172+
[`doc/grammars`](doc/grammars), in the format the [phplrt](https://phplrt.org)
173+
compiler reads. A grammar says what a PHPDoc may be written as, so it can be
174+
walked the other way round and asked for PHPDocs instead of being asked about
175+
one: that is where `FuzzyTest` gets its corpus, and it covers a great deal more
176+
of the language than a hand-written one does.
177+
178+
Nothing in `src/` reads those files, and the library needs neither the toolchain
179+
writing the corpus nor the PHP 8.4 it asks for. See
180+
[`doc/grammars/README.md`](doc/grammars/README.md).
181+
169182
## Code of Conduct
170183

171184
This project adheres to a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project and its community, you are expected to uphold this code.
@@ -181,3 +194,9 @@ Afterwards you can either run the whole build including linting and coding stand
181194
or run only tests using
182195

183196
make tests
197+
198+
The grammars have a toolchain of their own, because the compiler reading them
199+
asks for PHP 8.4. Without it the fuzzy tests skip themselves:
200+
201+
make grammars-install # install it
202+
make fuzz # write a corpus out of doc/grammars/*.pp3

build-abnfgen.sh

Lines changed: 0 additions & 22 deletions
This file was deleted.

doc/grammars/README.md

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# The grammars of the PHPDoc language
2+
3+
The files in this directory describe the language `phpstan/phpdoc-parser` reads,
4+
in the [PP3 format](https://phplrt.org/docs/basics/grammar) the
5+
[phplrt](https://phplrt.org) compiler reads. They are the specification of the
6+
language, and they are what `tests/PHPStan/Parser/FuzzyTest.php` writes its
7+
corpus from.
8+
9+
| File | What it holds |
10+
|---------------------|-----------------------------------------------------------------|
11+
| `lexemes.pp3` | Every token the language is read into |
12+
| `common.pp3` | What the grammars share: names, brackets, line breaks |
13+
| `types.pp3` | The type language of `TypeParser` |
14+
| `const-expr.pp3` | The constant expressions of `ConstExprParser` |
15+
| `phpdoc-block.pp3` | The PHPDoc itself: its tags, its text, its Doctrine annotations |
16+
| `type.pp3` | The entry point starting at `Type` |
17+
| `constant-expr.pp3` | The entry point starting at `ConstantExpr` |
18+
| `phpdoc.pp3` | The entry point starting at `PhpDoc` |
19+
20+
## What they are for
21+
22+
A grammar says what a PHPDoc may be written as, so it can be walked the other
23+
way round and asked for PHPDocs instead of being asked about one:
24+
25+
make grammars-install # the toolchain, which asks for PHP 8.4
26+
make fuzz # write a corpus into temp/fuzzy
27+
php vendor/bin/phpunit --filter FuzzyTest
28+
29+
`tools/phplrt/fuzz.php` compiles a grammar, walks its rules at random and writes
30+
down what comes out. `FuzzyTest` then asks the parser to read every one of them
31+
in full, and to read a type back as the very same type once it has been printed.
32+
That is a great deal more of the language than a hand-written corpus covers, and
33+
it is what replaced the `abnfgen`-driven fuzzer this project used before.
34+
35+
Nothing in `src/` reads these files, and the library needs neither the toolchain
36+
nor PHP 8.4: where either is missing, `FuzzyTest` skips itself. Only the
37+
`Grammars` job of `.github/workflows/build.yml` runs it for real.
38+
39+
## How they are written
40+
41+
The rules are named after the methods of `PhpDocParser`, `TypeParser` and
42+
`ConstExprParser` they stand for and are written in the order those methods try
43+
things in, so that a grammar and the parser it describes can be read side by
44+
side.
45+
46+
### What the grammars describe
47+
48+
**A PHPDoc that is written correctly.** The parser reads a broken one as well,
49+
by turning whatever it cannot read into an `InvalidTagValueNode` carrying the
50+
very error it has raised, and a grammar has no way of writing that error down.
51+
So what a broken PHPDoc means is left to the parser, and everything the grammars
52+
describe is something the parser has to read in full.
53+
54+
Two things follow from wanting that to hold for **every** input rather than for
55+
most of them:
56+
57+
- **A place the parser raises an error at is written as something the grammar
58+
cannot recognize.** Most of them are written as a `!` predicate forbidding
59+
whatever the error would have been raised on. For instance a name followed by
60+
a `<` has to go on into a generic type or into a callable, because `Foo<` is
61+
an error rather than the type `Foo` followed by something else:
62+
63+
```
64+
IdentifierAtomic
65+
: ...
66+
| !ShapeBrace() Identifier() !<T_DOUBLE_COLON> ( IdentifierSuffix() | !<T_OPEN_ANGLE_BRACKET> )
67+
;
68+
```
69+
70+
The same predicate is what keeps a rule from **giving back** what it has read.
71+
`@template T of` is an error rather than a template named `T` with the
72+
description `of`, so the bound is written as "either a bound or no `of` at
73+
all":
74+
75+
```
76+
TemplateUpperBound
77+
: <T_KEYWORD_OF> Type()
78+
| <T_KEYWORD_AS> Type()
79+
| !<T_KEYWORD_OF> !<T_KEYWORD_AS>
80+
;
81+
```
82+
83+
- **A rule reads exactly the tokens its method reads**, down to the line breaks
84+
around it.
85+
86+
### The tokens are not read by phplrt
87+
88+
A grammar of this directory is not read by the lexer it declares: it is read by
89+
the very tokens `PHPStan\PhpDocParser\Lexer\Lexer` produces, handed over by
90+
`tools/phplrt/Fuzzer/TokenStream.php`.
91+
92+
The `%token` declarations therefore name the tokens and document the language
93+
without being what reads it. Some of them describe something the lexer never
94+
reads as a token of its own, and `TokenStream` is what tells those apart in the
95+
stream:
96+
97+
- a word the parser compares by value (`is`, `array`, `covariant`, `static`, …)
98+
— every one of them is still an ordinary name as well, which is why they are
99+
all listed among the alternatives of `Identifier`;
100+
- a tag whose value a rule of its own reads (`@param`, `@return`, …), told apart
101+
from the tags nothing reads the value of;
102+
- a bracket or an asterisk whose neighbouring whitespace decides what it means,
103+
which is what tells `array{a: int}` from the type `array` followed by a brace,
104+
and `Foo[0]` from `Foo [0]`;
105+
- a tag a space is written before, which is what tells the `@since` of
106+
`@author Foo @since 1.0` from the `@baz` of `@author Foo <foo@baz.com>`;
107+
- a `<` opening what the parser recognizes as an HTML tag, so that
108+
`@return Foo<br>see below</br>` keeps meaning the type `Foo` followed by a
109+
description.
110+
111+
Telling them apart there is what lets the grammars be written without semantic
112+
predicates, which the PP3 format has none of.
113+
114+
### What is left out
115+
116+
Three corners of the language are left out on purpose, because a grammar cannot
117+
say what the parser does there. Each of them is written up where the rule that
118+
skirts it is written:
119+
120+
- a description that ends at a tag written in the middle of a line, which the
121+
parser decides by reading the tag and looking at what it turns out to be;
122+
- the same, on a line after the first, where the parser reads that line twice:
123+
once as part of the description and again as whatever comes next;
124+
- a tag whose value a rule reads, written with a parenthesis after it, where
125+
whether the description ends there depends on whether that value can be read
126+
at all.

doc/grammars/common.pp3

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* -----------------------------------------------------------------------------
3+
* What Both Grammars Are Written Of
4+
* -----------------------------------------------------------------------------
5+
*/
6+
7+
/**
8+
* A name, which every keyword is as well.
9+
*
10+
* The lexer reads all of them as T_IDENTIFIER and they are only told apart in
11+
* the stream so that the rules asking for a particular word can be written
12+
* without a semantic predicate. A word is therefore still a perfectly ordinary
13+
* name wherever a name is what is being read.
14+
*/
15+
Identifier
16+
: <T_IDENTIFIER>
17+
| <T_KEYWORD_IS>
18+
| <T_KEYWORD_NOT>
19+
| <T_KEYWORD_STATIC>
20+
| <T_KEYWORD_FROM>
21+
| <T_KEYWORD_OF>
22+
| <T_KEYWORD_AS>
23+
| <T_KEYWORD_SUPER>
24+
| <T_KEYWORD_COVARIANT>
25+
| <T_KEYWORD_CONTRAVARIANT>
26+
| <T_KEYWORD_ARRAY>
27+
| <T_KEYWORD_ARRAY_ANY_CASE>
28+
| <T_KEYWORD_LIST>
29+
| <T_KEYWORD_NON_EMPTY_ARRAY>
30+
| <T_KEYWORD_NON_EMPTY_LIST>
31+
| <T_KEYWORD_OBJECT>
32+
| <T_KEYWORD_TRUE>
33+
| <T_KEYWORD_FALSE>
34+
| <T_KEYWORD_NULL>
35+
;
36+
37+
/**
38+
* The word "array" written in any case at all.
39+
*/
40+
ArrayKeyword
41+
: <T_KEYWORD_ARRAY>
42+
| <T_KEYWORD_ARRAY_ANY_CASE>
43+
;
44+
45+
/**
46+
* An asterisk, whether or not whitespace follows it.
47+
*/
48+
Wildcard
49+
: <T_WILDCARD>
50+
| <T_WILDCARD_WS>
51+
;
52+
53+
/**
54+
* A "[" whether or not whitespace precedes it.
55+
*/
56+
SquareBracketOpen
57+
: <T_OPEN_SQUARE_BRACKET>
58+
| <T_OPEN_SQUARE_BRACKET_WS>
59+
;
60+
61+
/**
62+
* A "{" whether or not whitespace precedes it.
63+
*/
64+
CurlyBracketOpen
65+
: <T_OPEN_CURLY_BRACKET>
66+
| <T_OPEN_CURLY_BRACKET_WS>
67+
;
68+
69+
/**
70+
* A "<" whether or not it opens what looks like an HTML tag.
71+
*/
72+
AngleBracketOpen
73+
: <T_OPEN_ANGLE_BRACKET>
74+
| <T_OPEN_ANGLE_BRACKET_HTML>
75+
;
76+
77+
/**
78+
* The line breaks and the line comments a type may be written across, which is
79+
* what "TokenIterator::skipNewLineTokensAndConsumeComments()" walks over.
80+
*
81+
* That method reads "T_COMMENT? (T_PHPDOC_EOL T_COMMENT?)*", and this reads the
82+
* very same thing written the shorter way: a comment runs to the end of its
83+
* line, so the lexer never puts two of them next to each other and never puts
84+
* one anywhere but at the end of a line. Writing it as one repetition rather
85+
* than as three nested rules is what makes it cheap enough to be written in as
86+
* many places as it is.
87+
*
88+
* The comments are kept rather than thrown away: each of them ends up on the
89+
* node the reading reaches first after it, the way the hand-written parser
90+
* flushes them.
91+
*
92+
* This recognizes an empty input as well, so a rule written with it says
93+
* "a line break may be written here" rather than "a line break is written
94+
* here".
95+
*/
96+
Trivia
97+
: ( <T_PHPDOC_EOL> | <T_COMMENT> )*
98+
;

0 commit comments

Comments
 (0)