Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
*.abnf text eol=crlf
*.php text eol=lf
*.pp3 text eol=lf

.github export-ignore
apigen export-ignore
phpcs.xml export-ignore
doc export-ignore
tests export-ignore
tools export-ignore
tmp export-ignore
.editorconfig export-ignore
.gitattributes export-ignore
.gitignore export-ignore
build-abnfgen.sh export-ignore
CLAUDE.md export-ignore
CODE_OF_CONDUCT.md export-ignore
Makefile export-ignore
Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,44 @@
- name: "Tests"
run: "make tests"

grammars:
name: "Grammars"
runs-on: "ubuntu-latest"

strategy:
fail-fast: false
matrix:
php-version:
- "8.4"
- "8.5"

steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit

- name: "Checkout"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Comment on lines +160 to +161

- name: "Install PHP"
uses: "shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240" # v2
with:
coverage: "none"
php-version: "${{ matrix.php-version }}"
tools: composer:v2

- name: "Install dependencies"
run: "composer update --no-interaction --no-progress"

# The compiler reading doc/grammars asks for PHP 8.4, so the corpus is
# only written here. Everywhere else FuzzyTest skips itself.
- name: "Install the grammar toolchain"
run: "make grammars-install"

- name: "Tests"
run: "make tests"

static-analysis:
name: "PHPStan"
runs-on: "ubuntu-latest"
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/docs
/temp
/tools
/tools/phplrt/vendor
/tests/tmp
/build-cs
/vendor
Expand Down
11 changes: 11 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,14 @@ phpstan:
.PHONY: phpstan-generate-baseline
phpstan-generate-baseline:
php vendor/bin/phpstan --generate-baseline

# ---------------------------------------------------------------------------
# The grammars
# ---------------------------------------------------------------------------

# The tool writing a corpus out of doc/grammars is a development dependency of
# its own, because the grammar compiler asks for a PHP this library still runs
# without.
.PHONY: grammars-install
grammars-install:
composer install --no-interaction --working-dir tools/phplrt
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,19 @@ $newPhpDoc = $printer->printFormatPreserving($newPhpDocNode, $phpDocNode, $token
echo $newPhpDoc; // '/** @param Ipsum $a */'
```

## The grammars

The language this library reads is written down as a grammar in
[`doc/grammars`](doc/grammars), in the format the [phplrt](https://phplrt.org)
compiler reads. 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: that is where `FuzzyTest` gets its corpus, and it covers a great deal more
of the language than a hand-written one does.

Nothing in `src/` reads those files, and the library needs neither the toolchain
writing the corpus nor the PHP 8.4 it asks for. See
[`doc/grammars/README.md`](doc/grammars/README.md).

## Code of Conduct

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.
Expand All @@ -181,3 +194,11 @@ Afterwards you can either run the whole build including linting and coding stand
or run only tests using

make tests

The grammars have a toolchain of their own, because the compiler reading them
asks for PHP 8.4. Without it the fuzzy tests skip themselves:

make grammars-install # install it

`FuzzyTest` then writes its own corpus out of `doc/grammars/*.pp3` every time it
runs, and leaves it in `temp/fuzzy` to be looked at afterwards.
22 changes: 0 additions & 22 deletions build-abnfgen.sh

This file was deleted.

130 changes: 130 additions & 0 deletions doc/grammars/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# The grammars of the PHPDoc language

The files in this directory describe the language `phpstan/phpdoc-parser` reads,
in the [PP3 format](https://phplrt.org/docs/basics/grammar) the
[phplrt](https://phplrt.org) compiler reads. They are the specification of the
language, and they are what `tests/PHPStan/Parser/FuzzyTest.php` writes its
corpus from.

| File | What it holds |
|---------------------|-----------------------------------------------------------------|
| `lexemes.pp3` | Every token the language is read into |
| `common.pp3` | What the grammars share: names, brackets, line breaks |
| `types.pp3` | The type language of `TypeParser` |
| `const-expr.pp3` | The constant expressions of `ConstExprParser` |
| `phpdoc-block.pp3` | The PHPDoc itself: its tags, its text, its Doctrine annotations |
| `type.pp3` | The entry point starting at `Type` |
| `constant-expr.pp3` | The entry point starting at `ConstantExpr` |
| `phpdoc.pp3` | The entry point starting at `PhpDoc` |

## What they are for

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:

make grammars-install # the toolchain, which asks for PHP 8.4
php vendor/bin/phpunit --filter FuzzyTest

`FuzzyTest` runs `tools/phplrt/fuzz.php` itself, once per grammar, every time it
runs: the tool compiles a grammar, walks its rules at random and writes down
what comes out, and the test then asks the parser to read every one of them in
full, and to read a type back as the very same type once it has been printed.
There is no corpus to keep, and none to refresh when a rule changes — what is
generated changes the moment the grammar is read again. The inputs of the last
run are left in `temp/fuzzy` to be looked at.

That is a great deal more of the language than a hand-written corpus covers, and
it is what replaced the `abnfgen`-driven fuzzer this project used before.

Nothing in `src/` reads these files, and the library needs neither the toolchain
nor PHP 8.4: where either is missing, `FuzzyTest` skips itself. Only the
`Grammars` job of `.github/workflows/build.yml` runs it for real.

## How they are written

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 that a grammar and the parser it describes can be read side by
side.

### What 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 `InvalidTagValueNode` carrying the
very error it has raised, and a grammar has no way of writing that error down.
So what a broken PHPDoc means is left to the parser, and everything the grammars
describe is something the parser has to read in full.

Two things follow from wanting that to hold for **every** input rather than for
most of them:

- **A place the parser raises an error at is written as something the grammar
cannot recognize.** Most of them are written as a `!` predicate forbidding
whatever the error would have been raised on. For instance a name followed by
a `<` has to go on into a generic type or into a callable, because `Foo<` is
an error rather than the type `Foo` followed by something else:

```
IdentifierAtomic
: ...
| !ShapeBrace() Identifier() !<T_DOUBLE_COLON> ( IdentifierSuffix() | !<T_OPEN_ANGLE_BRACKET> )
;
```

The same predicate is what keeps a rule from **giving back** what it has read.
`@template T of` is an error rather than a template named `T` with the
description `of`, so the bound is written as "either a bound or no `of` at
all":

```
TemplateUpperBound
: <T_KEYWORD_OF> Type()
| <T_KEYWORD_AS> Type()
| !<T_KEYWORD_OF> !<T_KEYWORD_AS>
;
```

- **A rule reads exactly the tokens its method reads**, down to the line breaks
around it.

### The tokens are not read by phplrt

A grammar of this directory is not read by the lexer it declares: it is read by
the very tokens `PHPStan\PhpDocParser\Lexer\Lexer` produces, handed over by
`tools/phplrt/Fuzzer/TokenStream.php`.

The `%token` declarations therefore name the tokens and document the language
without being what reads it. Some of them describe something the lexer never
reads as a token of its own, and `TokenStream` is what tells those apart in the
stream:

- a word the parser compares by value (`is`, `array`, `covariant`, `static`, …)
— every one of them is still an ordinary name as well, which is why they are
all listed among the alternatives of `Identifier`;
- a tag whose value a rule of its own reads (`@param`, `@return`, …), told apart
from the tags nothing reads the value of;
- a bracket or an asterisk whose neighbouring whitespace decides what it means,
which is what tells `array{a: int}` from the type `array` followed by a brace,
and `Foo[0]` from `Foo [0]`;
- a tag a space is written before, which is what tells the `@since` of
`@author Foo @since 1.0` from the `@baz` of `@author Foo <foo@baz.com>`;
- a `<` opening what the parser recognizes as an HTML tag, so that
`@return Foo<br>see below</br>` keeps meaning the type `Foo` followed by a
description.

Telling them apart there is what lets the grammars be written without semantic
predicates, which the PP3 format has none of.

### What is left out

Three corners of the language are left out on purpose, because a grammar cannot
say what the parser does there. Each of them is written up where the rule that
skirts it is written:

- a description that ends at a tag written in the middle of a line, which the
parser decides by reading the tag and looking at what it turns out to be;
- the same, on a line after the first, where the parser reads that line twice:
once as part of the description and again as whatever comes next;
- a tag whose value a rule reads, written with a parenthesis after it, where
whether the description ends there depends on whether that value can be read
at all.
98 changes: 98 additions & 0 deletions doc/grammars/common.pp3
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* -----------------------------------------------------------------------------
* What Both Grammars Are Written Of
* -----------------------------------------------------------------------------
*/

/**
* A name, which every keyword is as well.
*
* The lexer reads all of them as T_IDENTIFIER and they are only told apart in
* the stream so that the rules asking for a particular word can be written
* without a semantic predicate. A word is therefore still a perfectly ordinary
* name wherever a name is what is being read.
*/
Identifier
: <T_IDENTIFIER>
| <T_KEYWORD_IS>
| <T_KEYWORD_NOT>
| <T_KEYWORD_STATIC>
| <T_KEYWORD_FROM>
| <T_KEYWORD_OF>
| <T_KEYWORD_AS>
| <T_KEYWORD_SUPER>
| <T_KEYWORD_COVARIANT>
| <T_KEYWORD_CONTRAVARIANT>
| <T_KEYWORD_ARRAY>
| <T_KEYWORD_ARRAY_ANY_CASE>
| <T_KEYWORD_LIST>
| <T_KEYWORD_NON_EMPTY_ARRAY>
| <T_KEYWORD_NON_EMPTY_LIST>
| <T_KEYWORD_OBJECT>
| <T_KEYWORD_TRUE>
| <T_KEYWORD_FALSE>
| <T_KEYWORD_NULL>
;

/**
* The word "array" written in any case at all.
*/
ArrayKeyword
: <T_KEYWORD_ARRAY>
| <T_KEYWORD_ARRAY_ANY_CASE>
;

/**
* An asterisk, whether or not whitespace follows it.
*/
Wildcard
: <T_WILDCARD>
| <T_WILDCARD_WS>
;

/**
* A "[" whether or not whitespace precedes it.
*/
SquareBracketOpen
: <T_OPEN_SQUARE_BRACKET>
| <T_OPEN_SQUARE_BRACKET_WS>
;

/**
* A "{" whether or not whitespace precedes it.
*/
CurlyBracketOpen
: <T_OPEN_CURLY_BRACKET>
| <T_OPEN_CURLY_BRACKET_WS>
;

/**
* A "<" whether or not it opens what looks like an HTML tag.
*/
AngleBracketOpen
: <T_OPEN_ANGLE_BRACKET>
| <T_OPEN_ANGLE_BRACKET_HTML>
;

/**
* The line breaks and the line comments a type may be written across, which is
* what "TokenIterator::skipNewLineTokensAndConsumeComments()" walks over.
*
* That method reads "T_COMMENT? (T_PHPDOC_EOL T_COMMENT?)*", and this reads the
* very same thing written the shorter way: a comment runs to the end of its
* line, so the lexer never puts two of them next to each other and never puts
* one anywhere but at the end of a line. Writing it as one repetition rather
* than as three nested rules is what makes it cheap enough to be written in as
* many places as it is.
*
* The comments are kept rather than thrown away: each of them ends up on the
* node the reading reaches first after it, the way the hand-written parser
* flushes them.
*
* This recognizes an empty input as well, so a rule written with it says
* "a line break may be written here" rather than "a line break is written
* here".
*/
Trivia
: ( <T_PHPDOC_EOL> | <T_COMMENT> )*
;
Loading
Loading