Skip to content

Commit 7a8c43c

Browse files
phpstan-botondrejmirtes
authored andcommitted
Resolve generic parameter types from an array literal argument's skeleton before typing the closures nested in it
- `ArgumentsHandler::processArgs()` now pins a structural skeleton for an array-literal argument that holds closures, before the argument is walked, so the per-argument acceptor resolution can infer the callee's templates from the array's own keys and non-closure values instead of padding the whole argument with `mixed` and falling back to the templates' bounds. - New `ArgumentsHandler::gatherArrayArgTypeSkeleton()` builds that skeleton without walking anything: nested array literals recurse, closures/arrow functions contribute `ClosureTypeResolver::getDeclaredClosureType()`, and every other key/value is priced from scope state, then from `InitializerExprTypeResolver` (literals, `::class`, constants, concatenation), and finally `mixed`. A `mixed` slot can never resolve a template below its bound, so the skeleton only ever sharpens the resolution. - The skeleton never reaches `$gatheredTypes`; the argument's real type replaces it once the walk is done, so the call's return type and the reported argument type are unchanged. - The same code path covers the analogous cases, each with its own assertion in the regression test: templates pinned by a sibling *value* of the array (in either order), nested array literals, `callable(T)` instead of `Closure(T)`, `class-string<T>` keys, variadic array parameters, named arguments, and methods / static methods / constructors.
1 parent dfe44f6 commit 7a8c43c

2 files changed

Lines changed: 234 additions & 0 deletions

File tree

src/Analyser/ArgumentsHandler.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
use PHPStan\Reflection\ExtendedMethodReflection;
3131
use PHPStan\Reflection\ExtendedParameterReflection;
3232
use PHPStan\Reflection\FunctionReflection;
33+
use PHPStan\Reflection\InitializerExprContext;
34+
use PHPStan\Reflection\InitializerExprTypeResolver;
3335
use PHPStan\Reflection\MethodReflection;
3436
use PHPStan\Reflection\ParameterReflection;
3537
use PHPStan\Reflection\ParametersAcceptor;
@@ -119,6 +121,7 @@ public function __construct(
119121
private AssignHandler $assignHandler,
120122
private ClosureTypeResolver $closureTypeResolver,
121123
private ClosureParameterResolver $closureParameterResolver,
124+
private InitializerExprTypeResolver $initializerExprTypeResolver,
122125
)
123126
{
124127
}
@@ -235,6 +238,19 @@ public function processArgs(
235238
? $this->gatherClosureArgType($parametersAcceptors, $i, $arg->value, $scope)
236239
: $this->closureTypeResolver->getClosureType($scope, $arg->value, true, $storage);
237240
$this->addGatheredArgType($gatheredTypes, $gatheredUnpack, $gatheredHasName, $originalArgForGather, $i, $gatheredArgTypeByIndex[$i]);
241+
} elseif (
242+
$argMetadataIsTypeDriven
243+
&& !$arg->unpack
244+
&& $arg->value instanceof Expr\Array_
245+
&& $this->argConsumesResolvedParameterType($arg->value)
246+
) {
247+
// An array literal holding closures decides the templates of its own
248+
// parameter through its keys and its non-closure values - and those
249+
// very templates type the closures nested in it. Pin a SKELETON of the
250+
// array (declared closure signatures, scope-known leaves, mixed for
251+
// anything that needs a walk) so the per-argument resolution below sees
252+
// them; the walk that follows overwrites it with the real type.
253+
$gatheredArgTypeByIndex[$i] = $this->gatherArrayArgTypeSkeleton($nodeScopeResolver, $arg->value, $scope);
238254
}
239255

240256
$argMetadataAcceptor = $metadataAcceptor;
@@ -938,6 +954,40 @@ private function gatherClosureArgType(array $parametersAcceptors, int $i, Expr $
938954
return $this->closureParameterResolver->resolveCallableTypeForScope($closureExpr, $scope);
939955
}
940956

957+
/**
958+
* A structural stand-in for an array literal argument that holds closures,
959+
* built without walking anything: a nested array literal recurses, a closure /
960+
* arrow function contributes its DECLARED signature
961+
* (ClosureTypeResolver::getDeclaredClosureType()), and every other key/value
962+
* is priced by the scope state it is already tracked as, falling back to the
963+
* constant-expression resolver (literals, ::class, constants, concatenation)
964+
* and ultimately to mixed. Widening a slot to mixed is safe: a template is
965+
* never resolved below its bound, so the skeleton can only ever sharpen the
966+
* resolution.
967+
*
968+
* Unlike gatherClosureArgType() this type never reaches $gatheredTypes: it
969+
* exists solely to resolve the parameter type the nested closures are typed
970+
* from. The argument's real type replaces it once the walk is done.
971+
*/
972+
private function gatherArrayArgTypeSkeleton(NodeScopeResolver $nodeScopeResolver, Expr\Array_ $expr, MutatingScope $scope): Type
973+
{
974+
$initializerContext = InitializerExprContext::fromScope($scope);
975+
$getType = function (Expr $inner) use (&$getType, $nodeScopeResolver, $scope, $initializerContext): Type {
976+
if ($inner instanceof Expr\Closure || $inner instanceof Expr\ArrowFunction) {
977+
return $this->closureTypeResolver->getDeclaredClosureType($scope, $inner);
978+
}
979+
980+
if ($inner instanceof Expr\Array_) {
981+
return $this->initializerExprTypeResolver->getArrayType($inner, $getType);
982+
}
983+
984+
return $nodeScopeResolver->findScopeStateType($inner, $scope)
985+
?? $this->initializerExprTypeResolver->getType($inner, $initializerContext);
986+
};
987+
988+
return $this->initializerExprTypeResolver->getArrayType($expr, $getType);
989+
}
990+
941991
/**
942992
* @param array<int|string, Type> $types
943993
* @param ParametersAcceptor[] $parametersAcceptors
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug15269;
6+
7+
use Closure;
8+
use stdClass;
9+
use function PHPStan\Testing\assertType;
10+
11+
/**
12+
* @template TColumn of string
13+
* @param array<TColumn, \Closure(TColumn): string> $formatters
14+
*/
15+
function formatColumns(array $formatters): void
16+
{
17+
}
18+
19+
formatColumns([
20+
'name' => function ($column) {
21+
assertType("'email'|'name'", $column);
22+
return strtoupper($column);
23+
},
24+
'email' => fn ($column) => assertType("'email'|'name'", $column),
25+
]);
26+
27+
/**
28+
* @template T
29+
* @param array{0: T, 1: \Closure(T): void} $a
30+
*/
31+
function siblingValue(array $a): void
32+
{
33+
}
34+
35+
siblingValue([1, function ($x) {
36+
assertType('1', $x);
37+
}]);
38+
39+
/**
40+
* @template T
41+
* @param array{0: \Closure(T): void, 1: T} $a
42+
*/
43+
function siblingValueReversed(array $a): void
44+
{
45+
}
46+
47+
siblingValueReversed([function ($x) {
48+
assertType("'foo'", $x);
49+
}, 'foo']);
50+
51+
/**
52+
* @template T of string
53+
* @param array<string, array<T, \Closure(T): string>> $f
54+
*/
55+
function nested(array $f): void
56+
{
57+
}
58+
59+
nested(['group' => ['inner' => function ($x) {
60+
assertType("'inner'", $x);
61+
return 'z';
62+
}]]);
63+
64+
/**
65+
* @template T of string
66+
* @param array<T, callable(T): string> $f
67+
*/
68+
function withCallable(array $f): void
69+
{
70+
}
71+
72+
withCallable(['e' => function ($x) {
73+
assertType("'e'", $x);
74+
return 'z';
75+
}]);
76+
77+
/**
78+
* @template T of object
79+
* @param array<class-string<T>, \Closure(T): void> $handlers
80+
*/
81+
function handlers(array $handlers): void
82+
{
83+
}
84+
85+
handlers([stdClass::class => function ($o) {
86+
assertType('stdClass', $o);
87+
}]);
88+
89+
/**
90+
* @template T of string
91+
* @param array<T, \Closure(T): string> ...$f
92+
*/
93+
function variadic(array ...$f): void
94+
{
95+
}
96+
97+
variadic(['v' => function ($x) {
98+
assertType("'v'", $x);
99+
return 'z';
100+
}]);
101+
102+
/**
103+
* @template T of string
104+
* @param array<T, \Closure(T): string> $f
105+
*/
106+
function named(array $f): void
107+
{
108+
}
109+
110+
named(f: ['n' => fn ($x) => assertType("'n'", $x)]);
111+
112+
/**
113+
* @template T of string
114+
* @param array{0: T, 1: \Closure(T): void} $a
115+
*/
116+
function boundedUnknown(array $a): void
117+
{
118+
}
119+
120+
function unknownString(): string
121+
{
122+
return 'x';
123+
}
124+
125+
// the first slot cannot be priced without a walk, so the template stays at its
126+
// bound instead of being widened to mixed
127+
boundedUnknown([unknownString(), function ($x) {
128+
assertType('string', $x);
129+
}]);
130+
131+
class Svc
132+
{
133+
134+
/**
135+
* @template T of string
136+
* @param array<T, \Closure(T): string> $f
137+
*/
138+
public function __construct(array $f)
139+
{
140+
}
141+
142+
/**
143+
* @template T of string
144+
* @param array<T, \Closure(T): string> $f
145+
*/
146+
public function method(array $f): void
147+
{
148+
}
149+
150+
/**
151+
* @template T of string
152+
* @param array<T, \Closure(T): string> $f
153+
*/
154+
public static function staticMethod(array $f): void
155+
{
156+
}
157+
158+
}
159+
160+
$svc = new Svc(['a' => function ($x) {
161+
assertType("'a'", $x);
162+
return 'z';
163+
}]);
164+
$svc->method(['b' => function ($x) {
165+
assertType("'b'", $x);
166+
return 'z';
167+
}]);
168+
Svc::staticMethod(['c' => function ($x) {
169+
assertType("'c'", $x);
170+
return 'z';
171+
}]);
172+
173+
/**
174+
* @template T
175+
* @template U
176+
* @param array{\Closure(T): U, T} $a
177+
* @return U
178+
*/
179+
function inferReturn(array $a)
180+
{
181+
throw new \Exception();
182+
}
183+
184+
assertType("'3'", inferReturn([fn ($x) => (string) $x, 3]));

0 commit comments

Comments
 (0)