Skip to content

Commit 7e9cd45

Browse files
committed
Do not lose generic type when the closure has native return type
Closes phpstan/phpstan#7281
1 parent 6d64074 commit 7e9cd45

File tree

5 files changed

+137
-18
lines changed

5 files changed

+137
-18
lines changed

src/Analyser/MutatingScope.php

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,6 @@
121121
use PHPStan\Type\ThisType;
122122
use PHPStan\Type\Type;
123123
use PHPStan\Type\TypeCombinator;
124-
use PHPStan\Type\TypehintHelper;
125124
use PHPStan\Type\TypeTraverser;
126125
use PHPStan\Type\TypeUtils;
127126
use PHPStan\Type\TypeWithClassName;
@@ -1277,7 +1276,8 @@ private function resolveType(string $exprString, Expr $node): Type
12771276
} else {
12781277
$returnType = $arrowScope->getKeepVoidType($node->expr);
12791278
if ($node->returnType !== null) {
1280-
$returnType = TypehintHelper::decideType($this->getFunctionType($node->returnType, false, false), $returnType);
1279+
$nativeReturnType = $this->getFunctionType($node->returnType, false, false);
1280+
$returnType = self::intersectButNotNever($nativeReturnType, $returnType);
12811281
}
12821282
}
12831283

@@ -1434,7 +1434,10 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu
14341434
$returnType,
14351435
]);
14361436
} else {
1437-
$returnType = TypehintHelper::decideType($this->getFunctionType($node->returnType, false, false), $returnType);
1437+
if ($node->returnType !== null) {
1438+
$nativeReturnType = $this->getFunctionType($node->returnType, false, false);
1439+
$returnType = self::intersectButNotNever($nativeReturnType, $returnType);
1440+
}
14381441
}
14391442

14401443
$usedVariables = [];
@@ -3213,16 +3216,16 @@ private function enterAnonymousFunctionWithoutReflection(
32133216
$parameterType = $this->getFunctionType($parameter->type, $isNullable, $parameter->variadic);
32143217
if ($callableParameters !== null) {
32153218
if (isset($callableParameters[$i])) {
3216-
$parameterType = TypehintHelper::decideType($parameterType, $callableParameters[$i]->getType());
3219+
$parameterType = self::intersectButNotNever($parameterType, $callableParameters[$i]->getType());
32173220
} elseif (count($callableParameters) > 0) {
32183221
$lastParameter = $callableParameters[count($callableParameters) - 1];
32193222
if ($lastParameter->isVariadic()) {
3220-
$parameterType = TypehintHelper::decideType($parameterType, $lastParameter->getType());
3223+
$parameterType = self::intersectButNotNever($parameterType, $lastParameter->getType());
32213224
} else {
3222-
$parameterType = TypehintHelper::decideType($parameterType, new MixedType());
3225+
$parameterType = self::intersectButNotNever($parameterType, new MixedType());
32233226
}
32243227
} else {
3225-
$parameterType = TypehintHelper::decideType($parameterType, new MixedType());
3228+
$parameterType = self::intersectButNotNever($parameterType, new MixedType());
32263229
}
32273230
}
32283231
$holder = ExpressionTypeHolder::createYes($parameter->var, $parameterType);
@@ -3389,16 +3392,16 @@ private function enterArrowFunctionWithoutReflection(Expr\ArrowFunction $arrowFu
33893392

33903393
if ($callableParameters !== null) {
33913394
if (isset($callableParameters[$i])) {
3392-
$parameterType = TypehintHelper::decideType($parameterType, $callableParameters[$i]->getType());
3395+
$parameterType = self::intersectButNotNever($parameterType, $callableParameters[$i]->getType());
33933396
} elseif (count($callableParameters) > 0) {
33943397
$lastParameter = $callableParameters[count($callableParameters) - 1];
33953398
if ($lastParameter->isVariadic()) {
3396-
$parameterType = TypehintHelper::decideType($parameterType, $lastParameter->getType());
3399+
$parameterType = self::intersectButNotNever($parameterType, $lastParameter->getType());
33973400
} else {
3398-
$parameterType = TypehintHelper::decideType($parameterType, new MixedType());
3401+
$parameterType = self::intersectButNotNever($parameterType, new MixedType());
33993402
}
34003403
} else {
3401-
$parameterType = TypehintHelper::decideType($parameterType, new MixedType());
3404+
$parameterType = self::intersectButNotNever($parameterType, new MixedType());
34023405
}
34033406
}
34043407

@@ -3483,6 +3486,20 @@ public function getFunctionType($type, bool $isNullable, bool $isVariadic): Type
34833486
return ParserNodeTypeToPHPStanType::resolve($type, $this->isInClass() ? $this->getClassReflection() : null);
34843487
}
34853488

3489+
private static function intersectButNotNever(Type $nativeType, Type $inferredType): Type
3490+
{
3491+
if ($nativeType->isSuperTypeOf($inferredType)->no()) {
3492+
return $nativeType;
3493+
}
3494+
3495+
$result = TypeCombinator::intersect($nativeType, $inferredType);
3496+
if (TypeCombinator::containsNull($nativeType)) {
3497+
return TypeCombinator::addNull($result);
3498+
}
3499+
3500+
return $result;
3501+
}
3502+
34863503
public function enterMatch(Expr\Match_ $expr): self
34873504
{
34883505
if ($expr->cond instanceof Variable) {

tests/PHPStan/Analyser/NodeScopeResolverTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ public function dataFileAsserts(): iterable
8585
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-2443.php');
8686
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-5508.php');
8787
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-10254.php');
88+
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-7281.php');
8889
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-2750.php');
8990
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-2850.php');
9091
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-2863.php');
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
namespace Bug7281;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class Percentage {}
8+
9+
/**
10+
* @template T
11+
*/
12+
final class Timeline {}
13+
14+
/**
15+
* @template K of array-key
16+
* @template T
17+
* @template U
18+
*
19+
* @param array<K, T> $array
20+
* @param (callable(T, K): U) $fn
21+
*
22+
* @return array<K, U>
23+
*/
24+
function map(array $array, callable $fn): array
25+
{
26+
/** @phpstan-ignore-next-line */
27+
return array_map($fn, $array);
28+
}
29+
30+
function (): void {
31+
/**
32+
* @var array<int, Timeline<Percentage>> $timelines
33+
*/
34+
$timelines = [];
35+
36+
assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', map(
37+
$timelines,
38+
static function (Timeline $timeline): Timeline {
39+
return $timeline;
40+
},
41+
));
42+
assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', map(
43+
$timelines,
44+
static function ($timeline) {
45+
return $timeline;
46+
},
47+
));
48+
49+
assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', map(
50+
$timelines,
51+
static fn (Timeline $timeline): Timeline => $timeline,
52+
));
53+
assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', map(
54+
$timelines,
55+
static fn ($timeline) => $timeline,
56+
));
57+
58+
assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', array_map(
59+
static function (Timeline $timeline): Timeline {
60+
return $timeline;
61+
},
62+
$timelines,
63+
));
64+
assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', array_map(
65+
static function ($timeline) {
66+
return $timeline;
67+
},
68+
$timelines,
69+
));
70+
71+
assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', array_map(
72+
static fn (Timeline $timeline): Timeline => $timeline,
73+
$timelines,
74+
));
75+
assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', array_map(
76+
static fn ($timeline) => $timeline,
77+
$timelines,
78+
));
79+
80+
assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', array_map(
81+
static function (Timeline $timeline) {
82+
return $timeline;
83+
},
84+
$timelines,
85+
));
86+
assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', array_map(
87+
static function ($timeline): Timeline {
88+
return $timeline;
89+
},
90+
$timelines,
91+
));
92+
93+
assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', array_map(
94+
static fn (Timeline $timeline) => $timeline,
95+
$timelines,
96+
));
97+
assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', array_map(
98+
static fn ($timeline): Timeline => $timeline,
99+
$timelines,
100+
));
101+
};

tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,15 @@ public function testClosureReturnTypeRule(): void
3131
28,
3232
],
3333
[
34-
'Anonymous function should return ClosureReturnTypes\Foo but returns ClosureReturnTypes\Bar.',
34+
'Anonymous function should return ClosureReturnTypes\Bar&ClosureReturnTypes\Foo but returns ClosureReturnTypes\Bar.',
3535
35,
3636
],
3737
[
38-
'Anonymous function should return SomeOtherNamespace\Foo but returns ClosureReturnTypes\Foo.',
38+
'Anonymous function should return ClosureReturnTypes\Foo&SomeOtherNamespace\Foo but returns ClosureReturnTypes\Foo.',
3939
39,
4040
],
4141
[
42-
'Anonymous function should return SomeOtherNamespace\Baz but returns ClosureReturnTypes\Foo.',
42+
'Anonymous function should return ClosureReturnTypes\Foo&SomeOtherNamespace\Baz but returns ClosureReturnTypes\Foo.',
4343
46,
4444
],
4545
[

tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,10 @@ public function dataMixed(): array
706706
'Parameter #1 $cb of static method CallStaticMethodMixed\CallableMixed::callReturnsInt() expects callable(): int, Closure(): mixed given.',
707707
161,
708708
],
709+
[
710+
'Parameter #1 $cb of static method CallStaticMethodMixed\CallableMixed::callReturnsInt() expects callable(): int, Closure(): mixed given.',
711+
168,
712+
],
709713
];
710714
$implicitOnlyErrors = [
711715
[
@@ -720,10 +724,6 @@ public function dataMixed(): array
720724
'Only iterables can be unpacked, mixed given in argument #1.',
721725
51,
722726
],
723-
[
724-
'Parameter #1 $cb of static method CallStaticMethodMixed\CallableMixed::callReturnsInt() expects callable(): int, Closure(): mixed given.',
725-
168,
726-
],
727727
];
728728
$combinedErrors = array_merge($explicitOnlyErrors, $implicitOnlyErrors);
729729
usort($combinedErrors, static fn (array $a, array $b): int => $a[1] <=> $b[1]);

0 commit comments

Comments
 (0)