Skip to content
Closed
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);
}

}
54 changes: 26 additions & 28 deletions src/Type/Php/ArrayReduceFunctionReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,40 +34,34 @@
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()) {

Check warning on line 42 in src/Type/Php/ArrayReduceFunctionReturnTypeExtension.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $callbackReturnType = $resolvedCarry['return']; } else { $callbackType = $scope->getType($args[1]->value); - if ($callbackType->isCallable()->no()) { + if (!$callbackType->isCallable()->yes()) { return null; }

Check warning on line 42 in src/Type/Php/ArrayReduceFunctionReturnTypeExtension.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $callbackReturnType = $resolvedCarry['return']; } else { $callbackType = $scope->getType($args[1]->value); - if ($callbackType->isCallable()->no()) { + if (!$callbackType->isCallable()->yes()) { return null; }
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;
}
$iterableAtLeastOnce = $scope->getType($args[0]->value)->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