Skip to content

Commit a958dd2

Browse files
phpstan-botclaude
andcommitted
Preserve property fetch types in immediately invoked closures
When a closure is immediately invoked (IIFE or callback passed to functions like array_map), property fetch types should be carried forward into the closure scope since the closure runs before properties can be modified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2610337 commit a958dd2

5 files changed

Lines changed: 93 additions & 3 deletions

File tree

src/Analyser/MutatingScope.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
use PHPStan\Node\Printer\ExprPrinter;
3737
use PHPStan\Node\VirtualNode;
3838
use PHPStan\Parser\ArrayMapArgVisitor;
39+
use PHPStan\Parser\ImmediatelyInvokedClosureVisitor;
3940
use PHPStan\Parser\Parser;
4041
use PHPStan\Php\PhpVersion;
4142
use PHPStan\Php\PhpVersionFactory;
@@ -2088,7 +2089,12 @@ public function enterAnonymousFunctionWithoutReflection(
20882089
|| $expr instanceof Expr\StaticPropertyFetch
20892090
|| $expr instanceof Expr\StaticCall
20902091
) {
2091-
continue;
2092+
if (
2093+
$closure->getAttribute(ImmediatelyInvokedClosureVisitor::ATTRIBUTE_NAME) !== true
2094+
&& $closure->getAttribute(NodeScopeResolver::IMMEDIATELY_CALLED_CALLBACK_ATTRIBUTE_NAME) !== true
2095+
) {
2096+
continue;
2097+
}
20922098
}
20932099

20942100
$expressionTypes[$exprString] = $typeHolder;

src/Analyser/NodeScopeResolver.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ class NodeScopeResolver
187187
private const LOOP_SCOPE_ITERATIONS = 3;
188188
private const GENERALIZE_AFTER_ITERATION = 1;
189189

190+
public const IMMEDIATELY_CALLED_CALLBACK_ATTRIBUTE_NAME = 'isImmediatelyCalledCallback';
191+
190192
/** @var array<string, true> filePath(string) => bool(true) */
191193
private array $analysedFiles = [];
192194

@@ -3332,8 +3334,12 @@ public function processArgs(
33323334
}
33333335

33343336
$this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context);
3337+
$callImmediately = $this->callCallbackImmediately($parameter, $parameterType, $calleeReflection);
3338+
if ($callImmediately) {
3339+
$arg->value->setAttribute(self::IMMEDIATELY_CALLED_CALLBACK_ATTRIBUTE_NAME, true);
3340+
}
33353341
$closureResult = $this->processClosureNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context, $parameterType ?? null);
3336-
if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) {
3342+
if ($callImmediately) {
33373343
$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()));
33383344
$impurePoints = array_merge($impurePoints, $closureResult->getImpurePoints());
33393345
$isAlwaysTerminating = $isAlwaysTerminating || $closureResult->isAlwaysTerminating();

tests/PHPStan/Analyser/nsrt/specified-types-closure-use.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,24 @@ function () use ($call, &$a): void {
3434
}
3535
}
3636

37+
public function doImmediatelyInvoked(MethodCall $call): void
38+
{
39+
if ($call->name instanceof Identifier) {
40+
array_map(function () use ($call): void {
41+
assertType('PhpParser\Node\Identifier', $call->name);
42+
}, [1]);
43+
}
44+
}
45+
46+
public function doIife(MethodCall $call): void
47+
{
48+
if ($call->name instanceof Identifier) {
49+
(function () use ($call): void {
50+
assertType('PhpParser\Node\Identifier', $call->name);
51+
})();
52+
}
53+
}
54+
3755
public function doBaz(array $arr, string $key): void
3856
{
3957
$arr[$key] = 'test';

tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,20 @@ public function testBug8056(): void
6262

6363
public function testBug10345(): void
6464
{
65-
$this->analyse([__DIR__ . '/data/bug-10345.php'], []);
65+
$this->analyse([__DIR__ . '/data/bug-10345.php'], [
66+
[
67+
'Empty array passed to foreach.',
68+
153,
69+
],
70+
[
71+
'Empty array passed to foreach.',
72+
170,
73+
],
74+
[
75+
'Empty array passed to foreach.',
76+
185,
77+
],
78+
]);
6679
}
6780

6881
}

tests/PHPStan/Rules/Arrays/data/bug-10345.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,50 @@ public static function setItems(array $items): void
139139

140140
$a6 = $func6();
141141
}
142+
143+
// Immediately invoked closure should keep the type
144+
$container7 = new \stdClass();
145+
$container7->items = [];
146+
147+
assertType('stdClass', $container7);
148+
assertType('array{}', $container7->items);
149+
$result7 = array_map(
150+
function() use ($container7): int {
151+
assertType('stdClass', $container7);
152+
assertType('array{}', $container7->items);
153+
foreach ($container7->items as $item) {
154+
}
155+
return 1;
156+
},
157+
[1, 2, 3],
158+
);
159+
160+
// Immediately invoked closure with declared property should also keep the type
161+
$container8 = new Foo();
162+
$container8->items = [];
163+
164+
assertType('Bug10345\Foo', $container8);
165+
assertType('array{}', $container8->items);
166+
$result8 = array_map(
167+
function() use ($container8): int {
168+
assertType('Bug10345\Foo', $container8);
169+
assertType('array{}', $container8->items);
170+
foreach ($container8->items as $item) {}
171+
return 1;
172+
},
173+
[1, 2, 3],
174+
);
175+
176+
// IIFE should keep the type
177+
$container9 = new \stdClass();
178+
$container9->items = [];
179+
180+
assertType('stdClass', $container9);
181+
assertType('array{}', $container9->items);
182+
$result9 = (function() use ($container9): int {
183+
assertType('stdClass', $container9);
184+
assertType('array{}', $container9->items);
185+
foreach ($container9->items as $item) {
186+
}
187+
return 1;
188+
})();

0 commit comments

Comments
 (0)