Skip to content

Commit 0334026

Browse files
committed
Fix Closure::fromCallable and first-class callables not propagating templates.
1 parent 6b52280 commit 0334026

File tree

5 files changed

+238
-27
lines changed

5 files changed

+238
-27
lines changed

src/Analyser/MutatingScope.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
use PHPStan\Parser\NewAssignedToPropertyVisitor;
4848
use PHPStan\Parser\Parser;
4949
use PHPStan\Php\PhpVersion;
50+
use PHPStan\PhpDoc\Tag\TemplateTag;
5051
use PHPStan\Reflection\Assertions;
5152
use PHPStan\Reflection\ClassMemberReflection;
5253
use PHPStan\Reflection\ClassReflection;
@@ -97,6 +98,7 @@
9798
use PHPStan\Type\Generic\TemplateType;
9899
use PHPStan\Type\Generic\TemplateTypeHelper;
99100
use PHPStan\Type\Generic\TemplateTypeMap;
101+
use PHPStan\Type\Generic\TemplateTypeVariance;
100102
use PHPStan\Type\Generic\TemplateTypeVarianceMap;
101103
use PHPStan\Type\IntegerRangeType;
102104
use PHPStan\Type\IntegerType;
@@ -2201,13 +2203,27 @@ private function createFirstClassCallable(array $variants): Type
22012203
$returnType = $this->nativeTypesPromoted ? $variant->getNativeReturnType() : $returnType;
22022204
}
22032205
$parameters = $variant->getParameters();
2206+
$templateTags = [];
2207+
foreach ($variant->getTemplateTypeMap()->getTypes() as $name => $templateType) {
2208+
if (!$templateType instanceof TemplateType) {
2209+
throw new ShouldNotHappenException();
2210+
}
2211+
2212+
$templateTags[$name] = new TemplateTag(
2213+
$name,
2214+
$templateType->getBound(),
2215+
TemplateTypeVariance::createInvariant(),
2216+
);
2217+
}
2218+
22042219
$closureTypes[] = new ClosureType(
22052220
$parameters,
22062221
$returnType,
22072222
$variant->isVariadic(),
22082223
$variant->getTemplateTypeMap(),
22092224
$variant->getResolvedTemplateTypeMap(),
22102225
$variant instanceof ParametersAcceptorWithPhpDocs ? $variant->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(),
2226+
$templateTags,
22112227
);
22122228
}
22132229

src/Reflection/GenericParametersAcceptorResolver.php

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc
3232
$namedArgTypes = [];
3333
foreach ($argTypes as $i => $argType) {
3434
if (is_int($i)) {
35-
if (isset($parameters[$i])) {
35+
if (isset($parameters[$i]) && $parameters[$i]->getName() !== '') {
3636
$namedArgTypes[$parameters[$i]->getName()] = $argType;
3737
continue;
3838
}
@@ -47,15 +47,16 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc
4747
$namedArgTypes[$parameterName] = $argType;
4848
}
4949
}
50-
continue;
5150
}
5251

5352
$namedArgTypes[$i] = $argType;
5453
}
5554

56-
foreach ($parametersAcceptor->getParameters() as $param) {
57-
if (isset($namedArgTypes[$param->getName()])) {
55+
foreach ($parametersAcceptor->getParameters() as $i => $param) {
56+
if ($param->getName() !== '' && isset($namedArgTypes[$param->getName()])) {
5857
$argType = $namedArgTypes[$param->getName()];
58+
} elseif (isset($namedArgTypes[$i])) {
59+
$argType = $namedArgTypes[$i];
5960
} elseif ($param->getDefaultValue() !== null) {
6061
$argType = $param->getDefaultValue();
6162
} else {

src/Type/Php/ArrayMapFunctionReturnTypeExtension.php

Lines changed: 55 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use PhpParser\Node\Expr\FuncCall;
66
use PHPStan\Analyser\Scope;
77
use PHPStan\Reflection\FunctionReflection;
8+
use PHPStan\Reflection\ParametersAcceptorSelector;
89
use PHPStan\Type\Accessory\AccessoryArrayListType;
910
use PHPStan\Type\Accessory\NonEmptyArrayType;
1011
use PHPStan\Type\ArrayType;
@@ -38,25 +39,6 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
3839
$callableType = $scope->getType($functionCall->getArgs()[0]->value);
3940
$callableIsNull = $callableType->isNull()->yes();
4041

41-
if ($callableType->isCallable()->yes()) {
42-
$valueTypes = [new NeverType()];
43-
foreach ($callableType->getCallableParametersAcceptors($scope) as $parametersAcceptor) {
44-
$valueTypes[] = $parametersAcceptor->getReturnType();
45-
}
46-
$valueType = TypeCombinator::union(...$valueTypes);
47-
} elseif ($callableIsNull) {
48-
$arrayBuilder = ConstantArrayTypeBuilder::createEmpty();
49-
foreach (array_slice($functionCall->getArgs(), 1) as $index => $arg) {
50-
$arrayBuilder->setOffsetValueType(
51-
new ConstantIntegerType($index),
52-
$scope->getType($arg->value)->getIterableValueType(),
53-
);
54-
}
55-
$valueType = $arrayBuilder->getArray();
56-
} else {
57-
$valueType = new MixedType();
58-
}
59-
6042
$arrayType = $scope->getType($functionCall->getArgs()[1]->value);
6143

6244
if ($singleArrayArgument) {
@@ -69,9 +51,21 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
6951
foreach ($constantArrays as $constantArray) {
7052
$returnedArrayBuilder = ConstantArrayTypeBuilder::createEmpty();
7153
foreach ($constantArray->getKeyTypes() as $i => $keyType) {
54+
$offsetValueType = $constantArray->getOffsetValueType($keyType);
55+
56+
$valueTypes = [new NeverType()];
57+
foreach ($callableType->getCallableParametersAcceptors($scope) as $parametersAcceptor) {
58+
$parametersAcceptor = ParametersAcceptorSelector::selectFromTypes(
59+
[$offsetValueType],
60+
[$parametersAcceptor],
61+
false,
62+
);
63+
$valueTypes[] = $parametersAcceptor->getReturnType();
64+
}
65+
7266
$returnedArrayBuilder->setOffsetValueType(
7367
$keyType,
74-
$valueType,
68+
TypeCombinator::union(...$valueTypes),
7569
$constantArray->isOptionalKey($i),
7670
);
7771
}
@@ -86,18 +80,18 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
8680
} elseif ($arrayType->isArray()->yes()) {
8781
$mappedArrayType = TypeCombinator::intersect(new ArrayType(
8882
$arrayType->getIterableKeyType(),
89-
$valueType,
83+
$this->resolveValueType($scope, $callableType, $callableIsNull, $functionCall),
9084
), ...TypeUtils::getAccessoryTypes($arrayType));
9185
} else {
9286
$mappedArrayType = new ArrayType(
9387
new MixedType(),
94-
$valueType,
88+
$this->resolveValueType($scope, $callableType, $callableIsNull, $functionCall),
9589
);
9690
}
9791
} else {
9892
$mappedArrayType = TypeCombinator::intersect(new ArrayType(
9993
new IntegerType(),
100-
$valueType,
94+
$this->resolveValueType($scope, $callableType, $callableIsNull, $functionCall),
10195
), ...TypeUtils::getAccessoryTypes($arrayType));
10296
}
10397

@@ -108,4 +102,42 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
108102
return $mappedArrayType;
109103
}
110104

105+
private function resolveValueType(
106+
Scope $scope,
107+
Type $callableType,
108+
bool $callableIsNull,
109+
FuncCall $functionCall,
110+
): Type
111+
{
112+
if ($callableType->isCallable()->yes()) {
113+
$argTypes = [];
114+
115+
foreach (array_slice($functionCall->getArgs(), 1) as $arrayArg) {
116+
$argTypes[] = $scope->getType($arrayArg->value)->getIterableValueType();
117+
}
118+
119+
$valueTypes = [new NeverType()];
120+
foreach ($callableType->getCallableParametersAcceptors($scope) as $parametersAcceptor) {
121+
$parametersAcceptor = ParametersAcceptorSelector::selectFromTypes(
122+
$argTypes,
123+
[$parametersAcceptor],
124+
false,
125+
);
126+
$valueTypes[] = $parametersAcceptor->getReturnType();
127+
}
128+
return TypeCombinator::union(...$valueTypes);
129+
} elseif ($callableIsNull) {
130+
$arrayBuilder = ConstantArrayTypeBuilder::createEmpty();
131+
foreach (array_slice($functionCall->getArgs(), 1) as $index => $arg) {
132+
$arrayBuilder->setOffsetValueType(
133+
new ConstantIntegerType($index),
134+
$scope->getType($arg->value)->getIterableValueType(),
135+
);
136+
}
137+
return $arrayBuilder->getArray();
138+
}
139+
140+
return new MixedType();
141+
}
142+
111143
}

src/Type/Php/ClosureFromCallableDynamicReturnTypeExtension.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@
55
use Closure;
66
use PhpParser\Node\Expr\StaticCall;
77
use PHPStan\Analyser\Scope;
8+
use PHPStan\PhpDoc\Tag\TemplateTag;
89
use PHPStan\Reflection\MethodReflection;
910
use PHPStan\Reflection\ParametersAcceptorWithPhpDocs;
11+
use PHPStan\ShouldNotHappenException;
1012
use PHPStan\Type\ClosureType;
1113
use PHPStan\Type\DynamicStaticMethodReturnTypeExtension;
1214
use PHPStan\Type\ErrorType;
15+
use PHPStan\Type\Generic\TemplateType;
16+
use PHPStan\Type\Generic\TemplateTypeVariance;
1317
use PHPStan\Type\Generic\TemplateTypeVarianceMap;
1418
use PHPStan\Type\Type;
1519
use PHPStan\Type\TypeCombinator;
@@ -41,13 +45,28 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection,
4145
$closureTypes = [];
4246
foreach ($callableType->getCallableParametersAcceptors($scope) as $variant) {
4347
$parameters = $variant->getParameters();
48+
$templateTags = [];
49+
50+
foreach ($variant->getTemplateTypeMap()->getTypes() as $name => $templateType) {
51+
if (!$templateType instanceof TemplateType) {
52+
throw new ShouldNotHappenException();
53+
}
54+
55+
$templateTags[$name] = new TemplateTag(
56+
$name,
57+
$templateType->getBound(),
58+
TemplateTypeVariance::createInvariant(),
59+
);
60+
}
61+
4462
$closureTypes[] = new ClosureType(
4563
$parameters,
4664
$variant->getReturnType(),
4765
$variant->isVariadic(),
4866
$variant->getTemplateTypeMap(),
4967
$variant->getResolvedTemplateTypeMap(),
5068
$variant instanceof ParametersAcceptorWithPhpDocs ? $variant->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(),
69+
$templateTags,
5170
);
5271
}
5372

tests/PHPStan/Analyser/data/generic-callables.php

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,146 @@ function testNestedClosures(Closure $closure, string $str, int $int): void
7878
$result = $closure1($int);
7979
assertType('int|string', $result);
8080
}
81+
82+
/**
83+
* @template T
84+
* @param T $arg
85+
* @return T
86+
*/
87+
function foo(mixed $arg): mixed {}
88+
89+
class Foo
90+
{
91+
/**
92+
* @template T
93+
* @param T $arg
94+
* @return T
95+
*/
96+
public function foo(mixed $arg): mixed {}
97+
}
98+
99+
function test(): void
100+
{
101+
assertType('Closure<T of mixed>(T): T', foo(...));
102+
assertType('1', foo(...)(1));
103+
104+
$foo = new Foo();
105+
$closure = Closure::fromCallable([$foo, 'foo']);
106+
assertType('Closure<T of mixed>(T): T', $closure);
107+
assertType('1', $closure(1));
108+
}
109+
110+
/**
111+
* @template A
112+
* @param A $value
113+
* @return A
114+
*/
115+
function identity(mixed $value): mixed
116+
{
117+
return $value;
118+
}
119+
120+
/**
121+
* @template B
122+
* @param B $value
123+
* @return B
124+
*/
125+
function identity2(mixed $value): mixed
126+
{
127+
return $value;
128+
}
129+
130+
function testIdentity(): void
131+
{
132+
assertType('array{1, 2, 3}', array_map(identity(...), [1, 2, 3]));
133+
}
134+
135+
/**
136+
* @template A
137+
* @template B
138+
* @param A $value
139+
* @param B $value2
140+
* @return A|B
141+
*/
142+
function identityTwoArgs(mixed $value, mixed $value2): mixed
143+
{
144+
return $value || $value2;
145+
}
146+
147+
function testIdentityTwoArgs(): void
148+
{
149+
assertType('non-empty-array<int, 1|2|3|4|5|6>', array_map(identityTwoArgs(...), [1, 2, 3], [4, 5, 6]));
150+
}
151+
152+
/**
153+
* @template A
154+
* @template B
155+
* @param list<A> $a
156+
* @param list<B> $b
157+
* @return list<array{A, B}>
158+
*/
159+
function zip(array $a, array $b): array
160+
{
161+
}
162+
163+
function testZip(): void
164+
{
165+
$fn = zip(...);
166+
167+
assertType('list<array{1, 2}>', $fn([1], [2]));
168+
}
169+
170+
/**
171+
* @template X
172+
* @template Y
173+
* @template Z
174+
* @param callable(X, Y): Z $fn
175+
* @return callable(Y, X): Z
176+
*/
177+
function flip(callable $fn): callable
178+
{
179+
}
180+
181+
/**
182+
* @param Closure<A of string, B of int>(A $a, B $b): (A|B) $closure
183+
*/
184+
function testFlip($closure): void
185+
{
186+
$fn = flip($closure);
187+
188+
assertType('callable(B, A): (A|B)', $fn);
189+
assertType("1|'one'", $fn(1, 'one'));
190+
}
191+
192+
function testFlipZip(): void
193+
{
194+
$fn = flip(zip(...));
195+
196+
assertType('list<array{2, 1}>', $fn([1], [2]));
197+
}
198+
199+
/**
200+
* @template L
201+
* @template M
202+
* @template N
203+
* @template O
204+
* @param callable(L): M $ab
205+
* @param callable(N): O $cd
206+
* @return Closure(array{L, N}): array{M, O}
207+
*/
208+
function compose2(callable $ab, callable $cd): Closure
209+
{
210+
throw new \RuntimeException();
211+
}
212+
213+
function testCompose(): void
214+
{
215+
$composed = compose2(
216+
identity(...),
217+
identity2(...),
218+
);
219+
220+
assertType('Closure(array{A, B}): array{A, B}', $composed);
221+
222+
assertType('array{1, 2}', $composed([1, 2]));
223+
}

0 commit comments

Comments
 (0)