diff --git a/src/Type/Php/ArrayReduceCarryTypeResolver.php b/src/Type/Php/ArrayReduceCarryTypeResolver.php new file mode 100644 index 00000000000..34661a2a1e8 --- /dev/null +++ b/src/Type/Php/ArrayReduceCarryTypeResolver.php @@ -0,0 +1,129 @@ +getArgs(); + if (!isset($args[0], $args[1]) || $args[0]->unpack || $args[1]->unpack) { + return null; + } + + $callback = $args[1]->value; + if (!$callback instanceof Closure && !$callback instanceof ArrowFunction) { + return null; + } + if (count($callback->params) < 1) { + return null; + } + foreach ($callback->params as $param) { + if ($param->byRef || $param->variadic) { + return null; + } + } + + $itemType = $scope->getIterableValueType($scope->getType($args[0]->value)); + $carryType = isset($args[2]) ? $scope->getType($args[2]->value) : new NullType(); + + for ($count = 0;; $count++) { + $returnType = $this->getReturnType($scope, $callback, $carryType, $itemType); + if ($returnType instanceof NeverType && !$returnType->isExplicit()) { + // the body's inference gave up, which tells nothing about what it returns + return null; + } + + $nextCarryType = TypeCombinator::union($carryType, $returnType); + if ($nextCarryType->equals($carryType)) { + return ['carry' => $carryType, 'item' => $itemType, 'return' => $returnType]; + } + + if ($count >= NodeScopeResolver::LOOP_SCOPE_ITERATIONS) { + // no fixed point: a carry type the closure can still outgrow would be unsound + return null; + } + + if ($count >= NodeScopeResolver::GENERALIZE_AFTER_ITERATION) { + $nextCarryType = $this->generalize($scope, $carryType, $nextCarryType); + } + + $carryType = $nextCarryType; + } + } + + public static function createCallbackParameter(string $name, Type $type): NativeParameterReflection + { + return new NativeParameterReflection($name, false, $type, PassedByReference::createNo(), false, null); + } + + private function getReturnType(MutatingScope $scope, Closure|ArrowFunction $callback, Type $carryType, Type $itemType): Type + { + $passedToType = new CallableType( + [ + self::createCallbackParameter('carry', $carryType), + self::createCallbackParameter('item', $itemType), + ], + new MixedType(), + false, + ); + + return $this->closureTypeResolver->getClosureType($scope, $callback, false, $scope->getCurrentExpressionResultStorage(), $passedToType)->getReturnType(); + } + + private function generalize(MutatingScope $scope, Type $previousCarryType, Type $carryType): Type + { + $yes = TrinaryLogic::createYes(); + $previousScope = $scope->assignVariable(self::CARRY_VARIABLE_NAME, $previousCarryType, $previousCarryType, $yes); + $nextScope = $scope->assignVariable(self::CARRY_VARIABLE_NAME, $carryType, $carryType, $yes); + + return $previousScope->generalizeWith($nextScope)->getVariableType(self::CARRY_VARIABLE_NAME); + } + +} diff --git a/src/Type/Php/ArrayReduceFunctionReturnTypeExtension.php b/src/Type/Php/ArrayReduceFunctionReturnTypeExtension.php index a38f2801dfc..7e156f66003 100644 --- a/src/Type/Php/ArrayReduceFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayReduceFunctionReturnTypeExtension.php @@ -7,17 +7,23 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; use PHPStan\Reflection\ParametersAcceptorSelector; -use PHPStan\TrinaryLogic; +use PHPStan\Type\ArrayType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; +use PHPStan\Type\MixedType; use PHPStan\Type\NullType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; -use function count; #[AutowiredService] final class ArrayReduceFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension { + public function __construct( + private ArrayReduceCarryTypeResolver $carryTypeResolver, + ) + { + } + public function isFunctionSupported(FunctionReflection $functionReflection): bool { return $functionReflection->getName() === 'array_reduce'; @@ -30,16 +36,21 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return null; } - $callbackType = $scope->getType($args[1]->value); - if ($callbackType->isCallable()->no()) { - return null; - } + $resolvedCarry = $this->carryTypeResolver->resolve($functionCall, $scope); + if ($resolvedCarry !== null) { + $callbackReturnType = $resolvedCarry['return']; + } else { + $callbackType = $scope->getType($args[1]->value); + if ($callbackType->isCallable()->no()) { + return null; + } - $callbackReturnType = ParametersAcceptorSelector::selectFromArgs( - $scope, - $args, - $callbackType->getCallableParametersAcceptors($scope), - )->getReturnType(); + $callbackReturnType = ParametersAcceptorSelector::selectFromArgs( + $scope, + $args, + $callbackType->getCallableParametersAcceptors($scope), + )->getReturnType(); + } if (isset($args[2])) { $initialType = $scope->getType($args[2]->value); @@ -47,23 +58,15 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $initialType = new NullType(); } - $arraysType = $scope->getType($args[0]->value); - $constantArrays = $arraysType->getConstantArrays(); - if (count($constantArrays) > 0) { - $onlyEmpty = TrinaryLogic::createYes(); - $onlyNonEmpty = TrinaryLogic::createYes(); - foreach ($constantArrays as $constantArray) { - $iterableAtLeastOnce = $constantArray->isIterableAtLeastOnce(); - $onlyEmpty = $onlyEmpty->and($iterableAtLeastOnce->negate()); - $onlyNonEmpty = $onlyNonEmpty->and($iterableAtLeastOnce); - } - - if ($onlyEmpty->yes()) { - return $initialType; - } - if ($onlyNonEmpty->yes()) { - return $callbackReturnType; - } + // array_reduce() throws on anything but an array, so only the arrays + // it is given decide whether the initial value can come back + $arrayType = TypeCombinator::intersect($scope->getType($args[0]->value), new ArrayType(new MixedType(), new MixedType())); + $iterableAtLeastOnce = $arrayType->isIterableAtLeastOnce(); + if ($iterableAtLeastOnce->no()) { + return $initialType; + } + if ($iterableAtLeastOnce->yes()) { + return $callbackReturnType; } return TypeCombinator::union($callbackReturnType, $initialType); diff --git a/src/Type/Php/ArrayReduceParameterClosureTypeExtension.php b/src/Type/Php/ArrayReduceParameterClosureTypeExtension.php new file mode 100644 index 00000000000..59ca065d2ef --- /dev/null +++ b/src/Type/Php/ArrayReduceParameterClosureTypeExtension.php @@ -0,0 +1,43 @@ +getName() === 'array_reduce' && $parameter->getName() === 'callback'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $resolved = $this->carryTypeResolver->resolve($functionCall, $scope); + if ($resolved === null) { + return null; + } + + return new ClosureType([ + ArrayReduceCarryTypeResolver::createCallbackParameter('carry', $resolved['carry']), + ArrayReduceCarryTypeResolver::createCallbackParameter('item', $resolved['item']), + ], new MixedType()); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/array-functions.php b/tests/PHPStan/Analyser/nsrt/array-functions.php index 175a8ab6620..4cefe03a2c9 100644 --- a/tests/PHPStan/Analyser/nsrt/array-functions.php +++ b/tests/PHPStan/Analyser/nsrt/array-functions.php @@ -198,10 +198,10 @@ assertType('non-empty-array<0|1|2, 1|2|3>', $uniquedIntegers); assertType('1|2|3', $uniquedIntegers[1]); assertType('string', $reducedIntegersToString); -assertType('string|null', $reducedIntegersToStringWithNull); +assertType('string', $reducedIntegersToStringWithNull); assertType('string', $reducedIntegersToStringAnother); assertType('null', $reducedToNull); -assertType('1|string', $reducedIntegersToStringWithInt); +assertType('string', $reducedIntegersToStringWithInt); assertType('1', $reducedToInt); assertType('array{1, 2, 3}', array_change_key_case($integers)); assertType('array', array_combine($array, $array2)); diff --git a/tests/PHPStan/Analyser/nsrt/bug-15388.php b/tests/PHPStan/Analyser/nsrt/bug-15388.php index 4ef52f1748c..24996c2ef25 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15388.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15388.php @@ -7,10 +7,10 @@ /** @param list $vals */ function foo(array $vals): int { $result = array_reduce($vals, function($carry, $val) { - assertType('(float|int)', $carry + map($val)); + assertType('int', $carry + map($val)); return $carry + map($val); }, 0); - assertType('(float|int)', $result); + assertType('int', $result); return $result; } @@ -22,10 +22,10 @@ function map(int $val): int { /** @param list $vals */ function bar(array $vals): int { $result = array_reduce($vals, function($carry, $val) { - assertType('(float|int)', $carry + $val); + assertType('int', $carry + $val); return $carry + $val; }, 0); - assertType('(float|int)', $result); + assertType('int', $result); return $result; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-7280.php b/tests/PHPStan/Analyser/nsrt/bug-7280.php new file mode 100644 index 00000000000..308fba99eb0 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-7280.php @@ -0,0 +1,134 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug7280; + +use function PHPStan\Testing\assertType; + +function doFoo(): void +{ + $result = array_reduce( + ['test1', 'test2'], + static function (array $carry, string $value): array{ + assertType("array{starts: list<'test1'|'test2'>, ends: list<'test1'|'test2'>}", $carry); + $carry['starts'][] = $value; + $carry['ends'][] = $value; + + return $carry; + }, + initial: ['starts' => [], 'ends' => []], + ); + assertType("array{starts: non-empty-list<'test1'|'test2'>, ends: non-empty-list<'test1'|'test2'>}", $result); +} + +function doBar(): void +{ + /** + * @var array $numbers + */ + $numbers = [1, 2]; + + $sum = array_reduce( + $numbers, + fn ($curr, $n) => $curr + $n, + 0 + ); + assertType('int', $sum); +} + +class Foo +{ + + public int $count = 0; + +} + +/** + * @param list $strings + * @param non-empty-list $nonEmptyStrings + * @param list> $matrix + */ +function doBaz(array $strings, array $nonEmptyStrings, array $matrix): void +{ + $lengths = array_reduce($strings, function ($carry, $string) { + assertType('array>', $carry); + $carry[$string] = strlen($string); + return $carry; + }, []); + assertType('array>', $lengths); + + $grouped = array_reduce($strings, static function (array $carry, string $string): array { + assertType('array>', $carry); + $carry[$string[0] ?? ''][] = $string; + return $carry; + }, []); + assertType('array>', $grouped); + + $count = array_reduce($strings, fn ($carry) => $carry + 1, 0); + assertType('int<0, max>', $count); + + $nonEmptyCount = array_reduce($nonEmptyStrings, fn ($carry) => $carry + 1, 0); + assertType('int<1, max>', $nonEmptyCount); + + $longest = array_reduce($strings, static function (?string $carry, string $string): string { + assertType('string|null', $carry); + return $carry === null || strlen($string) > strlen($carry) ? $string : $carry; + }); + assertType('string|null', $longest); + + $sum = array_reduce($matrix, fn ($carry, $row) => $carry + array_reduce($row, fn ($rowCarry, $cell) => $rowCarry + $cell, 0), 0); + assertType('int', $sum); + + $foo = array_reduce($strings, static function (Foo $carry, string $string): Foo { + $carry->count++; + return $carry; + }, new Foo()); + assertType('Bug7280\Foo', $foo); + + $flipFlop = array_reduce($strings, static fn ($carry) => $carry === false ? 1 : 'one', false); + assertType("1|'one'|false", $flipFlop); + + $fromEmpty = array_reduce([], static fn ($carry, $item) => $carry . $item, 'initial'); + assertType("'initial'", $fromEmpty); +} + +/** @param list $strings */ +function doNever(array $strings): void +{ + // the body's inference gives up on $carry[count($carry)], so the carry is not inferred from it + $untyped = array_reduce($strings, function ($carry, $string) { + $carry[count($carry)] = $string; + return $carry; + }, []); + assertType('mixed', $untyped); + + $typed = array_reduce($strings, static function (array $carry, string $string): array { + $carry[count($carry)] = $string; + return $carry; + }, []); + assertType('array', $typed); + + $alwaysThrows = array_reduce($strings, static function ($carry, $string) { + throw new \Exception(); + }, 0); + assertType('0', $alwaysThrows); +} + +/** @param list $integers */ +function callableString(array $integers, string $callback): void +{ + // the callback may be a function name, whose return type is unknown + assertType('mixed', array_reduce($integers, $callback, 0)); +} + +/** @param non-empty-array|object $items */ +function nonArrayMember($items): void +{ + // array_reduce() throws on the object, so only the non-empty array reaches the callback and the initial null cannot come back + $byId = array_reduce($items, function ($carry, $item) { + $carry[$item->id][] = $item; + return $carry; + }); + assertType('non-empty-array>', $byId); +} diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 807b408c77b..8b176d7f6ed 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -695,6 +695,12 @@ public function testArrayReduceCallback(): void ]); } + #[RequiresPhp('>= 8.0.0')] + public function testArrayReduceInferredCarry(): void + { + $this->analyse([__DIR__ . '/data/array-reduce-inferred-carry.php'], []); + } + public function testArrayReduceArrowFunctionCallback(): void { $this->analyse([__DIR__ . '/data/array_reduce_arrow.php'], [ diff --git a/tests/PHPStan/Rules/Functions/data/array-reduce-inferred-carry.php b/tests/PHPStan/Rules/Functions/data/array-reduce-inferred-carry.php new file mode 100644 index 00000000000..2b4e8da299e --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/array-reduce-inferred-carry.php @@ -0,0 +1,17 @@ += 8.0 + +declare(strict_types = 1); + +namespace ArrayReduceInferredCarry; + +/** @param list $strings */ +function doFoo(array $strings): void +{ + $concatenated = array_reduce($strings, fn ($carry, $string) => $carry . $string, 'a'); + $named = array_reduce(array: $strings, callback: fn ($carry, $string) => $carry . $string, initial: 'a'); + $typed = array_reduce($strings, static fn (string $carry, string $string): string => $carry . $string, ''); + $set = array_reduce($strings, static function (array $carry, string $string): array { + $carry[$string] = true; + return $carry; + }, []); +}