Skip to content

Commit

Permalink
Fix Closure::fromCallable and first-class callables not propagating t…
Browse files Browse the repository at this point in the history
…emplates.
  • Loading branch information
mad-briller committed Mar 9, 2024
1 parent 6b52280 commit f0b8ac3
Show file tree
Hide file tree
Showing 4 changed files with 233 additions and 23 deletions.
16 changes: 16 additions & 0 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
use PHPStan\Parser\NewAssignedToPropertyVisitor;
use PHPStan\Parser\Parser;
use PHPStan\Php\PhpVersion;
use PHPStan\PhpDoc\Tag\TemplateTag;
use PHPStan\Reflection\Assertions;
use PHPStan\Reflection\ClassMemberReflection;
use PHPStan\Reflection\ClassReflection;
Expand Down Expand Up @@ -97,6 +98,7 @@
use PHPStan\Type\Generic\TemplateType;
use PHPStan\Type\Generic\TemplateTypeHelper;
use PHPStan\Type\Generic\TemplateTypeMap;
use PHPStan\Type\Generic\TemplateTypeVariance;
use PHPStan\Type\Generic\TemplateTypeVarianceMap;
use PHPStan\Type\IntegerRangeType;
use PHPStan\Type\IntegerType;
Expand Down Expand Up @@ -2201,13 +2203,27 @@ private function createFirstClassCallable(array $variants): Type
$returnType = $this->nativeTypesPromoted ? $variant->getNativeReturnType() : $returnType;
}
$parameters = $variant->getParameters();
$templateTags = [];
foreach ($variant->getTemplateTypeMap()->getTypes() as $name => $templateType) {
if (!$templateType instanceof TemplateType) {
throw new ShouldNotHappenException();
}

$templateTags[$name] = new TemplateTag(
$name,
$templateType->getBound(),
TemplateTypeVariance::createInvariant(),
);
}

$closureTypes[] = new ClosureType(
$parameters,
$returnType,
$variant->isVariadic(),
$variant->getTemplateTypeMap(),
$variant->getResolvedTemplateTypeMap(),
$variant instanceof ParametersAcceptorWithPhpDocs ? $variant->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(),
$templateTags,
);
}

Expand Down
78 changes: 55 additions & 23 deletions src/Type/Php/ArrayMapFunctionReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\Accessory\AccessoryArrayListType;
use PHPStan\Type\Accessory\NonEmptyArrayType;
use PHPStan\Type\ArrayType;
Expand Down Expand Up @@ -38,25 +39,6 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
$callableType = $scope->getType($functionCall->getArgs()[0]->value);
$callableIsNull = $callableType->isNull()->yes();

if ($callableType->isCallable()->yes()) {
$valueTypes = [new NeverType()];
foreach ($callableType->getCallableParametersAcceptors($scope) as $parametersAcceptor) {
$valueTypes[] = $parametersAcceptor->getReturnType();
}
$valueType = TypeCombinator::union(...$valueTypes);
} elseif ($callableIsNull) {
$arrayBuilder = ConstantArrayTypeBuilder::createEmpty();
foreach (array_slice($functionCall->getArgs(), 1) as $index => $arg) {
$arrayBuilder->setOffsetValueType(
new ConstantIntegerType($index),
$scope->getType($arg->value)->getIterableValueType(),
);
}
$valueType = $arrayBuilder->getArray();
} else {
$valueType = new MixedType();
}

$arrayType = $scope->getType($functionCall->getArgs()[1]->value);

if ($singleArrayArgument) {
Expand All @@ -69,9 +51,21 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
foreach ($constantArrays as $constantArray) {
$returnedArrayBuilder = ConstantArrayTypeBuilder::createEmpty();
foreach ($constantArray->getKeyTypes() as $i => $keyType) {
$offsetValueType = $constantArray->getOffsetValueType($keyType);

$valueTypes = [new NeverType()];
foreach ($callableType->getCallableParametersAcceptors($scope) as $parametersAcceptor) {
$parametersAcceptor = ParametersAcceptorSelector::selectFromTypes(
[$offsetValueType],
[$parametersAcceptor],
false,
);
$valueTypes[] = $parametersAcceptor->getReturnType();
}

$returnedArrayBuilder->setOffsetValueType(
$keyType,
$valueType,
TypeCombinator::union(...$valueTypes),
$constantArray->isOptionalKey($i),
);
}
Expand All @@ -86,18 +80,18 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
} elseif ($arrayType->isArray()->yes()) {
$mappedArrayType = TypeCombinator::intersect(new ArrayType(
$arrayType->getIterableKeyType(),
$valueType,
$this->resolveValueType($scope, $callableType, $callableIsNull, $functionCall),
), ...TypeUtils::getAccessoryTypes($arrayType));
} else {
$mappedArrayType = new ArrayType(
new MixedType(),
$valueType,
$this->resolveValueType($scope, $callableType, $callableIsNull, $functionCall),
);
}
} else {
$mappedArrayType = TypeCombinator::intersect(new ArrayType(
new IntegerType(),
$valueType,
$this->resolveValueType($scope, $callableType, $callableIsNull, $functionCall),
), ...TypeUtils::getAccessoryTypes($arrayType));
}

Expand All @@ -108,4 +102,42 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
return $mappedArrayType;
}

private function resolveValueType(
Scope $scope,
Type $callableType,
bool $callableIsNull,
FuncCall $functionCall,
): Type
{
if ($callableType->isCallable()->yes()) {
$argTypes = [];

foreach (array_slice($functionCall->getArgs(), 1) as $arrayArg) {
$argTypes[] = $scope->getType($arrayArg->value)->getIterableValueType();
}

$valueTypes = [new NeverType()];
foreach ($callableType->getCallableParametersAcceptors($scope) as $parametersAcceptor) {
$parametersAcceptor = ParametersAcceptorSelector::selectFromTypes(
$argTypes,
[$parametersAcceptor],
false,
);
$valueTypes[] = $parametersAcceptor->getReturnType();
}
return TypeCombinator::union(...$valueTypes);
} elseif ($callableIsNull) {
$arrayBuilder = ConstantArrayTypeBuilder::createEmpty();
foreach (array_slice($functionCall->getArgs(), 1) as $index => $arg) {
$arrayBuilder->setOffsetValueType(
new ConstantIntegerType($index),
$scope->getType($arg->value)->getIterableValueType(),
);
}
return $arrayBuilder->getArray();
}

return new MixedType();
}

}
19 changes: 19 additions & 0 deletions src/Type/Php/ClosureFromCallableDynamicReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@
use Closure;
use PhpParser\Node\Expr\StaticCall;
use PHPStan\Analyser\Scope;
use PHPStan\PhpDoc\Tag\TemplateTag;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorWithPhpDocs;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\ClosureType;
use PHPStan\Type\DynamicStaticMethodReturnTypeExtension;
use PHPStan\Type\ErrorType;
use PHPStan\Type\Generic\TemplateType;
use PHPStan\Type\Generic\TemplateTypeVariance;
use PHPStan\Type\Generic\TemplateTypeVarianceMap;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
Expand Down Expand Up @@ -41,13 +45,28 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection,
$closureTypes = [];
foreach ($callableType->getCallableParametersAcceptors($scope) as $variant) {
$parameters = $variant->getParameters();
$templateTags = [];

foreach ($variant->getTemplateTypeMap()->getTypes() as $name => $templateType) {
if (!$templateType instanceof TemplateType) {
throw new ShouldNotHappenException();
}

$templateTags[$name] = new TemplateTag(
$name,
$templateType->getBound(),
TemplateTypeVariance::createInvariant(),
);
}

$closureTypes[] = new ClosureType(
$parameters,
$variant->getReturnType(),
$variant->isVariadic(),
$variant->getTemplateTypeMap(),
$variant->getResolvedTemplateTypeMap(),
$variant instanceof ParametersAcceptorWithPhpDocs ? $variant->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(),
$templateTags,
);
}

Expand Down
143 changes: 143 additions & 0 deletions tests/PHPStan/Analyser/data/generic-callables.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,146 @@ function testNestedClosures(Closure $closure, string $str, int $int): void
$result = $closure1($int);
assertType('int|string', $result);
}

/**
* @template T
* @param T $arg
* @return T
*/
function foo(mixed $arg): mixed {}

class Foo
{
/**
* @template T
* @param T $arg
* @return T
*/
public function foo(mixed $arg): mixed {}
}

function test(): void
{
assertType('Closure<T of mixed>(T): T', foo(...));
assertType('1', foo(...)(1));

$foo = new Foo();
$closure = Closure::fromCallable([$foo, 'foo']);
assertType('Closure<T of mixed>(T): T', $closure);
assertType('1', $closure(1));
}

/**
* @template A
* @param A $value
* @return A
*/
function identity(mixed $value): mixed
{
return $value;
}

/**
* @template B
* @param B $value
* @return B
*/
function identity2(mixed $value): mixed
{
return $value;
}

function testIdentity(): void
{
assertType('array{1, 2, 3}', array_map(identity(...), [1, 2, 3]));
}

/**
* @template A
* @template B
* @param A $value
* @param B $value2
* @return A|B
*/
function identityTwoArgs(mixed $value, mixed $value2): mixed
{
return $value || $value2;
}

function testIdentityTwoArgs(): void
{
assertType('non-empty-array<int, 1|2|3|4|5|6>', array_map(identityTwoArgs(...), [1, 2, 3], [4, 5, 6]));
}

/**
* @template A
* @template B
* @param list<A> $a
* @param list<B> $b
* @return list<array{A, B}>
*/
function zip(array $a, array $b): array
{
}

function testZip(): void
{
$fn = zip(...);

assertType('list<array{1, 2}>', $fn([1], [2]));
}

/**
* @template X
* @template Y
* @template Z
* @param callable(X $x, Y $y): Z $fn
* @return callable(Y $xy, X $yx): Z
*/
function flip(callable $fn): callable
{
}

/**
* @param Closure<A of string, B of int>(A $a, B $b): (A|B) $closure
*/
function testFlip($closure): void
{
$fn = flip($closure);

assertType('callable(B, A): (A|B)', $fn);
assertType("1|'one'", $fn(1, 'one'));
}

function testFlipZip(): void
{
$fn = flip(zip(...));

assertType('list<array{2, 1}>', $fn([1], [2]));
}

/**
* @template L
* @template M
* @template N
* @template O
* @param callable(L): M $ab
* @param callable(N): O $cd
* @return Closure(array{L, N}): array{M, O}
*/
function compose2(callable $ab, callable $cd): Closure
{
throw new \RuntimeException();
}

function testCompose(): void
{
$composed = compose2(
identity(...),
identity2(...),
);

assertType('Closure(array{A, B}): array{A, B}', $composed);

assertType('array{1, 2}', $composed([1, 2]));
}

0 comments on commit f0b8ac3

Please sign in to comment.