From 8e7cf141b6221b5f168f3fc49190dff5e453d5af Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Thu, 14 May 2026 23:16:05 +0000 Subject: [PATCH 1/5] Add `null` to return type of `Iterator::current()`, `Iterator::key()`, and `Generator::send()` with `valid()` narrowing - Add `IteratorCurrentReturnTypeExtension` that appends `|null` to the return type of `Iterator::current()` and `Iterator::key()` via `DynamicMethodReturnTypeExtension`, since these methods can return null when the iterator is in an invalid state (exhausted, before rewind, etc.) - Add `GeneratorSendReturnTypeExtension` that appends `|null` to `Generator::send()` for the same reason (returns null when generator is exhausted) - Add `@phpstan-assert-if-true !null $this->current()` and `@phpstan-assert-if-true !null $this->key()` to `Iterator::valid()` in the stub, so that `null` is narrowed away after a `valid()` check - `foreach` loops are unaffected because they resolve value/key types via `getIterableValueType()`/`getIterableKeyType()` which use template types directly, not the method return types - Update existing tests that asserted the old (buggy) behavior without `valid()` checks --- .../Php/GeneratorSendReturnTypeExtension.php | 40 ++++ .../IteratorCurrentReturnTypeExtension.php | 41 ++++ stubs/iterable.stub | 6 + tests/PHPStan/Analyser/nsrt/bug-3674.php | 175 ++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-7519.php | 8 +- .../Rules/Methods/data/infer-array-key.php | 10 +- 6 files changed, 271 insertions(+), 9 deletions(-) create mode 100644 src/Type/Php/GeneratorSendReturnTypeExtension.php create mode 100644 src/Type/Php/IteratorCurrentReturnTypeExtension.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-3674.php diff --git a/src/Type/Php/GeneratorSendReturnTypeExtension.php b/src/Type/Php/GeneratorSendReturnTypeExtension.php new file mode 100644 index 00000000000..9e644824ba0 --- /dev/null +++ b/src/Type/Php/GeneratorSendReturnTypeExtension.php @@ -0,0 +1,40 @@ +getName() === 'send'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + $returnType = ParametersAcceptorSelector::selectFromArgs( + $scope, + $methodCall->getArgs(), + $methodReflection->getVariants(), + )->getReturnType(); + + return TypeCombinator::addNull($returnType); + } + +} diff --git a/src/Type/Php/IteratorCurrentReturnTypeExtension.php b/src/Type/Php/IteratorCurrentReturnTypeExtension.php new file mode 100644 index 00000000000..721be7323b3 --- /dev/null +++ b/src/Type/Php/IteratorCurrentReturnTypeExtension.php @@ -0,0 +1,41 @@ +getName(), ['current', 'key'], true); + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + $returnType = ParametersAcceptorSelector::selectFromArgs( + $scope, + $methodCall->getArgs(), + $methodReflection->getVariants(), + )->getReturnType(); + + return TypeCombinator::addNull($returnType); + } + +} diff --git a/stubs/iterable.stub b/stubs/iterable.stub index baf5ca90837..d23600652f9 100644 --- a/stubs/iterable.stub +++ b/stubs/iterable.stub @@ -43,6 +43,12 @@ interface Iterator extends Traversable */ public function key(); + /** + * @phpstan-assert-if-true !null $this->current() + * @phpstan-assert-if-true !null $this->key() + */ + public function valid(): bool; + } /** diff --git a/tests/PHPStan/Analyser/nsrt/bug-3674.php b/tests/PHPStan/Analyser/nsrt/bug-3674.php new file mode 100644 index 00000000000..6daaa2f2280 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-3674.php @@ -0,0 +1,175 @@ + */ +function gen(): Generator { yield 'hello'; } + +function testGeneratorCurrent(): void +{ + $it = gen(); + assertType('string|null', $it->current()); + assertType('int|null', $it->key()); +} + +function testGeneratorAfterValid(): void +{ + $it = gen(); + if ($it->valid()) { + assertType('string', $it->current()); + assertType('int', $it->key()); + } +} + +function testGeneratorInForeach(): void +{ + foreach (gen() as $key => $value) { + assertType('string', $value); + assertType('int', $key); + } +} + +/** @param Iterator $it */ +function testIteratorCurrent(Iterator $it): void +{ + assertType('string|null', $it->current()); + assertType('int|null', $it->key()); +} + +/** @param Iterator $it */ +function testIteratorAfterValid(Iterator $it): void +{ + if ($it->valid()) { + assertType('string', $it->current()); + assertType('int', $it->key()); + } +} + +/** @param Iterator $it */ +function testIteratorInForeach(Iterator $it): void +{ + foreach ($it as $key => $value) { + assertType('string', $value); + assertType('int', $key); + } +} + +/** @param ArrayIterator $it */ +function testArrayIteratorCurrent(ArrayIterator $it): void +{ + assertType('string|null', $it->current()); + assertType('int|null', $it->key()); +} + +/** @param ArrayIterator $it */ +function testArrayIteratorAfterValid(ArrayIterator $it): void +{ + if ($it->valid()) { + assertType('string', $it->current()); + assertType('int', $it->key()); + } +} + +function testGeneratorWhileLoop(): void +{ + $it = gen(); + $it->rewind(); + while ($it->valid()) { + assertType('string', $it->current()); + assertType('int', $it->key()); + $it->next(); + } +} + +function testGeneratorSend(): void +{ + /** @var Generator $gen */ + $gen = gen(); + assertType('string|null', $gen->send(42)); +} + +/** @return Generator */ +function genInt(): Generator { yield 1; } + +function testOriginalIssue(): void +{ + $it = genInt(); + assertType('int|null', $it->current()); +} + +/** @param Iterator $it */ +function testNegatedValid(Iterator $it): void +{ + if (!$it->valid()) { + assertType('null', $it->current()); + assertType('null', $it->key()); + } +} + +function testWhileLoopWithValid(): void +{ + $it = gen(); + while ($it->valid()) { + $v = $it->current(); + assertType('string', $v); + $k = $it->key(); + assertType('int', $k); + $it->next(); + } + assertType('null', $it->current()); + assertType('null', $it->key()); +} + +/** + * @template T + * @implements Iterator + */ +class CustomIterator implements Iterator +{ + /** @return T|null */ + public function current(): mixed { return null; } + public function key(): int { return 0; } + public function next(): void {} + public function rewind(): void {} + public function valid(): bool { return false; } +} + +/** @param CustomIterator $it */ +function testCustomIterator(CustomIterator $it): void +{ + assertType('string|null', $it->current()); + assertType('int|null', $it->key()); + if ($it->valid()) { + assertType('string', $it->current()); + assertType('int', $it->key()); + } +} + +/** @param \IteratorIterator> $it */ +function testIteratorIterator(\IteratorIterator $it): void +{ + assertType('string|null', $it->current()); + assertType('int|null', $it->key()); + if ($it->valid()) { + assertType('string', $it->current()); + assertType('int', $it->key()); + } +} + +/** @param \NoRewindIterator> $it */ +function testNoRewindIterator(\NoRewindIterator $it): void +{ + assertType('string|null', $it->current()); + assertType('int|null', $it->key()); + if ($it->valid()) { + assertType('string', $it->current()); + assertType('int', $it->key()); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-7519.php b/tests/PHPStan/Analyser/nsrt/bug-7519.php index 1fd556f0e3a..b8201139bbf 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-7519.php +++ b/tests/PHPStan/Analyser/nsrt/bug-7519.php @@ -41,8 +41,8 @@ function doFoo() { $iterator = new FooFilterIterator($generator()); - assertType('array{}|bool|stdClass', $iterator->key()); - assertType('array{}|bool|stdClass', $iterator->current()); + assertType('array{}|bool|stdClass|null', $iterator->key()); + assertType('array{}|bool|stdClass|null', $iterator->current()); $generator = static function (): Generator { yield true => true; @@ -51,6 +51,6 @@ function doFoo() { $iterator = new FooFilterIterator($generator()); - assertType('bool', $iterator->key()); - assertType('bool', $iterator->current()); + assertType('bool|null', $iterator->key()); + assertType('bool|null', $iterator->current()); } diff --git a/tests/PHPStan/Rules/Methods/data/infer-array-key.php b/tests/PHPStan/Rules/Methods/data/infer-array-key.php index 86ab98a1b5c..b8f9fdedda5 100644 --- a/tests/PHPStan/Rules/Methods/data/infer-array-key.php +++ b/tests/PHPStan/Rules/Methods/data/infer-array-key.php @@ -17,7 +17,7 @@ class Foo implements \IteratorAggregate public function getIterator() { $it = new \ArrayIterator($this->items); - assertType('(int|string)', $it->key()); + assertType('int|string|null', $it->key()); return $it; } @@ -37,7 +37,7 @@ class Bar implements \IteratorAggregate public function getIterator() { $it = new \ArrayIterator($this->items); - assertType('int', $it->key()); + assertType('int|null', $it->key()); return $it; } @@ -57,7 +57,7 @@ class Baz implements \IteratorAggregate public function getIterator() { $it = new \ArrayIterator($this->items); - assertType('string', $it->key()); + assertType('string|null', $it->key()); return $it; } @@ -77,7 +77,7 @@ class Lorem implements \IteratorAggregate public function getIterator() { $it = new \ArrayIterator($this->items); - assertType('(int|string)', $it->key()); + assertType('int|string|null', $it->key()); return $it; } @@ -97,7 +97,7 @@ class Ipsum implements \IteratorAggregate public function getIterator() { $it = new \ArrayIterator($this->items); - assertType('int|string', $it->key()); + assertType('int|string|null', $it->key()); return $it; } From 97c33db4659b6c06556563f7faee7a1952e90829 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 09:11:38 +0000 Subject: [PATCH 2/5] Add tests verifying next() and rewind() reset valid() narrowing Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-3674.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-3674.php b/tests/PHPStan/Analyser/nsrt/bug-3674.php index 6daaa2f2280..813fab63cdd 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-3674.php +++ b/tests/PHPStan/Analyser/nsrt/bug-3674.php @@ -173,3 +173,27 @@ function testNoRewindIterator(\NoRewindIterator $it): void assertType('int', $it->key()); } } + +/** @param Iterator $it */ +function testNextResetsNarrowing(Iterator $it): void +{ + if ($it->valid()) { + assertType('string', $it->current()); + assertType('int', $it->key()); + $it->next(); + assertType('string|null', $it->current()); + assertType('int|null', $it->key()); + } +} + +/** @param Iterator $it */ +function testRewindResetsNarrowing(Iterator $it): void +{ + if (!$it->valid()) { + assertType('null', $it->current()); + assertType('null', $it->key()); + $it->rewind(); + assertType('string|null', $it->current()); + assertType('int|null', $it->key()); + } +} From 52bdce353c63e2058f628d6fd0198d0e2666ab05 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 09:45:21 +0000 Subject: [PATCH 3/5] Address review: preserve benevolent unions, respect non-null native return types, gate test on PHP 8+ - Skip adding null when the native return type explicitly excludes it (e.g. a class overriding current(): bool) - Preserve BenevolentUnionType when adding null so (int|string) becomes (int|string|null) instead of int|string|null - Add // lint >= 8.0 to bug-3674.php since mixed return type is used - Add test for non-null override (NonNullIterator) - Fix CustomIterator::key() assertion (native : int excludes null) Co-Authored-By: Claude Opus 4.6 --- .../Php/GeneratorSendReturnTypeExtension.php | 24 ++++++++++++++++--- .../IteratorCurrentReturnTypeExtension.php | 24 ++++++++++++++++--- tests/PHPStan/Analyser/nsrt/bug-3674.php | 22 +++++++++++++++-- .../Rules/Methods/data/infer-array-key.php | 4 ++-- 4 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/Type/Php/GeneratorSendReturnTypeExtension.php b/src/Type/Php/GeneratorSendReturnTypeExtension.php index 9e644824ba0..8137db2b79c 100644 --- a/src/Type/Php/GeneratorSendReturnTypeExtension.php +++ b/src/Type/Php/GeneratorSendReturnTypeExtension.php @@ -6,11 +6,15 @@ use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParametersAcceptorSelector; +use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\DynamicMethodReturnTypeExtension; +use PHPStan\Type\NullType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeUtils; #[AutowiredService] final class GeneratorSendReturnTypeExtension implements DynamicMethodReturnTypeExtension @@ -28,13 +32,27 @@ public function isMethodSupported(MethodReflection $methodReflection): bool public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type { - $returnType = ParametersAcceptorSelector::selectFromArgs( + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( $scope, $methodCall->getArgs(), $methodReflection->getVariants(), - )->getReturnType(); + ); - return TypeCombinator::addNull($returnType); + $returnType = $parametersAcceptor->getReturnType(); + + if ($parametersAcceptor instanceof ExtendedParametersAcceptor) { + $nativeReturnType = $parametersAcceptor->getNativeReturnType(); + if ($nativeReturnType->isSuperTypeOf(new NullType())->no()) { + return $returnType; + } + } + + $result = TypeCombinator::addNull($returnType); + if ($returnType instanceof BenevolentUnionType && !($result instanceof BenevolentUnionType)) { + $result = TypeUtils::toBenevolentUnion($result); + } + + return $result; } } diff --git a/src/Type/Php/IteratorCurrentReturnTypeExtension.php b/src/Type/Php/IteratorCurrentReturnTypeExtension.php index 721be7323b3..5295107af02 100644 --- a/src/Type/Php/IteratorCurrentReturnTypeExtension.php +++ b/src/Type/Php/IteratorCurrentReturnTypeExtension.php @@ -6,11 +6,15 @@ use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParametersAcceptorSelector; +use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\DynamicMethodReturnTypeExtension; +use PHPStan\Type\NullType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeUtils; use function in_array; #[AutowiredService] @@ -29,13 +33,27 @@ public function isMethodSupported(MethodReflection $methodReflection): bool public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type { - $returnType = ParametersAcceptorSelector::selectFromArgs( + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( $scope, $methodCall->getArgs(), $methodReflection->getVariants(), - )->getReturnType(); + ); - return TypeCombinator::addNull($returnType); + $returnType = $parametersAcceptor->getReturnType(); + + if ($parametersAcceptor instanceof ExtendedParametersAcceptor) { + $nativeReturnType = $parametersAcceptor->getNativeReturnType(); + if ($nativeReturnType->isSuperTypeOf(new NullType())->no()) { + return $returnType; + } + } + + $result = TypeCombinator::addNull($returnType); + if ($returnType instanceof BenevolentUnionType && !($result instanceof BenevolentUnionType)) { + $result = TypeUtils::toBenevolentUnion($result); + } + + return $result; } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-3674.php b/tests/PHPStan/Analyser/nsrt/bug-3674.php index 813fab63cdd..f21a89a83a6 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-3674.php +++ b/tests/PHPStan/Analyser/nsrt/bug-3674.php @@ -1,4 +1,4 @@ -= 8.0 declare(strict_types = 1); @@ -145,7 +145,7 @@ public function valid(): bool { return false; } function testCustomIterator(CustomIterator $it): void { assertType('string|null', $it->current()); - assertType('int|null', $it->key()); + assertType('int', $it->key()); if ($it->valid()) { assertType('string', $it->current()); assertType('int', $it->key()); @@ -197,3 +197,21 @@ function testRewindResetsNarrowing(Iterator $it): void assertType('int|null', $it->key()); } } + +/** + * @implements Iterator + */ +class NonNullIterator implements Iterator +{ + public function current(): string { return 'hello'; } + public function key(): int { return 0; } + public function next(): void {} + public function rewind(): void {} + public function valid(): bool { return false; } +} + +function testNonNullOverride(NonNullIterator $it): void +{ + assertType('string', $it->current()); + assertType('int', $it->key()); +} diff --git a/tests/PHPStan/Rules/Methods/data/infer-array-key.php b/tests/PHPStan/Rules/Methods/data/infer-array-key.php index b8f9fdedda5..2eeaeb49517 100644 --- a/tests/PHPStan/Rules/Methods/data/infer-array-key.php +++ b/tests/PHPStan/Rules/Methods/data/infer-array-key.php @@ -17,7 +17,7 @@ class Foo implements \IteratorAggregate public function getIterator() { $it = new \ArrayIterator($this->items); - assertType('int|string|null', $it->key()); + assertType('(int|string|null)', $it->key()); return $it; } @@ -77,7 +77,7 @@ class Lorem implements \IteratorAggregate public function getIterator() { $it = new \ArrayIterator($this->items); - assertType('int|string|null', $it->key()); + assertType('(int|string|null)', $it->key()); return $it; } From 96cd039c19c7b5e3ecb5cbf1d091dc599997c2c8 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 10:01:07 +0000 Subject: [PATCH 4/5] =?UTF-8?q?Remove=20@phpstan-assert-if-true=20on=20Ite?= =?UTF-8?q?rator::valid()=20=E2=80=94=20null=20can=20be=20a=20valid=20TKey?= =?UTF-8?q?/TValue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assertion `@phpstan-assert-if-true !null $this->current()` is incorrect when TValue includes null (e.g. Iterator): valid() returning true does not guarantee current() is non-null, since null is a legitimate value. Co-Authored-By: Claude Opus 4.6 --- stubs/iterable.stub | 6 ---- tests/PHPStan/Analyser/nsrt/bug-3674.php | 46 ++++++++++++------------ 2 files changed, 23 insertions(+), 29 deletions(-) diff --git a/stubs/iterable.stub b/stubs/iterable.stub index d23600652f9..baf5ca90837 100644 --- a/stubs/iterable.stub +++ b/stubs/iterable.stub @@ -43,12 +43,6 @@ interface Iterator extends Traversable */ public function key(); - /** - * @phpstan-assert-if-true !null $this->current() - * @phpstan-assert-if-true !null $this->key() - */ - public function valid(): bool; - } /** diff --git a/tests/PHPStan/Analyser/nsrt/bug-3674.php b/tests/PHPStan/Analyser/nsrt/bug-3674.php index f21a89a83a6..ddb02b83064 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-3674.php +++ b/tests/PHPStan/Analyser/nsrt/bug-3674.php @@ -23,8 +23,8 @@ function testGeneratorAfterValid(): void { $it = gen(); if ($it->valid()) { - assertType('string', $it->current()); - assertType('int', $it->key()); + assertType('string|null', $it->current()); + assertType('int|null', $it->key()); } } @@ -47,8 +47,8 @@ function testIteratorCurrent(Iterator $it): void function testIteratorAfterValid(Iterator $it): void { if ($it->valid()) { - assertType('string', $it->current()); - assertType('int', $it->key()); + assertType('string|null', $it->current()); + assertType('int|null', $it->key()); } } @@ -72,8 +72,8 @@ function testArrayIteratorCurrent(ArrayIterator $it): void function testArrayIteratorAfterValid(ArrayIterator $it): void { if ($it->valid()) { - assertType('string', $it->current()); - assertType('int', $it->key()); + assertType('string|null', $it->current()); + assertType('int|null', $it->key()); } } @@ -82,8 +82,8 @@ function testGeneratorWhileLoop(): void $it = gen(); $it->rewind(); while ($it->valid()) { - assertType('string', $it->current()); - assertType('int', $it->key()); + assertType('string|null', $it->current()); + assertType('int|null', $it->key()); $it->next(); } } @@ -108,8 +108,8 @@ function testOriginalIssue(): void function testNegatedValid(Iterator $it): void { if (!$it->valid()) { - assertType('null', $it->current()); - assertType('null', $it->key()); + assertType('string|null', $it->current()); + assertType('int|null', $it->key()); } } @@ -118,13 +118,13 @@ function testWhileLoopWithValid(): void $it = gen(); while ($it->valid()) { $v = $it->current(); - assertType('string', $v); + assertType('string|null', $v); $k = $it->key(); - assertType('int', $k); + assertType('int|null', $k); $it->next(); } - assertType('null', $it->current()); - assertType('null', $it->key()); + assertType('string|null', $it->current()); + assertType('int|null', $it->key()); } /** @@ -147,7 +147,7 @@ function testCustomIterator(CustomIterator $it): void assertType('string|null', $it->current()); assertType('int', $it->key()); if ($it->valid()) { - assertType('string', $it->current()); + assertType('string|null', $it->current()); assertType('int', $it->key()); } } @@ -158,8 +158,8 @@ function testIteratorIterator(\IteratorIterator $it): void assertType('string|null', $it->current()); assertType('int|null', $it->key()); if ($it->valid()) { - assertType('string', $it->current()); - assertType('int', $it->key()); + assertType('string|null', $it->current()); + assertType('int|null', $it->key()); } } @@ -169,8 +169,8 @@ function testNoRewindIterator(\NoRewindIterator $it): void assertType('string|null', $it->current()); assertType('int|null', $it->key()); if ($it->valid()) { - assertType('string', $it->current()); - assertType('int', $it->key()); + assertType('string|null', $it->current()); + assertType('int|null', $it->key()); } } @@ -178,8 +178,8 @@ function testNoRewindIterator(\NoRewindIterator $it): void function testNextResetsNarrowing(Iterator $it): void { if ($it->valid()) { - assertType('string', $it->current()); - assertType('int', $it->key()); + assertType('string|null', $it->current()); + assertType('int|null', $it->key()); $it->next(); assertType('string|null', $it->current()); assertType('int|null', $it->key()); @@ -190,8 +190,8 @@ function testNextResetsNarrowing(Iterator $it): void function testRewindResetsNarrowing(Iterator $it): void { if (!$it->valid()) { - assertType('null', $it->current()); - assertType('null', $it->key()); + assertType('string|null', $it->current()); + assertType('int|null', $it->key()); $it->rewind(); assertType('string|null', $it->current()); assertType('int|null', $it->key()); From f3eadfd464eabcd0b77f2588a068b48c0770aadd Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 10:21:50 +0000 Subject: [PATCH 5/5] Narrow Iterator::current() and key() via MethodTypeSpecifyingExtension on valid() Instead of @phpstan-assert-if-true !null (which incorrectly strips null when null is a valid TKey/TValue), use a MethodTypeSpecifyingExtension that narrows current()/key() to their base return types from the method reflection when valid() is truthy. This removes only the null added by IteratorCurrentReturnTypeExtension while preserving null when it is part of the declared return type or template parameter. Co-Authored-By: Claude Opus 4.6 --- ...atorValidMethodTypeSpecifyingExtension.php | 72 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-3674.php | 52 +++++++++----- 2 files changed, 108 insertions(+), 16 deletions(-) create mode 100644 src/Type/Php/IteratorValidMethodTypeSpecifyingExtension.php diff --git a/src/Type/Php/IteratorValidMethodTypeSpecifyingExtension.php b/src/Type/Php/IteratorValidMethodTypeSpecifyingExtension.php new file mode 100644 index 00000000000..a4da26faae9 --- /dev/null +++ b/src/Type/Php/IteratorValidMethodTypeSpecifyingExtension.php @@ -0,0 +1,72 @@ +typeSpecifier = $typeSpecifier; + } + + public function getClass(): string + { + return Iterator::class; + } + + public function isMethodSupported(MethodReflection $methodReflection, MethodCall $node, TypeSpecifierContext $context): bool + { + return $methodReflection->getName() === 'valid' + && $context->truthy(); + } + + public function specifyTypes(MethodReflection $methodReflection, MethodCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $calledOnType = $scope->getType($node->var); + $types = new SpecifiedTypes(); + + foreach (['current', 'key'] as $methodName) { + $methodCallExpr = new MethodCall($node->var, new Identifier($methodName)); + + $targetMethodReflection = $scope->getMethodReflection($calledOnType, $methodName); + if ($targetMethodReflection === null) { + continue; + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + [], + $targetMethodReflection->getVariants(), + ); + + $baseReturnType = $parametersAcceptor->getReturnType(); + + $types = $types->unionWith($this->typeSpecifier->create( + $methodCallExpr, + $baseReturnType, + TypeSpecifierContext::createTrue(), + $scope, + )); + } + + return $types; + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-3674.php b/tests/PHPStan/Analyser/nsrt/bug-3674.php index ddb02b83064..69e8b45aa9d 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-3674.php +++ b/tests/PHPStan/Analyser/nsrt/bug-3674.php @@ -23,8 +23,8 @@ function testGeneratorAfterValid(): void { $it = gen(); if ($it->valid()) { - assertType('string|null', $it->current()); - assertType('int|null', $it->key()); + assertType('string', $it->current()); + assertType('int', $it->key()); } } @@ -47,8 +47,8 @@ function testIteratorCurrent(Iterator $it): void function testIteratorAfterValid(Iterator $it): void { if ($it->valid()) { - assertType('string|null', $it->current()); - assertType('int|null', $it->key()); + assertType('string', $it->current()); + assertType('int', $it->key()); } } @@ -72,8 +72,8 @@ function testArrayIteratorCurrent(ArrayIterator $it): void function testArrayIteratorAfterValid(ArrayIterator $it): void { if ($it->valid()) { - assertType('string|null', $it->current()); - assertType('int|null', $it->key()); + assertType('string', $it->current()); + assertType('int', $it->key()); } } @@ -82,8 +82,8 @@ function testGeneratorWhileLoop(): void $it = gen(); $it->rewind(); while ($it->valid()) { - assertType('string|null', $it->current()); - assertType('int|null', $it->key()); + assertType('string', $it->current()); + assertType('int', $it->key()); $it->next(); } } @@ -118,9 +118,9 @@ function testWhileLoopWithValid(): void $it = gen(); while ($it->valid()) { $v = $it->current(); - assertType('string|null', $v); + assertType('string', $v); $k = $it->key(); - assertType('int|null', $k); + assertType('int', $k); $it->next(); } assertType('string|null', $it->current()); @@ -158,8 +158,8 @@ function testIteratorIterator(\IteratorIterator $it): void assertType('string|null', $it->current()); assertType('int|null', $it->key()); if ($it->valid()) { - assertType('string|null', $it->current()); - assertType('int|null', $it->key()); + assertType('string', $it->current()); + assertType('int', $it->key()); } } @@ -169,8 +169,8 @@ function testNoRewindIterator(\NoRewindIterator $it): void assertType('string|null', $it->current()); assertType('int|null', $it->key()); if ($it->valid()) { - assertType('string|null', $it->current()); - assertType('int|null', $it->key()); + assertType('string', $it->current()); + assertType('int', $it->key()); } } @@ -178,8 +178,8 @@ function testNoRewindIterator(\NoRewindIterator $it): void function testNextResetsNarrowing(Iterator $it): void { if ($it->valid()) { - assertType('string|null', $it->current()); - assertType('int|null', $it->key()); + assertType('string', $it->current()); + assertType('int', $it->key()); $it->next(); assertType('string|null', $it->current()); assertType('int|null', $it->key()); @@ -215,3 +215,23 @@ function testNonNullOverride(NonNullIterator $it): void assertType('string', $it->current()); assertType('int', $it->key()); } + +/** @param Iterator $it */ +function testNullInTemplateType(Iterator $it): void +{ + assertType('string|null', $it->current()); + assertType('int|null', $it->key()); + if ($it->valid()) { + assertType('string|null', $it->current()); + assertType('int|null', $it->key()); + } +} + +/** @param Iterator $it */ +function testNullInTemplateTypeForeach(Iterator $it): void +{ + foreach ($it as $key => $value) { + assertType('string|null', $value); + assertType('int|null', $key); + } +}