Skip to content

Commit 66f2738

Browse files
committed
Fix closure type inference bug related to FNSR
See #4807 Closes phpstan/phpstan#13993
1 parent 97c3588 commit 66f2738

5 files changed

Lines changed: 242 additions & 7 deletions

File tree

src/Analyser/Fiber/FiberScope.php

Lines changed: 16 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,25 @@ 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+
/** @var self $scope */
145+
$scope = parent::pushInFunctionCall($reflection, $parameter, $rememberTypes);
146+
$scope->truthyValueExprs = $this->truthyValueExprs;
147+
$scope->falseyValueExprs = $this->falseyValueExprs;
148+
149+
return $scope;
145150
}
146151

147152
public function popInFunctionCall(): self
148153
{
149-
// no need to track this in rules, the type will be correct anyway
150-
return $this;
154+
$stack = $this->inFunctionCallsStack;
155+
array_pop($stack);
156+
157+
/** @var self $scope */
158+
$scope = parent::popInFunctionCall();
159+
$scope->truthyValueExprs = $this->truthyValueExprs;
160+
$scope->falseyValueExprs = $this->falseyValueExprs;
161+
162+
return $scope;
151163
}
152164

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

tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3761,4 +3761,25 @@ public function testBug12735(): void
37613761
$this->analyse([__DIR__ . '/data/bug-12735.php'], []);
37623762
}
37633763

3764+
public function testBug13993(): void
3765+
{
3766+
$this->checkThisOnly = false;
3767+
$this->checkNullables = true;
3768+
$this->checkUnionTypes = true;
3769+
$this->checkExplicitMixed = true;
3770+
$this->checkImplicitMixed = true;
3771+
$this->analyse([__DIR__ . '/data/bug-13993.php'], []);
3772+
}
3773+
3774+
#[RequiresPhp('>= 8.1')]
3775+
public function testBug13993b(): void
3776+
{
3777+
$this->checkThisOnly = false;
3778+
$this->checkNullables = true;
3779+
$this->checkUnionTypes = true;
3780+
$this->checkExplicitMixed = true;
3781+
$this->checkImplicitMixed = true;
3782+
$this->analyse([__DIR__ . '/../../Analyser/nsrt/closure-passed-to-type-fiberscope-php81.php'], []);
3783+
}
3784+
37643785
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
namespace Bug13993;
4+
5+
use DateTimeImmutable;
6+
7+
/**
8+
* @template TData
9+
*/
10+
class NightIntervalDataSet
11+
{
12+
/**
13+
* @param list<NightIntervalData<TData>> $intervals
14+
*/
15+
final public function __construct(private array $intervals) {}
16+
17+
/**
18+
* @template TInput
19+
* @param iterable<TInput> $inputs
20+
* @param callable(TInput): array{0: DateTimeImmutable, 1: DateTimeImmutable} $mapper
21+
* @param callable(TData, TInput): TData $reducer
22+
* @return self<TData>
23+
*/
24+
public function modifyDataByStream(iterable $inputs, callable $mapper, callable $reducer): self
25+
{
26+
return new static(array_values($this->intervals));
27+
}
28+
29+
}
30+
31+
/**
32+
* @template TData
33+
*/
34+
class NightIntervalData
35+
{
36+
37+
/**
38+
* @param TData $data
39+
*/
40+
final public function __construct(public DateTimeImmutable $start, public DateTimeImmutable $end, public $data) {}
41+
42+
}
43+
44+
interface Reservation
45+
{
46+
function getStart(): DateTimeImmutable;
47+
function getEnd(): DateTimeImmutable;
48+
function getRoomType(): ?object;
49+
50+
}
51+
52+
/** @var list<Reservation> */
53+
$reservations = [];
54+
$set = new NightIntervalDataSet([new NightIntervalData(
55+
new DateTimeImmutable('2017-01-01'),
56+
new DateTimeImmutable('2017-02-28'),
57+
['roomTypeId' => 'xxx', 'capacity' => 1],
58+
)]);
59+
$set->modifyDataByStream(
60+
$reservations,
61+
static fn (Reservation $occupation): array => [$occupation->getStart(), $occupation->getEnd()],
62+
static function (array $capacityData, Reservation $reservation): array {
63+
if ($reservation->getRoomType() === null) {
64+
$capacityData['capacity'] = max(0, $capacityData['capacity'] - 1);
65+
66+
return $capacityData;
67+
}
68+
69+
return $capacityData;
70+
},
71+
);
72+

0 commit comments

Comments
 (0)