diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 4ce247488f..5131ab39ec 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -36,6 +36,7 @@ use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Node\VirtualNode; use PHPStan\Parser\ArrayMapArgVisitor; +use PHPStan\Parser\ImmediatelyInvokedClosureVisitor; use PHPStan\Parser\Parser; use PHPStan\Php\PhpVersion; use PHPStan\Php\PhpVersionFactory; @@ -2080,6 +2081,22 @@ public function enterAnonymousFunctionWithoutReflection( } } + if ( + $expr instanceof PropertyFetch + || $expr instanceof MethodCall + || $expr instanceof Expr\NullsafePropertyFetch + || $expr instanceof Expr\NullsafeMethodCall + || $expr instanceof Expr\StaticPropertyFetch + || $expr instanceof Expr\StaticCall + ) { + if ( + $closure->getAttribute(ImmediatelyInvokedClosureVisitor::ATTRIBUTE_NAME) !== true + && $closure->getAttribute(NodeScopeResolver::IMMEDIATELY_CALLED_CALLBACK_ATTRIBUTE_NAME) !== true + ) { + continue; + } + } + $expressionTypes[$exprString] = $typeHolder; } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index ca1daa6f34..787499e4db 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -187,6 +187,8 @@ class NodeScopeResolver private const LOOP_SCOPE_ITERATIONS = 3; private const GENERALIZE_AFTER_ITERATION = 1; + public const IMMEDIATELY_CALLED_CALLBACK_ATTRIBUTE_NAME = 'isImmediatelyCalledCallback'; + /** @var array filePath(string) => bool(true) */ private array $analysedFiles = []; @@ -3332,8 +3334,12 @@ public function processArgs( } $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); + $callImmediately = $this->callCallbackImmediately($parameter, $parameterType, $calleeReflection); + if ($callImmediately) { + $arg->value->setAttribute(self::IMMEDIATELY_CALLED_CALLBACK_ATTRIBUTE_NAME, true); + } $closureResult = $this->processClosureNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context, $parameterType ?? null); - if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { + if ($callImmediately) { $throwPoints = array_merge($throwPoints, array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : InternalThrowPoint::createImplicit($scope, $arg->value), $closureResult->getThrowPoints())); $impurePoints = array_merge($impurePoints, $closureResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $closureResult->isAlwaysTerminating(); diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index 13ca1bdc2e..507aa650f6 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -262,6 +262,7 @@ private static function findTestFiles(): iterable yield __DIR__ . '/../Rules/Properties/data/bug-14012.php'; yield __DIR__ . '/../Rules/Variables/data/bug-14124.php'; yield __DIR__ . '/../Rules/Variables/data/bug-14124b.php'; + yield __DIR__ . '/../Rules/Arrays/data/bug-10345.php'; yield __DIR__ . '/../Rules/Arrays/data/bug-14308.php'; } diff --git a/tests/PHPStan/Analyser/nsrt/specified-types-closure-use.php b/tests/PHPStan/Analyser/nsrt/specified-types-closure-use.php index f5f8189ded..1457c1d0ee 100644 --- a/tests/PHPStan/Analyser/nsrt/specified-types-closure-use.php +++ b/tests/PHPStan/Analyser/nsrt/specified-types-closure-use.php @@ -13,7 +13,7 @@ public function doFoo(MethodCall $call, MethodCall $bar): void { if ($call->name instanceof Identifier && $bar->name instanceof Identifier) { function () use ($call): void { - assertType('PhpParser\Node\Identifier', $call->name); + assertType('PhpParser\Node\Expr|PhpParser\Node\Identifier', $call->name); assertType('mixed', $bar->name); }; @@ -26,7 +26,7 @@ public function doBar(MethodCall $call, MethodCall $bar): void if ($call->name instanceof Identifier && $bar->name instanceof Identifier) { $a = 1; function () use ($call, &$a): void { - assertType('PhpParser\Node\Identifier', $call->name); + assertType('PhpParser\Node\Expr|PhpParser\Node\Identifier', $call->name); assertType('mixed', $bar->name); }; @@ -34,6 +34,24 @@ function () use ($call, &$a): void { } } + public function doImmediatelyInvoked(MethodCall $call): void + { + if ($call->name instanceof Identifier) { + array_map(function () use ($call): void { + assertType('PhpParser\Node\Identifier', $call->name); + }, [1]); + } + } + + public function doIife(MethodCall $call): void + { + if ($call->name instanceof Identifier) { + (function () use ($call): void { + assertType('PhpParser\Node\Identifier', $call->name); + })(); + } + } + public function doBaz(array $arr, string $key): void { $arr[$key] = 'test'; diff --git a/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php b/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php index 227bcc3e4f..8052a81ddb 100644 --- a/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php @@ -60,4 +60,22 @@ public function testBug8056(): void $this->analyse([__DIR__ . '/data/bug-8056.php'], []); } + public function testBug10345(): void + { + $this->analyse([__DIR__ . '/data/bug-10345.php'], [ + [ + 'Empty array passed to foreach.', + 153, + ], + [ + 'Empty array passed to foreach.', + 170, + ], + [ + 'Empty array passed to foreach.', + 185, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-10345.php b/tests/PHPStan/Rules/Arrays/data/bug-10345.php new file mode 100644 index 0000000000..181eeee7c2 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-10345.php @@ -0,0 +1,188 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug10345; + +use function PHPStan\Testing\assertType; + +$container = new \stdClass(); +$container->items = []; + +assertType('stdClass', $container); +assertType('array{}', $container->items); +$func = function() use ($container): int { + assertType('stdClass', $container); + assertType('mixed', $container->items); + foreach ($container->items as $item) { + } + return 1; +}; + +$container->items[] = '1'; + +$a = $func(); + +class Foo { + /** @var list */ + public array $items = []; +} + +$container2 = new Foo(); +$container2->items = []; + +assertType('Bug10345\Foo', $container2); +assertType('array{}', $container2->items); +$func2 = function() use ($container2): int { + assertType('Bug10345\Foo', $container2); + assertType('list', $container2->items); + foreach ($container2->items as $item) {} + return 1; +}; + +$container2->items[] = '1'; + +$a2 = $func2(); + +class Bar { + /** @var list */ + private array $items = []; + + /** @return list */ + public function getItems(): array + { + return $this->items; + } + + /** @param list $items */ + public function setItems(array $items): void + { + $this->items = $items; + } +} + +$container3 = new Bar(); +if ($container3->getItems() === []) { + assertType('Bug10345\Bar', $container3); + assertType('array{}', $container3->getItems()); + $func3 = function() use ($container3): int { + assertType('Bug10345\Bar', $container3); + assertType('list', $container3->getItems()); + foreach ($container3->getItems() as $item) {} + return 1; + }; + + $container3->setItems(['foo']); + + $a3 = $func3(); +} + +// Nullsafe property fetch +$container4 = new Foo(); +$container4->items = []; + +assertType('Bug10345\Foo', $container4); +assertType('array{}', $container4->items); +$func4 = function() use ($container4): int { + assertType('Bug10345\Foo', $container4); + assertType('list', $container4->items); + foreach ($container4?->items as $item) {} + return 1; +}; + +$container4->items[] = '1'; + +$a4 = $func4(); + +// Static property access +class Baz { + /** @var list */ + public static array $items = []; + + /** @return list */ + public static function getItems(): array + { + return self::$items; + } + + /** @param list $items */ + public static function setItems(array $items): void + { + self::$items = $items; + } +} + +Baz::$items = []; + +assertType('array{}', Baz::$items); +$func5 = function(): int { + assertType('list', Baz::$items); + foreach (Baz::$items as $item) {} + return 1; +}; + +Baz::$items[] = '1'; + +$a5 = $func5(); + +// Static method call +Baz::setItems([]); +if (Baz::getItems() === []) { + assertType('array{}', Baz::getItems()); + $func6 = function(): int { + assertType('list', Baz::getItems()); + foreach (Baz::getItems() as $item) {} + return 1; + }; + + Baz::setItems(['foo']); + + $a6 = $func6(); +} + +// Immediately invoked closure should keep the type +$container7 = new \stdClass(); +$container7->items = []; + +assertType('stdClass', $container7); +assertType('array{}', $container7->items); +$result7 = array_map( + function() use ($container7): int { + assertType('stdClass', $container7); + assertType('array{}', $container7->items); + foreach ($container7->items as $item) { + } + return 1; + }, + [1, 2, 3], +); + +// Immediately invoked closure with declared property should also keep the type +$container8 = new Foo(); +$container8->items = []; + +assertType('Bug10345\Foo', $container8); +assertType('array{}', $container8->items); +$result8 = array_map( + function() use ($container8): int { + assertType('Bug10345\Foo', $container8); + assertType('array{}', $container8->items); + foreach ($container8->items as $item) {} + return 1; + }, + [1, 2, 3], +); + +// IIFE should keep the type +$container9 = new \stdClass(); +$container9->items = []; + +assertType('stdClass', $container9); +assertType('array{}', $container9->items); +$result9 = (function() use ($container9): int { + assertType('stdClass', $container9); + assertType('array{}', $container9->items); + foreach ($container9->items as $item) { + } + return 1; +})();