diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index f1dfb1f0da..662bcb9019 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -44,6 +44,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; @@ -145,6 +146,7 @@ class MutatingScope implements Scope, NodeCallbackInvoker { public const KEEP_VOID_ATTRIBUTE_NAME = 'keepVoid'; + public const CALL_CALLBACK_IMMEDIATELY_ATTRIBUTE_NAME = 'callCallbackImmediately'; private const CONTAINS_SUPER_GLOBAL_ATTRIBUTE_NAME = 'containsSuperGlobal'; /** @var Type[] */ @@ -2152,6 +2154,21 @@ public function enterAnonymousFunctionWithoutReflection( } } + if ( + $closure->getAttribute(ImmediatelyInvokedClosureVisitor::ATTRIBUTE_NAME) !== true + && $closure->getAttribute(self::CALL_CALLBACK_IMMEDIATELY_ATTRIBUTE_NAME) !== true + && ( + $expr instanceof PropertyFetch + || $expr instanceof MethodCall + || $expr instanceof Expr\NullsafePropertyFetch + || $expr instanceof Expr\NullsafeMethodCall + || $expr instanceof Expr\StaticPropertyFetch + || $expr instanceof Expr\StaticCall + ) + ) { + continue; + } + $expressionTypes[$exprString] = $typeHolder; } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index d6c6832f85..25f9683411 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3406,9 +3406,14 @@ public function processArgs( } } + $callCallbackImmediately = $this->callCallbackImmediately($parameter, $parameterType, $calleeReflection); + if ($callCallbackImmediately) { + $arg->value->setAttribute(MutatingScope::CALL_CALLBACK_IMMEDIATELY_ATTRIBUTE_NAME, true); + } + $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); $closureResult = $this->processClosureNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context, $parameterType ?? null); - if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { + if ($callCallbackImmediately) { $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(); @@ -3464,9 +3469,14 @@ public function processArgs( } } + $callCallbackImmediately = $this->callCallbackImmediately($parameter, $parameterType, $calleeReflection); + if ($callCallbackImmediately) { + $arg->value->setAttribute(MutatingScope::CALL_CALLBACK_IMMEDIATELY_ATTRIBUTE_NAME, true); + } + $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); $arrowFunctionResult = $this->processArrowFunctionNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $parameterType ?? null); - if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { + if ($callCallbackImmediately) { $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), $arrowFunctionResult->getThrowPoints())); $impurePoints = array_merge($impurePoints, $arrowFunctionResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $arrowFunctionResult->isAlwaysTerminating(); diff --git a/tests/PHPStan/Analyser/nsrt/specified-types-closure-use.php b/tests/PHPStan/Analyser/nsrt/specified-types-closure-use.php index 9cd49e4522..6df9945f93 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); }; @@ -69,4 +69,51 @@ function ($key) use ($arr): void { } } + public function doIife(MethodCall $call): void + { + if ($call->name instanceof Identifier) { + // IIFE - property types should be carried forward + (function () use ($call): void { + assertType('PhpParser\Node\Identifier', $call->name); + })(); + } + } + + public function doArrayMap(MethodCall $call): void + { + if ($call->name instanceof Identifier) { + // array_map - closure is immediately invoked, property types should be carried forward + array_map(function () use ($call): void { + assertType('PhpParser\Node\Identifier', $call->name); + }, [1]); + } + } + + public function doGenericFunctionCall(MethodCall $call): void + { + if ($call->name instanceof Identifier) { + // Generic function with callable parameter - immediately invoked by default + usort([1], function () use ($call): int { + assertType('PhpParser\Node\Identifier', $call->name); + return 0; + }); + } + } + + public function doLaterInvokedCallable(MethodCall $call): void + { + if ($call->name instanceof Identifier) { + // @param-later-invoked-callable - property types should NOT be carried forward + laterInvoke(function () use ($call): void { + assertType('PhpParser\Node\Expr|PhpParser\Node\Identifier', $call->name); + }); + } + } + +} + +/** + * @param-later-invoked-callable $callback + */ +function laterInvoke(callable $callback): void { } diff --git a/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php b/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php index 9e272b5802..4767783a60 100644 --- a/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php @@ -55,4 +55,22 @@ public function testBug2457(): void $this->analyse([__DIR__ . '/data/bug-2457.php'], []); } + public function testBug10345(): void + { + $this->analyse([__DIR__ . '/data/bug-10345.php'], [ + [ + 'Empty array passed to foreach.', + 125, + ], + [ + 'Empty array passed to foreach.', + 134, + ], + [ + 'Empty array passed to foreach.', + 152, + ], + ]); + } + } 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..c71f426e9e --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-10345.php @@ -0,0 +1,170 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug10345; + +$container = new \stdClass(); +$container->items = []; + +$func = function() use ($container): int { + foreach ($container->items as $item) {} + return 1; +}; + +$container->items[] = '1'; + +$a = $func(); + +class Foo { + /** @var list */ + public array $items = []; +} + +$container2 = new Foo(); +$container2->items = []; + +$func2 = function() use ($container2): int { + 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() === []) { + $func3 = function() use ($container3): int { + foreach ($container3->getItems() as $item) {} + return 1; + }; + + $container3->setItems(['foo']); + + $a3 = $func3(); +} + +// Nullsafe property fetch +$container4 = new Foo(); +$container4->items = []; + +$func4 = function() use ($container4): int { + 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 = []; + +$func5 = function(): int { + foreach (Baz::$items as $item) {} + return 1; +}; + +Baz::$items[] = '1'; + +$a5 = $func5(); + +// Static method call +Baz::setItems([]); +if (Baz::getItems() === []) { + $func6 = function(): int { + foreach (Baz::getItems() as $item) {} + return 1; + }; + + Baz::setItems(['foo']); + + $a6 = $func6(); +} + +// Immediately invoked closure (IIFE) - should still detect empty array +$container7 = new \stdClass(); +$container7->items = []; + +$result7 = (function() use ($container7): int { + foreach ($container7->items as $item) {} + return 1; +})(); + +// array_map - closure is immediately invoked, should still detect empty array +$container8 = new \stdClass(); +$container8->items = []; + +$result8 = array_map(function() use ($container8): int { + foreach ($container8->items as $item) {} + return 1; +}, [1]); + +// Generic function with callable parameter - immediately invoked by default +/** + * @template T + * @param callable(): T $callback + * @return T + */ +function invoke(callable $callback): mixed { + return $callback(); +} + +$container9 = new \stdClass(); +$container9->items = []; + +$result9 = invoke(function() use ($container9): int { + foreach ($container9->items as $item) {} // should report error - immediately invoked + return 1; +}); + +// Function with @param-later-invoked-callable - should NOT report error +/** + * @param-later-invoked-callable $callback + */ +function invokeLater(callable $callback): void { + // stores callback for later +} + +$container10 = new \stdClass(); +$container10->items = []; + +invokeLater(function() use ($container10): int { + foreach ($container10->items as $item) {} // should NOT report error - later invoked + return 1; +});