Skip to content

Commit ef0221a

Browse files
gnutixclaude
andcommitted
Fix closure type inference in FiberScope
FiberScope::pushInFunctionCall() and popInFunctionCall() were no-ops, which prevented the inFunctionCallsStack from being populated when using FNSR (Fiber Node Scope Resolver). This caused closure parameter types to not be properly inferred from the expected callable type when a closure is passed as an argument. The fix implements proper function call stack tracking in FiberScope and removes the FiberScope exclusion in ParametersAcceptorSelector. Fixes #13993 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8301dbb commit ef0221a

File tree

3 files changed

+190
-7
lines changed

3 files changed

+190
-7
lines changed

src/Analyser/Fiber/FiberScope.php

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use PHPStan\Reflection\MethodReflection;
1111
use PHPStan\Reflection\ParameterReflection;
1212
use PHPStan\Type\Type;
13+
use function array_pop;
1314

1415
final class FiberScope extends MutatingScope
1516
{
@@ -140,14 +141,64 @@ private function preprocessScope(MutatingScope $scope): Scope
140141
*/
141142
public function pushInFunctionCall($reflection, ?ParameterReflection $parameter, bool $rememberTypes): self
142143
{
143-
// no need to track this in rules, the type will be correct anyway
144-
return $this;
144+
// Track the function call stack so closure types can be properly inferred
145+
// when the closure is passed as a callable argument
146+
$stack = $this->inFunctionCallsStack;
147+
$stack[] = [$reflection, $parameter];
148+
149+
/** @var self $scope */
150+
$scope = $this->scopeFactory->create(
151+
$this->context,
152+
$this->isDeclareStrictTypes(),
153+
$this->getFunction(),
154+
$this->getNamespace(),
155+
$this->expressionTypes,
156+
$this->nativeExpressionTypes,
157+
$this->conditionalExpressions,
158+
$this->inClosureBindScopeClasses,
159+
$this->getAnonymousFunctionReflection(),
160+
$this->isInFirstLevelStatement(),
161+
$this->currentlyAssignedExpressions,
162+
$this->currentlyAllowedUndefinedExpressions,
163+
$stack,
164+
$this->afterExtractCall,
165+
parent::getParentScope(),
166+
$this->nativeTypesPromoted,
167+
);
168+
$scope->truthyValueExprs = $this->truthyValueExprs;
169+
$scope->falseyValueExprs = $this->falseyValueExprs;
170+
171+
return $scope;
145172
}
146173

147174
public function popInFunctionCall(): self
148175
{
149-
// no need to track this in rules, the type will be correct anyway
150-
return $this;
176+
$stack = $this->inFunctionCallsStack;
177+
array_pop($stack);
178+
179+
/** @var self $scope */
180+
$scope = $this->scopeFactory->create(
181+
$this->context,
182+
$this->isDeclareStrictTypes(),
183+
$this->getFunction(),
184+
$this->getNamespace(),
185+
$this->expressionTypes,
186+
$this->nativeExpressionTypes,
187+
$this->conditionalExpressions,
188+
$this->inClosureBindScopeClasses,
189+
$this->getAnonymousFunctionReflection(),
190+
$this->isInFirstLevelStatement(),
191+
$this->currentlyAssignedExpressions,
192+
$this->currentlyAllowedUndefinedExpressions,
193+
$stack,
194+
$this->afterExtractCall,
195+
parent::getParentScope(),
196+
$this->nativeTypesPromoted,
197+
);
198+
$scope->truthyValueExprs = $this->truthyValueExprs;
199+
$scope->falseyValueExprs = $this->falseyValueExprs;
200+
201+
return $scope;
151202
}
152203

153204
public function getParentScope(): ?MutatingScope

src/Reflection/ParametersAcceptorSelector.php

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
use Closure;
66
use PhpParser\Node;
77
use PHPStan\Analyser\ArgumentsNormalizer;
8-
use PHPStan\Analyser\Fiber\FiberScope;
98
use PHPStan\Analyser\MutatingScope;
109
use PHPStan\Analyser\Scope;
1110
use PHPStan\Node\Expr\ParameterVariableOriginalValueExpr;
@@ -498,14 +497,14 @@ public static function selectFromArgs(
498497
}
499498
}
500499

501-
if ($parameter !== null && $scope instanceof MutatingScope && !$scope instanceof FiberScope) {
500+
if ($parameter !== null && $scope instanceof MutatingScope) {
502501
$rememberTypes = !$originalArg->value instanceof Node\Expr\Closure && !$originalArg->value instanceof Node\Expr\ArrowFunction;
503502
$scope = $scope->pushInFunctionCall(null, $parameter, $rememberTypes);
504503
}
505504

506505
$type = $scope->getType($originalArg->value);
507506

508-
if ($parameter !== null && $scope instanceof MutatingScope && !$scope instanceof FiberScope) {
507+
if ($parameter !== null && $scope instanceof MutatingScope) {
509508
$scope = $scope->popInFunctionCall();
510509
}
511510

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<?php
2+
3+
namespace ClosurePassedToTypeFiberScope;
4+
5+
use Closure;
6+
use function PHPStan\Testing\assertType;
7+
8+
/**
9+
* Regression tests for closure parameter type inference in FiberScope.
10+
* @see https://github.com/phpstan/phpstan/issues/13993
11+
*/
12+
13+
// ============================================================================
14+
// Example 1: Closure parameter inference with array destructuring
15+
// ============================================================================
16+
17+
class DateRange
18+
{
19+
public function format(): string
20+
{
21+
return '2024-01-01';
22+
}
23+
}
24+
25+
class Context {}
26+
27+
class Loader
28+
{
29+
/**
30+
* @param Closure(Context, non-empty-array<array{DateRange, list<int>}>): iterable<array{int, string}, string> $loader
31+
*/
32+
public function __construct(
33+
private Closure $loader,
34+
) {}
35+
}
36+
37+
/**
38+
* Test: Closure parameter inference with array destructuring in constructor
39+
* When a closure is passed to a constructor, the parameter types should be
40+
* inferred from the expected Closure type, including array destructuring.
41+
*/
42+
function testClosureParameterInferenceWithDestructuring(): void
43+
{
44+
$loader = new Loader(
45+
loader: function (Context $context, array $items): iterable {
46+
assertType('non-empty-array<array{ClosurePassedToTypeFiberScope\DateRange, list<int>}>', $items);
47+
foreach ($items as [$dateRange, $ids]) {
48+
assertType('ClosurePassedToTypeFiberScope\DateRange', $dateRange);
49+
assertType('list<int>', $ids);
50+
foreach ($ids as $id) {
51+
assertType('int', $id);
52+
yield [$id, $dateRange->format()] => 'value';
53+
}
54+
}
55+
},
56+
);
57+
}
58+
59+
// ============================================================================
60+
// Example 2: Generic callable parameter resolution
61+
// ============================================================================
62+
63+
/**
64+
* @template T
65+
*/
66+
class Vote
67+
{
68+
/**
69+
* @param T $subject
70+
*/
71+
public function __construct(
72+
public bool $granted,
73+
public mixed $subject,
74+
) {}
75+
}
76+
77+
/**
78+
* @template TSubject
79+
*/
80+
class Decision
81+
{
82+
/**
83+
* @param list<Vote<TSubject>> $votes
84+
*/
85+
public function __construct(
86+
private array $votes,
87+
) {}
88+
89+
/**
90+
* @template U
91+
* @template K of array-key
92+
*
93+
* @param callable(Vote<TSubject> $vote): iterable<K, U> $fn
94+
*
95+
* @return array<K, U>
96+
*/
97+
public function collect(callable $fn): array
98+
{
99+
$result = [];
100+
foreach ($this->votes as $vote) {
101+
foreach ($fn($vote) as $key => $value) {
102+
$result[$key] = $value;
103+
}
104+
}
105+
return $result;
106+
}
107+
}
108+
109+
class Subject
110+
{
111+
public function id(): int
112+
{
113+
return 42;
114+
}
115+
}
116+
117+
/**
118+
* Test: Generic callable parameter resolution
119+
* When passing a closure to Decision<Subject>::collect(),
120+
* the Vote parameter should be inferred as Vote<Subject>.
121+
*/
122+
function testGenericCallableParameterResolution(): void
123+
{
124+
$decision = new Decision([new Vote(granted: true, subject: new Subject())]);
125+
$result = $decision->collect(static function (Vote $vote): iterable {
126+
assertType('ClosurePassedToTypeFiberScope\Vote<ClosurePassedToTypeFiberScope\Subject>', $vote);
127+
assertType('ClosurePassedToTypeFiberScope\Subject', $vote->subject);
128+
if ($vote->granted) {
129+
yield $vote->subject->id() => $vote->subject;
130+
}
131+
});
132+
assertType('array<int, ClosurePassedToTypeFiberScope\Subject>', $result);
133+
}

0 commit comments

Comments
 (0)