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
129 changes: 129 additions & 0 deletions src/Type/Php/ArrayReduceCarryTypeResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type\Php;

use PhpParser\Node\Expr\ArrowFunction;
use PhpParser\Node\Expr\Closure;
use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver;
use PHPStan\Analyser\MutatingScope;
use PHPStan\Analyser\NodeScopeResolver;
use PHPStan\Analyser\Scope;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Reflection\Native\NativeParameterReflection;
use PHPStan\Reflection\PassedByReference;
use PHPStan\TrinaryLogic;
use PHPStan\Type\CallableType;
use PHPStan\Type\MixedType;
use PHPStan\Type\NeverType;
use PHPStan\Type\NullType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use function count;

/**
* Types the carry of an array_reduce() closure the way a foreach loop types
* a variable it writes on every pass: the carry starts as the initial value
* and grows by what the closure returns for it, widened like a loop variable,
* until what the closure returns adds nothing new.
*/
#[AutowiredService]
final class ArrayReduceCarryTypeResolver
{

/**
* Holds the carry types of two passes in a throwaway scope, so that
* they are widened by the very rules that widen a loop variable.
*/
private const CARRY_VARIABLE_NAME = '__phpstanArrayReduceCarry';

public function __construct(
private ClosureTypeResolver $closureTypeResolver,
)
{
}

/**
* @return array{carry: Type, item: Type, return: Type}|null null when the callback is not a closure or arrow function written in the call
*/
public function resolve(FuncCall $functionCall, Scope $scope): ?array
{
if (!$scope instanceof MutatingScope) {
return null;
}

$args = $functionCall->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);
}

}
59 changes: 31 additions & 28 deletions src/Type/Php/ArrayReduceFunctionReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,40 +36,37 @@ 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);
} else {
$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);
Expand Down
43 changes: 43 additions & 0 deletions src/Type/Php/ArrayReduceParameterClosureTypeExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type\Php;

use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Reflection\ParameterReflection;
use PHPStan\Type\ClosureType;
use PHPStan\Type\FunctionParameterClosureTypeExtension;
use PHPStan\Type\MixedType;
use PHPStan\Type\Type;

#[AutowiredService]
final class ArrayReduceParameterClosureTypeExtension implements FunctionParameterClosureTypeExtension
{

public function __construct(
private ArrayReduceCarryTypeResolver $carryTypeResolver,
)
{
}

public function isFunctionSupported(FunctionReflection $functionReflection, ParameterReflection $parameter): bool
{
return $functionReflection->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());
}

}
4 changes: 2 additions & 2 deletions tests/PHPStan/Analyser/nsrt/array-functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
8 changes: 4 additions & 4 deletions tests/PHPStan/Analyser/nsrt/bug-15388.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
/** @param list<int> $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;
}
Expand All @@ -22,10 +22,10 @@ function map(int $val): int {
/** @param list<int> $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;
}
Loading
Loading