From a4be44eea3cd85e09d817d59e06b8b3325b86bb8 Mon Sep 17 00:00:00 2001 From: valzargaming Date: Mon, 14 Sep 2026 08:33:06 -0400 Subject: [PATCH] TypeParser: read the "|" that follows a "?" "?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", "array{a: ?int|null}" and "(?int|string)" are read too. Co-Authored-By: Claude Opus 5 --- doc/grammars/types.pp3 | 16 +++++- src/Parser/TypeParser.php | 64 +++++++++++++++------- tests/PHPStan/Parser/TypeParserTest.php | 71 +++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 21 deletions(-) diff --git a/doc/grammars/types.pp3 b/doc/grammars/types.pp3 index a1b92aeb..35d77fed 100644 --- a/doc/grammars/types.pp3 +++ b/doc/grammars/types.pp3 @@ -29,9 +29,13 @@ * "int\n|string" is one type while "int\nstring" is a type followed by a * description. Whichever of the two is written, a "|" or a "&" left unread is * an error rather than the end of the type, which is what the predicates say. + * + * A "?" written first carries over the "|" that may follow it, because "?A|B" is + * the same set of values whichever of the two the "?" is read to belong to. An + * "&" is not carried over, as "(?A)&B" and "?(A&B)" are not the same set. */ Type - : Nullable() + : Nullable() ( NullableTail() | ! ! ) | Atomic() ( TypeTail() | ! ! ) ; @@ -39,6 +43,10 @@ TypeTail : Trivia() ( Union() | Intersection() ) ; +NullableTail + : Trivia() Union() + ; + Union : ( Atomic() ( Trivia() & )? )+ ! ; @@ -56,7 +64,7 @@ Nullable * may be written and the only place a line break needs no "|" after it. */ SubType - : Nullable() + : Nullable() SubNullableTail() | ConditionalForParameter() | Atomic() ( Conditional() | SubTypeTail() ) ; @@ -65,6 +73,10 @@ SubTypeTail : Trivia() ( SubUnion() | SubIntersection() )? ; +SubNullableTail + : Trivia() SubUnion()? + ; + SubUnion : ( Trivia() Atomic() Trivia() )+ ! ; diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index a2db4fce..7e10d706 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -34,44 +34,58 @@ public function parse(TokenIterator $tokens): Ast\Type\TypeNode { $startLine = $tokens->currentTokenLine(); $startIndex = $tokens->currentTokenIndex(); - if ($tokens->isCurrentTokenType(Lexer::TOKEN_NULLABLE)) { + $nullable = $tokens->isCurrentTokenType(Lexer::TOKEN_NULLABLE); + if ($nullable) { $type = $this->parseNullable($tokens); } else { $type = $this->parseAtomic($tokens); + } - $tokens->pushSavePoint(); - $tokens->skipNewLineTokensAndConsumeComments(); + $tokens->pushSavePoint(); + $tokens->skipNewLineTokensAndConsumeComments(); - try { - $enrichedType = $this->enrichTypeOnUnionOrIntersection($tokens, $type); + try { + $enrichedType = $this->enrichTypeOnUnionOrIntersection($tokens, $type, $nullable); - } catch (ParserException $parserException) { - $enrichedType = null; - } + } catch (ParserException $parserException) { + $enrichedType = null; + } - if ($enrichedType !== null) { - $type = $enrichedType; - $tokens->dropSavePoint(); + if ($enrichedType !== null) { + $type = $enrichedType; + $tokens->dropSavePoint(); - } else { - $tokens->rollback(); - $type = $this->enrichTypeOnUnionOrIntersection($tokens, $type) ?? $type; - } + } else { + $tokens->rollback(); + $type = $this->enrichTypeOnUnionOrIntersection($tokens, $type, $nullable) ?? $type; } return $this->enrichWithAttributes($tokens, $type, $startLine, $startIndex); } - /** @phpstan-impure */ - private function enrichTypeOnUnionOrIntersection(TokenIterator $tokens, Ast\Type\TypeNode $type): ?Ast\Type\TypeNode + /** + * A "?" written before a type carries over the "|" that may follow it, 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". + * + * An "&" is left alone, as "(?A)&B" and "?(A&B)" are not the same set, and + * PHP itself writes the latter as "(A&B)|null" rather than as "?A&B". + * + * @phpstan-impure + */ + private function enrichTypeOnUnionOrIntersection( + TokenIterator $tokens, + Ast\Type\TypeNode $type, + bool $unionOnly = false + ): ?Ast\Type\TypeNode { if ($tokens->isCurrentTokenType(Lexer::TOKEN_UNION)) { return $this->parseUnion($tokens, $type); } - if ($tokens->isCurrentTokenType(Lexer::TOKEN_INTERSECTION)) { + if (!$unionOnly && $tokens->isCurrentTokenType(Lexer::TOKEN_INTERSECTION)) { return $this->parseIntersection($tokens, $type); } @@ -112,7 +126,11 @@ private function subParse(TokenIterator $tokens): Ast\Type\TypeNode if ($tokens->isCurrentTokenType(Lexer::TOKEN_NULLABLE)) { $type = $this->parseNullable($tokens); + $tokens->skipNewLineTokensAndConsumeComments(); + if ($tokens->isCurrentTokenType(Lexer::TOKEN_UNION)) { + $type = $this->subParseUnion($tokens, $type); + } } elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_VARIABLE)) { $type = $this->parseConditionalForParameter($tokens, $tokens->currentTokenValue()); @@ -395,11 +413,19 @@ private function parseConditionalForParameter(TokenIterator $tokens, string $par /** @phpstan-impure */ private function parseNullable(TokenIterator $tokens): Ast\Type\TypeNode { + $startLine = $tokens->currentTokenLine(); + $startIndex = $tokens->currentTokenIndex(); + $tokens->consumeTokenType(Lexer::TOKEN_NULLABLE); $type = $this->parseAtomic($tokens); - return new Ast\Type\NullableTypeNode($type); + return $this->enrichWithAttributes( + $tokens, + new Ast\Type\NullableTypeNode($type), + $startLine, + $startIndex, + ); } /** @phpstan-impure */ diff --git a/tests/PHPStan/Parser/TypeParserTest.php b/tests/PHPStan/Parser/TypeParserTest.php index 1f97651d..a1faa872 100644 --- a/tests/PHPStan/Parser/TypeParserTest.php +++ b/tests/PHPStan/Parser/TypeParserTest.php @@ -546,6 +546,77 @@ public function provideParseData(): array new IdentifierTypeNode('int'), ), ], + [ + '?int|null', + new UnionTypeNode([ + new NullableTypeNode(new IdentifierTypeNode('int')), + new IdentifierTypeNode('null'), + ]), + ], + [ + '?Foo|Bar', + new UnionTypeNode([ + new NullableTypeNode(new IdentifierTypeNode('Foo')), + new IdentifierTypeNode('Bar'), + ]), + ], + [ + '?Foo|Bar|Baz', + new UnionTypeNode([ + new NullableTypeNode(new IdentifierTypeNode('Foo')), + new IdentifierTypeNode('Bar'), + new IdentifierTypeNode('Baz'), + ]), + ], + [ + "?Foo\n|Bar", + new UnionTypeNode([ + new NullableTypeNode(new IdentifierTypeNode('Foo')), + new IdentifierTypeNode('Bar'), + ]), + ], + [ + '(?Foo|Bar)', + new UnionTypeNode([ + new NullableTypeNode(new IdentifierTypeNode('Foo')), + new IdentifierTypeNode('Bar'), + ]), + ], + [ + 'array', + new GenericTypeNode( + new IdentifierTypeNode('array'), + [ + new UnionTypeNode([ + new NullableTypeNode(new IdentifierTypeNode('Foo')), + new IdentifierTypeNode('null'), + ]), + ], + [ + GenericTypeNode::VARIANCE_INVARIANT, + ], + ), + ], + [ + 'array{a: ?Foo|null}', + ArrayShapeNode::createSealed([ + new ArrayShapeItemNode( + new IdentifierTypeNode('a'), + false, + new UnionTypeNode([ + new NullableTypeNode(new IdentifierTypeNode('Foo')), + new IdentifierTypeNode('null'), + ]), + ), + ]), + ], + [ + // "(?Foo)&Bar" and "?(Foo&Bar)" are different types, so the "&" + // is left for the caller to reject rather than read into either. + '?Foo&Bar', + new NullableTypeNode(new IdentifierTypeNode('Foo')), + Lexer::TOKEN_INTERSECTION, + ], [ '?Foo', new NullableTypeNode(