Skip to content
Open
17 changes: 17 additions & 0 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[] */
Expand Down Expand Up @@ -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;
}

Expand Down
14 changes: 12 additions & 2 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
51 changes: 49 additions & 2 deletions tests/PHPStan/Analyser/nsrt/specified-types-closure-use.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};

Expand All @@ -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);
};

Expand Down Expand Up @@ -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 {
}
18 changes: 18 additions & 0 deletions tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
],
]);
}

}
170 changes: 170 additions & 0 deletions tests/PHPStan/Rules/Arrays/data/bug-10345.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php // lint >= 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<string> */
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<string> */
private array $items = [];

/** @return list<string> */
public function getItems(): array
{
return $this->items;
}

/** @param list<string> $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<string> */
public static array $items = [];

/** @return list<string> */
public static function getItems(): array
{
return self::$items;
}

/** @param list<string> $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;
});
Loading