Skip to content

Commit e13aa2a

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 e13aa2a

File tree

3 files changed

+212
-7
lines changed

3 files changed

+212
-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: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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+
* @template T
15+
*/
16+
class Vote
17+
{
18+
/**
19+
* @param T $subject
20+
*/
21+
public function __construct(
22+
public bool $granted,
23+
public mixed $subject,
24+
) {}
25+
}
26+
27+
/**
28+
* @template TSubject
29+
*/
30+
class Decision
31+
{
32+
/**
33+
* @param list<Vote<TSubject>> $votes
34+
*/
35+
public function __construct(
36+
private array $votes,
37+
) {}
38+
39+
/**
40+
* @template U
41+
* @template K of array-key
42+
*
43+
* @param callable(Vote<TSubject> $vote): iterable<K, U> $fn
44+
*
45+
* @return array<K, U>
46+
*/
47+
public function collect(callable $fn): array
48+
{
49+
$result = [];
50+
foreach ($this->votes as $vote) {
51+
foreach ($fn($vote) as $key => $value) {
52+
$result[$key] = $value;
53+
}
54+
}
55+
return $result;
56+
}
57+
}
58+
59+
class Subject
60+
{
61+
public function id(): int
62+
{
63+
return 42;
64+
}
65+
}
66+
67+
/**
68+
* Test: Generic callable parameter resolution
69+
* When passing a closure to Decision<Subject>::collect(),
70+
* the Vote parameter should be inferred as Vote<Subject>.
71+
*/
72+
function testGenericCallableParameterResolution(): void
73+
{
74+
$decision = new Decision([new Vote(granted: true, subject: new Subject())]);
75+
$result = $decision->collect(static function (Vote $vote): iterable {
76+
assertType('ClosurePassedToTypeFiberScope\Vote<ClosurePassedToTypeFiberScope\Subject>', $vote);
77+
assertType('ClosurePassedToTypeFiberScope\Subject', $vote->subject);
78+
if ($vote->granted) {
79+
yield $vote->subject->id() => $vote->subject;
80+
}
81+
});
82+
assertType('array<int, ClosurePassedToTypeFiberScope\Subject>', $result);
83+
}
84+
85+
/**
86+
* @template T
87+
*/
88+
class Vote
89+
{
90+
/**
91+
* @param T $subject
92+
*/
93+
public function __construct(
94+
public bool $granted,
95+
public mixed $subject,
96+
) {}
97+
}
98+
99+
/**
100+
* @template TSubject
101+
*/
102+
class Decision
103+
{
104+
/**
105+
* @param list<Vote<TSubject>> $votes
106+
*/
107+
public function __construct(
108+
private array $votes,
109+
) {}
110+
111+
/**
112+
* @template U
113+
* @template K of array-key
114+
*
115+
* @param callable(Vote<TSubject> $vote): iterable<K, U> $fn
116+
*
117+
* @return array<K, U>
118+
*/
119+
public function collect(callable $fn): array
120+
{
121+
$result = [];
122+
foreach ($this->votes as $vote) {
123+
foreach ($fn($vote) as $key => $value) {
124+
$result[$key] = $value;
125+
}
126+
}
127+
return $result;
128+
}
129+
}
130+
131+
class Subject
132+
{
133+
public function id(): int
134+
{
135+
return 42;
136+
}
137+
}
138+
139+
/**
140+
* Test 2: Generic callable parameter resolution
141+
* When passing a closure to Decision<Subject>::collect(),
142+
* the Vote parameter should be inferred as Vote<Subject>.
143+
*/
144+
function testGenericCallableParameterResolution(): void
145+
{
146+
$decision = new Decision([new Vote(granted: true, subject: new Subject())]);
147+
$result = $decision->collect(static function (Vote $vote): iterable {
148+
assertType('ClosurePassedToTypeFiberScope\Vote<ClosurePassedToTypeFiberScope\Subject>', $vote);
149+
assertType('ClosurePassedToTypeFiberScope\Subject', $vote->subject);
150+
if ($vote->granted) {
151+
yield $vote->subject->id() => $vote->subject;
152+
}
153+
});
154+
assertType('array<int, ClosurePassedToTypeFiberScope\Subject>', $result);
155+
}

0 commit comments

Comments
 (0)