From 0b49c763e3bba516dd8c0f1183fb7969e7db3aa5 Mon Sep 17 00:00:00 2001 From: Dorian Villet Date: Sat, 7 Nov 2026 11:06:34 +0100 Subject: [PATCH] Type the array_reduce() carry as a loop variable The callback's carry starts as the initial value and grows by what the closure returns for it, widened with the loop's generalization rules, until the closure adds nothing new. The closure body is walked with that carry, and the call returns what the closure returns for it - or the initial value too, when the array may be empty. A closure whose return type comes out as an implicit never (its body's inference gave up) says nothing about the carry, so it keeps the callback's declared typing instead of passing for a fixed point. Co-Authored-By: Claude Opus 5.5 --- src/Type/Php/ArrayReduceCarryTypeResolver.php | 129 ++++++++++++++++++ ...ArrayReduceFunctionReturnTypeExtension.php | 54 ++++---- ...rayReduceParameterClosureTypeExtension.php | 43 ++++++ .../PHPStan/Analyser/nsrt/array-functions.php | 4 +- tests/PHPStan/Analyser/nsrt/bug-15388.php | 8 +- tests/PHPStan/Analyser/nsrt/bug-7280.php | 116 ++++++++++++++++ .../CallToFunctionParametersRuleTest.php | 6 + .../data/array-reduce-inferred-carry.php | 17 +++ 8 files changed, 343 insertions(+), 34 deletions(-) create mode 100644 src/Type/Php/ArrayReduceCarryTypeResolver.php create mode 100644 src/Type/Php/ArrayReduceParameterClosureTypeExtension.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-7280.php create mode 100644 tests/PHPStan/Rules/Functions/data/array-reduce-inferred-carry.php 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..3cf3db2ebae 100644 --- a/src/Type/Php/ArrayReduceFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayReduceFunctionReturnTypeExtension.php @@ -7,17 +7,21 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; use PHPStan\Reflection\ParametersAcceptorSelector; -use PHPStan\TrinaryLogic; use PHPStan\Type\DynamicFunctionReturnTypeExtension; 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 +34,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 +56,12 @@ 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; - } + $iterableAtLeastOnce = $scope->getType($args[0]->value)->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..1cbdb181e4d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-7280.php @@ -0,0 +1,116 @@ += 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); +} 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; + }, []); +}