TypeParser: read the "|" that follows a "?" - #321
valzargaming wants to merge 1 commit into
Conversation
"?A|B" was read as the nullable type "?A", leaving the "|B" for the caller
to fail on, so every tag written that way became an error:
@Property ?int|null $handler Unexpected token "|", expected variable
The "?" is now carried over the union, because "?A|B" is the same set of
values whichever of the two the "?" is read to belong to: "(?A)|B" and
"?(A|B)" are both "A|B|null". It is read as a union whose first member is
"?A", which prints back as it was written.
An "&" is deliberately left as it was: "(?A)&B" and "?(A&B)" are not the
same set, so "?A&B" stays an error rather than being read as either one.
PHP writes that type as "(A&B)|null".
The grammars in doc/grammars are updated to say the same, and the union is
read at every depth a type may be written at, so "array<?int|null>",
"array{a: ?int|null}" and "(?int|string)" are read too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
its a parse-error: https://3v4l.org/kNPeM#v allowing such type in phpdoc while its invalid in native types would be very confusing |
|
you should fix your phpdoc to read it might make sense to open a issue on intelliphense about the bug in phpdoc parsing. I think it works correct at the PHPStan end |
|
I don't think it's a bug either as Intellephense correctly drops the ? without throwing an error whereas phpstan actually throws an error. |
|
yes, intelliphense should also throw an error. |
|
For anyone who comes across this PR in the future I patched this with https://github.com/discord-php/phpdoc-tool I understand it diverts from the RFC, however our library needs to be as faithful to Discord's official documentation as other languages follow this schema, and their docs utilize the |

The problem
A
?written before a type ends the type: the|that may follow it is left unread, for the caller to fail on.So every tag written that way is turned into an error:
It is not specific to
null—?int|stringfails the same way — and it happens at every depth a type may be written at:array<?int|null>,list<?int|null>,array{a: ?int|null}and(?int|string)are all turned down.Why the
|can be read?A|Bis the same set of values whichever of the two the?is read to belong to:(?A)|B→A|null|B?(A|B)→A|B|nullThere is nothing to choose between, and nothing for a reader to get wrong. The parser already reads both of those spellings when they are written with parentheses; only the plain one is turned down, so what is being rejected is the spelling rather than the type.
An
&is a different matter, and this PR deliberately leaves it as it is:(?A)&Band?(A&B)are not the same setso
?A&Bstays an error rather than being quietly read as one of them. PHP writes that type as(A&B)|null, and a test is added pinning the behaviour.Where it is written
PHPDoc written from an API specification that carries its own nullable marker ends up with both markers on the same type. Discord's API reference marks a nullable field by prefixing its type with a question mark, and marks an optional field by suffixing its name with one (Nullable and Optional Resource Fields) — a
?stringcolumn there is a field that may benull.A property transcribed from such a table carries the specification's marker and PHPDoc's own, and is written
?int|null. The two agree: the type isint|nullon either reading.Reported downstream as phpDocumentor/phpDocumentor#3987, where it surfaces as an
ERRORline per tag.On #86
#86 asked for this and was closed on the grounds that
?A|Bis not valid PHP syntax, which is true and is not in dispute here — PHPDoc already reads a good deal that PHP will not (array{a: int},list<int>,int-mask, conditional types). What that PR did not put forward, and what this one rests on, is that the union case is unambiguous and the intersection case is not, so the two can be told apart rather than refused together. It also carried no tests and no grammar. If the answer is still no, the tests here at least write the current behaviour down.The change
TypeParser::parse()andTypeParser::subParse()carry a nullable type into a following union, and only into a union.parseNullable()now enriches the node it builds with its own attributes, asparseAtomic()already does — without it theNullableTypeNodecarries no line or index once it is a union member.doc/grammars/types.pp3says the same, so the fuzzer generates these types andGrammarSyncTestkeeps the two in step.?A|Bis read as a union whose first member is?A, which prints back exactly as written.Checks
make tests— 1766 pass (16 added; no existing test changed or removed)FuzzyTest— 6000 generated inputs passmake phpstan— no errorsmake cs,make lint— cleanGrammarSyncTest::testPhpDocGrammarReadsWhatPhpDocParserReadsfails for me on Windows on an unrelated@return Foo <strong>input, because of\r\nin the known-disagreement list; it fails identically on a clean checkout, so it is not from this change.🤖 Generated with Claude Code