Skip to content

Commit

Permalink
Do not lose generic type when the closure has native return type
Browse files Browse the repository at this point in the history
  • Loading branch information
ondrejmirtes committed May 18, 2024
1 parent 6d64074 commit 7e9cd45
Show file tree
Hide file tree
Showing 5 changed files with 137 additions and 18 deletions.
39 changes: 28 additions & 11 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@
use PHPStan\Type\ThisType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\TypehintHelper;
use PHPStan\Type\TypeTraverser;
use PHPStan\Type\TypeUtils;
use PHPStan\Type\TypeWithClassName;
Expand Down Expand Up @@ -1277,7 +1276,8 @@ private function resolveType(string $exprString, Expr $node): Type
} else {
$returnType = $arrowScope->getKeepVoidType($node->expr);
if ($node->returnType !== null) {
$returnType = TypehintHelper::decideType($this->getFunctionType($node->returnType, false, false), $returnType);
$nativeReturnType = $this->getFunctionType($node->returnType, false, false);
$returnType = self::intersectButNotNever($nativeReturnType, $returnType);
}
}

Expand Down Expand Up @@ -1434,7 +1434,10 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu
$returnType,
]);
} else {
$returnType = TypehintHelper::decideType($this->getFunctionType($node->returnType, false, false), $returnType);
if ($node->returnType !== null) {
$nativeReturnType = $this->getFunctionType($node->returnType, false, false);
$returnType = self::intersectButNotNever($nativeReturnType, $returnType);
}
}

$usedVariables = [];
Expand Down Expand Up @@ -3213,16 +3216,16 @@ private function enterAnonymousFunctionWithoutReflection(
$parameterType = $this->getFunctionType($parameter->type, $isNullable, $parameter->variadic);
if ($callableParameters !== null) {
if (isset($callableParameters[$i])) {
$parameterType = TypehintHelper::decideType($parameterType, $callableParameters[$i]->getType());
$parameterType = self::intersectButNotNever($parameterType, $callableParameters[$i]->getType());
} elseif (count($callableParameters) > 0) {
$lastParameter = $callableParameters[count($callableParameters) - 1];
if ($lastParameter->isVariadic()) {
$parameterType = TypehintHelper::decideType($parameterType, $lastParameter->getType());
$parameterType = self::intersectButNotNever($parameterType, $lastParameter->getType());
} else {
$parameterType = TypehintHelper::decideType($parameterType, new MixedType());
$parameterType = self::intersectButNotNever($parameterType, new MixedType());
}
} else {
$parameterType = TypehintHelper::decideType($parameterType, new MixedType());
$parameterType = self::intersectButNotNever($parameterType, new MixedType());
}
}
$holder = ExpressionTypeHolder::createYes($parameter->var, $parameterType);
Expand Down Expand Up @@ -3389,16 +3392,16 @@ private function enterArrowFunctionWithoutReflection(Expr\ArrowFunction $arrowFu

if ($callableParameters !== null) {
if (isset($callableParameters[$i])) {
$parameterType = TypehintHelper::decideType($parameterType, $callableParameters[$i]->getType());
$parameterType = self::intersectButNotNever($parameterType, $callableParameters[$i]->getType());
} elseif (count($callableParameters) > 0) {
$lastParameter = $callableParameters[count($callableParameters) - 1];
if ($lastParameter->isVariadic()) {
$parameterType = TypehintHelper::decideType($parameterType, $lastParameter->getType());
$parameterType = self::intersectButNotNever($parameterType, $lastParameter->getType());
} else {
$parameterType = TypehintHelper::decideType($parameterType, new MixedType());
$parameterType = self::intersectButNotNever($parameterType, new MixedType());
}
} else {
$parameterType = TypehintHelper::decideType($parameterType, new MixedType());
$parameterType = self::intersectButNotNever($parameterType, new MixedType());
}
}

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

private static function intersectButNotNever(Type $nativeType, Type $inferredType): Type
{
if ($nativeType->isSuperTypeOf($inferredType)->no()) {
return $nativeType;
}

$result = TypeCombinator::intersect($nativeType, $inferredType);
if (TypeCombinator::containsNull($nativeType)) {
return TypeCombinator::addNull($result);
}

return $result;
}

public function enterMatch(Expr\Match_ $expr): self
{
if ($expr->cond instanceof Variable) {
Expand Down
1 change: 1 addition & 0 deletions tests/PHPStan/Analyser/NodeScopeResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ public function dataFileAsserts(): iterable
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-2443.php');
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-5508.php');
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-10254.php');
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-7281.php');
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-2750.php');
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-2850.php');
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-2863.php');
Expand Down
101 changes: 101 additions & 0 deletions tests/PHPStan/Analyser/data/bug-7281.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

namespace Bug7281;

use function PHPStan\Testing\assertType;

class Percentage {}

/**
* @template T
*/
final class Timeline {}

/**
* @template K of array-key
* @template T
* @template U
*
* @param array<K, T> $array
* @param (callable(T, K): U) $fn
*
* @return array<K, U>
*/
function map(array $array, callable $fn): array
{
/** @phpstan-ignore-next-line */
return array_map($fn, $array);
}

function (): void {
/**
* @var array<int, Timeline<Percentage>> $timelines
*/
$timelines = [];

assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', map(
$timelines,
static function (Timeline $timeline): Timeline {
return $timeline;
},
));
assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', map(
$timelines,
static function ($timeline) {
return $timeline;
},
));

assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', map(
$timelines,
static fn (Timeline $timeline): Timeline => $timeline,
));
assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', map(
$timelines,
static fn ($timeline) => $timeline,
));

assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', array_map(
static function (Timeline $timeline): Timeline {
return $timeline;
},
$timelines,
));
assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', array_map(
static function ($timeline) {
return $timeline;
},
$timelines,
));

assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', array_map(
static fn (Timeline $timeline): Timeline => $timeline,
$timelines,
));
assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', array_map(
static fn ($timeline) => $timeline,
$timelines,
));

assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', array_map(
static function (Timeline $timeline) {
return $timeline;
},
$timelines,
));
assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', array_map(
static function ($timeline): Timeline {
return $timeline;
},
$timelines,
));

assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', array_map(
static fn (Timeline $timeline) => $timeline,
$timelines,
));
assertType('array<int, Bug7281\\Timeline<Bug7281\\Percentage>>', array_map(
static fn ($timeline): Timeline => $timeline,
$timelines,
));
};
6 changes: 3 additions & 3 deletions tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,15 @@ public function testClosureReturnTypeRule(): void
28,
],
[
'Anonymous function should return ClosureReturnTypes\Foo but returns ClosureReturnTypes\Bar.',
'Anonymous function should return ClosureReturnTypes\Bar&ClosureReturnTypes\Foo but returns ClosureReturnTypes\Bar.',
35,
],
[
'Anonymous function should return SomeOtherNamespace\Foo but returns ClosureReturnTypes\Foo.',
'Anonymous function should return ClosureReturnTypes\Foo&SomeOtherNamespace\Foo but returns ClosureReturnTypes\Foo.',
39,
],
[
'Anonymous function should return SomeOtherNamespace\Baz but returns ClosureReturnTypes\Foo.',
'Anonymous function should return ClosureReturnTypes\Foo&SomeOtherNamespace\Baz but returns ClosureReturnTypes\Foo.',
46,
],
[
Expand Down
8 changes: 4 additions & 4 deletions tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,10 @@ public function dataMixed(): array
'Parameter #1 $cb of static method CallStaticMethodMixed\CallableMixed::callReturnsInt() expects callable(): int, Closure(): mixed given.',
161,
],
[
'Parameter #1 $cb of static method CallStaticMethodMixed\CallableMixed::callReturnsInt() expects callable(): int, Closure(): mixed given.',
168,
],
];
$implicitOnlyErrors = [
[
Expand All @@ -720,10 +724,6 @@ public function dataMixed(): array
'Only iterables can be unpacked, mixed given in argument #1.',
51,
],
[
'Parameter #1 $cb of static method CallStaticMethodMixed\CallableMixed::callReturnsInt() expects callable(): int, Closure(): mixed given.',
168,
],
];
$combinedErrors = array_merge($explicitOnlyErrors, $implicitOnlyErrors);
usort($combinedErrors, static fn (array $a, array $b): int => $a[1] <=> $b[1]);
Expand Down

0 comments on commit 7e9cd45

Please sign in to comment.