Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Closure::fromCallable and first-class callables not propagating templates. #2962

Draft
wants to merge 1 commit into
base: 1.10.x
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
9 changes: 5 additions & 4 deletions src/Reflection/GenericParametersAcceptorResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc
$namedArgTypes = [];
foreach ($argTypes as $i => $argType) {
if (is_int($i)) {
if (isset($parameters[$i])) {
if (isset($parameters[$i]) && $parameters[$i]->getName() !== '') {
$namedArgTypes[$parameters[$i]->getName()] = $argType;
continue;
}
Expand All @@ -47,15 +47,16 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc
$namedArgTypes[$parameterName] = $argType;
}
}
continue;
}

$namedArgTypes[$i] = $argType;
}

foreach ($parametersAcceptor->getParameters() as $param) {
if (isset($namedArgTypes[$param->getName()])) {
foreach ($parametersAcceptor->getParameters() as $i => $param) {
if ($param->getName() !== '' && isset($namedArgTypes[$param->getName()])) {
$argType = $namedArgTypes[$param->getName()];
} elseif (isset($namedArgTypes[$i])) {
$argType = $namedArgTypes[$i];
} elseif ($param->getDefaultValue() !== null) {
$argType = $param->getDefaultValue();
} else {
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, Y): Z $fn
* @return callable(Y, X): 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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i had to change the generic type names of this function from A B C D to something else, as identity() and identity2() are also using A and B as generic type names, which made the type resolution fail. Been trying to dig into it a bit but as for why i'm really not sure right now,

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could this be related to TemplateTypeScope? i'm yet to properly understand it's role in the type resolution but it seems like it could be pertinent

{
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]));
}
Loading