Skip to content
Open
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
95 changes: 90 additions & 5 deletions src/Analyser/ArgumentsHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,15 @@
use PHPStan\Type\FunctionParameterClosureThisExtension;
use PHPStan\Type\FunctionParameterClosureTypeExtension;
use PHPStan\Type\FunctionParameterOutTypeExtension;
use PHPStan\Type\Generic\TemplateType;
use PHPStan\Type\Generic\TemplateTypeHelper;
use PHPStan\Type\Generic\TemplateTypeMap;
use PHPStan\Type\Generic\TemplateTypeVariance;
use PHPStan\Type\MethodParameterClosureThisExtension;
use PHPStan\Type\MethodParameterClosureTypeExtension;
use PHPStan\Type\MethodParameterOutTypeExtension;
use PHPStan\Type\MixedType;
use PHPStan\Type\NeverType;
use PHPStan\Type\ObjectWithoutClassType;
use PHPStan\Type\ResourceType;
use PHPStan\Type\StaticMethodParameterClosureThisExtension;
Expand All @@ -59,6 +61,7 @@
use PHPStan\Type\ThisType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\TypeTraverser;
use PHPStan\Type\TypeUtils;
use function array_keys;
use function array_last;
Expand Down Expand Up @@ -234,7 +237,7 @@ public function processArgs(
// resolution (see gatherClosureArgType()).
$originalArgForGather = $arg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $arg;
$gatheredArgTypeByIndex[$i] = $typeDrivenAcceptorSelection
? $this->gatherClosureArgType($parametersAcceptors, $i, $arg->value, $scope)
? $this->gatherClosureArgType($nodeScopeResolver, $args, $parametersAcceptors, $namedArgumentsVariants, $gatheredArgTypeByIndex, $i, $arg->value, $scope)
: $this->closureTypeResolver->getClosureType($scope, $arg->value, true, $storage);
$this->addGatheredArgType($gatheredTypes, $gatheredUnpack, $gatheredHasName, $originalArgForGather, $i, $gatheredArgTypeByIndex[$i]);
}
Expand Down Expand Up @@ -957,9 +960,26 @@ private function argConsumesResolvedParameterType(Expr $value): bool
* resolved from sibling args. That keeps the inferred return type (the U in
* callable(T): U) faithful to the closure's own declaration.
*
* A template of the callee that the raw parameter leaks into the closure
* type (the T in callable(T): T, read back from a closure returning its
* parameter) would infer T from T itself, so it is resolved from the other
* arguments gathered so far instead (see resolveCalleeTemplateTypes()).
*
* @param Node\Arg[] $args
* @param ParametersAcceptor[] $parametersAcceptors
* @param ParametersAcceptor[]|null $namedArgumentsVariants
* @param array<int, Type> $gatheredArgTypeByIndex
*/
private function gatherClosureArgType(array $parametersAcceptors, int $i, Expr $closureExpr, MutatingScope $scope): Type
private function gatherClosureArgType(
NodeScopeResolver $nodeScopeResolver,
array $args,
array $parametersAcceptors,
?array $namedArgumentsVariants,
array $gatheredArgTypeByIndex,
int $i,
Expr $closureExpr,
MutatingScope $scope,
): Type
{
$rawParameter = null;
if (count($parametersAcceptors) === 1) {
Expand All @@ -971,11 +991,76 @@ private function gatherClosureArgType(array $parametersAcceptors, int $i, Expr $
}
}

if ($rawParameter !== null) {
$scope = $scope->pushInFunctionCall(null, $rawParameter, false);
if ($rawParameter === null) {
return $this->closureParameterResolver->resolveCallableTypeForScope($closureExpr, $scope);
}

$closureType = $this->closureParameterResolver->resolveCallableTypeForScope(
$closureExpr,
$scope->pushInFunctionCall(null, $rawParameter, false),
);

$calleeTemplateTypeMap = $parametersAcceptors[0]->getTemplateTypeMap();
if ($calleeTemplateTypeMap->isEmpty() || !self::referencesCalleeTemplateTypes($closureType, $calleeTemplateTypeMap)) {
return $closureType;
}

$siblingTypes = [];
$siblingUnpack = false;
$siblingHasName = false;
foreach ($args as $j => $siblingArg) {
$siblingOriginalArg = $siblingArg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $siblingArg;
$this->addGatheredArgType($siblingTypes, $siblingUnpack, $siblingHasName, $siblingOriginalArg, $j, $j !== $i ? ($gatheredArgTypeByIndex[$j] ?? new MixedType()) : new MixedType());
}

return self::resolveCalleeTemplateTypes(
$closureType,
$calleeTemplateTypeMap,
$this->selectArgsMetadataAcceptor($nodeScopeResolver, $args, $siblingTypes, $parametersAcceptors, $namedArgumentsVariants, $siblingHasName, $siblingUnpack, $scope)->getResolvedTemplateTypeMap(),
);
}

private static function referencesCalleeTemplateTypes(Type $type, TemplateTypeMap $calleeTemplateTypeMap): bool
{
if (!$type->hasTemplateOrLateResolvableType()) {
return false;
}

foreach ($type->getReferencedTemplateTypes(TemplateTypeVariance::createInvariant()) as $reference) {
if (self::isCalleeTemplateType($reference->getType(), $calleeTemplateTypeMap)) {
return true;
}
}

return $this->closureParameterResolver->resolveCallableTypeForScope($closureExpr, $scope);
return false;
}

/**
* Replaces the callee's own template types with what the other arguments
* resolve them to - their default or bound when they say nothing about them,
* or pass no value of them at all (never, from an empty array).
*/
private static function resolveCalleeTemplateTypes(Type $type, TemplateTypeMap $calleeTemplateTypeMap, TemplateTypeMap $resolvedTemplateTypeMap): Type
{
return TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($calleeTemplateTypeMap, $resolvedTemplateTypeMap): Type {
if (!$type instanceof TemplateType || !self::isCalleeTemplateType($type, $calleeTemplateTypeMap)) {
return $traverse($type);
}

$resolvedType = $resolvedTemplateTypeMap->getType($type->getName());
if ($resolvedType === null || $resolvedType instanceof ErrorType || $resolvedType instanceof NeverType) {
return $traverse($type->getDefault() ?? $type->getBound());
}

return $resolvedType;
});
}

private static function isCalleeTemplateType(TemplateType $type, TemplateTypeMap $calleeTemplateTypeMap): bool
{
$calleeTemplateType = $calleeTemplateTypeMap->getType($type->getName());

return $calleeTemplateType instanceof TemplateType && $calleeTemplateType->getScope()->equals($type->getScope());
}

/**
Expand Down
105 changes: 105 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-15654.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php // lint >= 8.0

declare(strict_types = 1);

namespace Bug15654;

use function PHPStan\Testing\assertType;

/**
* @template T
* @param list<T> $list
* @param callable(T): T $fn
*/
function apply(array $list, callable $fn): void {}

apply(['a', 'b'], static function (string $s): string {
assertType('\'a\'|\'b\'', $s);
echo match ($s) { 'a' => 1, 'b' => 2 };
return $s;
});

apply(['a', 'b'], static function (string $s): string {
assertType('\'a\'|\'b\'', $s);
echo match ($s) { 'a' => 1, 'b' => 2, default => throw new \LogicException() };
return $s;
});

apply(['a', 'b'], static function ($s) {
assertType('\'a\'|\'b\'', $s);
return $s;
});

apply([], static function (string $s): string {
assertType('string', $s);
return $s;
});

/**
* @template T
* @param list<T> $list
* @param callable(T): T $fn
* @return list<T>
*/
function map(array $list, callable $fn): array
{
return $list;
}

function (): void {
assertType('list<\'a\'|\'b\'>', map(['a', 'b'], static fn (string $s): string => $s));
assertType('list<non-falsy-string>', map(['a', 'b'], static fn (string $s): string => $s . 'x'));
};

/**
* @template T
* @param callable(T): T $fn
* @return T
*/
function only(callable $fn)
{
return $fn(null);
}

function (): void {
assertType('string', only(static fn (string $s): string => $s));
assertType('mixed', only(static fn ($s) => $s));
};

class Collection
{

/**
* @template T
* @param list<T> $list
* @param callable(T): T $fn
* @return list<T>
*/
public function map(array $list, callable $fn): array
{
return $list;
}

/**
* @template T
* @param list<T> $list
* @param callable(T): T $fn
* @return list<T>
*/
public static function staticMap(array $list, callable $fn): array
{
return $list;
}

}

function (Collection $c): void {
assertType('list<\'a\'|\'b\'>', $c->map(['a', 'b'], static function (string $s): string {
assertType('\'a\'|\'b\'', $s);
return $s;
}));
assertType('list<\'a\'|\'b\'>', Collection::staticMap(['a', 'b'], static function (string $s): string {
assertType('\'a\'|\'b\'', $s);
return $s;
}));
};
12 changes: 12 additions & 0 deletions tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,18 @@ public function testBug12241(): void
$this->analyse([__DIR__ . '/data/bug-12241.php'], []);
}

#[RequiresPhp('>= 8.0.0')]
public function testBug15654(): void
{
$this->analyse([__DIR__ . '/data/bug-15654.php'], [
[
'Match arm comparison between \'b\' and \'b\' is always true.',
20,
'Remove remaining cases below this one and this error will disappear too.',
],
]);
}

#[RequiresPhp('>= 8.0.0')]
public function testBug14412(): void
{
Expand Down
22 changes: 22 additions & 0 deletions tests/PHPStan/Rules/Comparison/data/bug-15654.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php // lint >= 8.0

declare(strict_types = 1);

namespace Bug15654Match;

/**
* @template T
* @param list<T> $list
* @param callable(T): T $fn
*/
function apply(array $list, callable $fn): void {}

apply(['a', 'b'], static function (string $s): string {
echo match ($s) { 'a' => 1, 'b' => 2 };
return $s;
});

apply(['a', 'b'], static function (string $s): string {
echo match ($s) { 'a' => 1, 'b' => 2, default => throw new \LogicException() };
return $s;
});
Loading
Loading