Skip to content
161 changes: 161 additions & 0 deletions src/Type/Php/ArrayAllAnyNarrowingHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type\Php;

use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\MutatingScope;
use PHPStan\Analyser\SpecifiedTypes;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\ArrayType;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
use PHPStan\Type\NeverType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use function count;
use function in_array;

/**
* Builds the narrowed array type for `array_all` (truthy) and `array_any`
* (falsey): every element is required to satisfy (array_all) or fail
* (array_any) the predicate, so element value/key types are refined
* accordingly.
*
* When narrowing rejects a general element, the member becomes `array{}`
* (an empty array still makes `array_all` true / `array_any` false) rather than
* `never` - the caller's scope intersection turns a genuinely non-empty array
* into `never` on its own.
*/
#[AutowiredService]
final class ArrayAllAnyNarrowingHelper
{

public function __construct(
private ArrayPredicateCallbackResolver $predicateCallbackResolver,
)
{
}

/**
* @param bool $truthy true narrows elements by the predicate being truthy
* (array_all), false by the predicate being falsey
* (array_any).
*/
public function narrowArrayType(MutatingScope $scope, Type $arrayType, ArrayCallbackPredicate $predicate, bool $truthy): ?Type
{
if ($predicate->getExpr() === null) {
return null;
}

$arrays = $arrayType->getArrays();
if (count($arrays) === 0) {
return null;
}

$results = [];
foreach ($arrays as $array) {
$constantArrays = $array->getConstantArrays();
if (count($constantArrays) > 0) {
foreach ($constantArrays as $constantArray) {
$results[] = $this->narrowConstantArray($scope, $constantArray, $predicate, $truthy);
}
continue;
}

[$newKey, $newValue, $rejected] = $this->narrowElement($scope, $array->getIterableKeyType(), $array->getIterableValueType(), $predicate, $truthy);
if ($rejected) {
$results[] = new ConstantArrayType([], []);
continue;
}

$results[] = new ArrayType($newKey, $newValue);
}

return TypeCombinator::union(...$results);
}

/**
* The narrowing is a consequence of the call's result, not proof of it: an
* unchanged array type does not make the result certain. Only a narrowed
* type that cannot coexist with the argument's type proves the specified
* result impossible, so that is the only verdict left for the
* impossible-check rules (also when an extension asks for the falsey
* narrowing of a nested call, as an assertFalse() would).
*/
public function leaveResultUndecided(SpecifiedTypes $specifiedTypes, FuncCall $node, Type $arrayType, Type $narrowedType): SpecifiedTypes
{
if (TypeCombinator::intersect($arrayType, $narrowedType) instanceof NeverType) {
return $specifiedTypes;
}

return $specifiedTypes->setRootExpr($node);
}

private function narrowConstantArray(MutatingScope $scope, ConstantArrayType $constantArray, ArrayCallbackPredicate $predicate, bool $truthy): Type
{
$builder = ConstantArrayTypeBuilder::createEmpty();
$optionalKeys = $constantArray->getOptionalKeys();

foreach ($constantArray->getKeyTypes() as $i => $keyType) {
$itemType = $constantArray->getValueTypes()[$i];
$optional = in_array($i, $optionalKeys, true);

[, $newValue, $rejected] = $this->narrowElement($scope, $keyType, $itemType, $predicate, $truthy);
if ($rejected) {
if ($optional) {
// The predicate rejects this element, so satisfying the
// whole array requires the optional offset to be absent.
continue;
}

// A required offset that cannot satisfy the predicate makes
// the whole shape impossible.
return new NeverType();
}

$builder->setOffsetValueType($keyType, $newValue, $optional);
}

if ($constantArray->isUnsealed()->yes()) {
$unsealedTypes = $constantArray->getUnsealedTypes();
if ($unsealedTypes !== null) {
[$newKey, $newValue, $rejected] = $this->narrowElement($scope, $unsealedTypes[0], $unsealedTypes[1], $predicate, $truthy);
if (!$rejected) {
$builder->makeUnsealed($newKey, $newValue);
}
}
}

return $builder->getArray();
}

/**
* @return array{Type, Type, bool} the narrowed key and value, plus whether
* the element cannot satisfy the predicate.
*/
private function narrowElement(MutatingScope $scope, Type $keyType, Type $itemType, ArrayCallbackPredicate $predicate, bool $truthy): array
{
[$scope, $itemVarName, $keyVarName] = $this->predicateCallbackResolver->assignPredicateVariables($scope, $predicate, $itemType, $keyType);

$expr = $predicate->getExpr();
if ($expr === null) {
throw new ShouldNotHappenException();
}

$booleanResult = $scope->getType($expr)->toBoolean();
$impossible = $truthy ? $booleanResult->isFalse()->yes() : $booleanResult->isTrue()->yes();
if ($impossible) {
return [new NeverType(), new NeverType(), true];
}

$narrowedScope = $truthy ? $scope->filterByTruthyValue($expr) : $scope->filterByFalseyValue($expr);
$newKey = $keyVarName !== null ? $narrowedScope->getVariableType($keyVarName) : $keyType;
$newValue = $itemVarName !== null ? $narrowedScope->getVariableType($itemVarName) : $itemType;

$rejected = $newKey instanceof NeverType || $newValue instanceof NeverType;

return [$newKey, $newValue, $rejected];
}

}
100 changes: 100 additions & 0 deletions src/Type/Php/ArrayAllFunctionTypeSpecifyingExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type\Php;

use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\MutatingScope;
use PHPStan\Analyser\Scope;
use PHPStan\Analyser\SpecifiedTypes;
use PHPStan\Analyser\TypeSpecifier;
use PHPStan\Analyser\TypeSpecifierAwareExtension;
use PHPStan\Analyser\TypeSpecifierContext;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Type\Accessory\NonEmptyArrayType;
use PHPStan\Type\ArrayType;
use PHPStan\Type\FunctionTypeSpecifyingExtension;
use PHPStan\Type\MixedType;
use PHPStan\Type\TypeCombinator;
use function count;
use function strtolower;

/**
* array_all($array, $callback):
* - true => every element satisfies the predicate (an empty array qualifies),
* so element value/key types are narrowed by the predicate.
* - false => at least one element fails, so the array is non-empty.
*/
#[AutowiredService]
final class ArrayAllFunctionTypeSpecifyingExtension implements FunctionTypeSpecifyingExtension, TypeSpecifierAwareExtension
{

private TypeSpecifier $typeSpecifier;

public function __construct(
private ArrayPredicateCallbackResolver $predicateCallbackResolver,
private ArrayAllAnyNarrowingHelper $narrowingHelper,
)
{
}

public function isFunctionSupported(FunctionReflection $functionReflection, FuncCall $node, TypeSpecifierContext $context): bool
{
return strtolower($functionReflection->getName()) === 'array_all'
&& !$context->null();
}

public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes
{
$args = $node->getArgs();
if (count($args) < 2 || !$scope instanceof MutatingScope) {
return new SpecifiedTypes();
}

$arrayArg = $args[0]->value;
$callbackArg = $args[1]->value;

$arrayType = $scope->getType($arrayArg);
if ($arrayType->isArray()->no()) {
return new SpecifiedTypes();
}

if ($context->false()) {
// At least one element fails the predicate: the array is non-empty.
$nonEmptyType = $arrayType->isArray()->yes()
? new NonEmptyArrayType()
: TypeCombinator::intersect(new ArrayType(new MixedType(), new MixedType()), new NonEmptyArrayType());

return $this->narrowingHelper->leaveResultUndecided(
$this->typeSpecifier->create($arrayArg, $nonEmptyType, TypeSpecifierContext::createTruthy(), $scope),
$node,
$arrayType,
$nonEmptyType,
);
}

// Every element satisfies the predicate: narrow value/key types.
$predicates = $this->predicateCallbackResolver->resolve($scope, $callbackArg, ArrayCallbackParameterMapping::valueAndKey());
if ($predicates === null || count($predicates) !== 1) {
return new SpecifiedTypes();
}

$narrowedType = $this->narrowingHelper->narrowArrayType($scope, $arrayType, $predicates[0], true);
if ($narrowedType === null) {
return new SpecifiedTypes();
}

return $this->narrowingHelper->leaveResultUndecided(
$this->typeSpecifier->create($arrayArg, $narrowedType, TypeSpecifierContext::createTruthy(), $scope),
$node,
$arrayType,
$narrowedType,
);
}

public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void
{
$this->typeSpecifier = $typeSpecifier;
}

}
102 changes: 102 additions & 0 deletions src/Type/Php/ArrayAnyFunctionTypeSpecifyingExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type\Php;

use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\MutatingScope;
use PHPStan\Analyser\Scope;
use PHPStan\Analyser\SpecifiedTypes;
use PHPStan\Analyser\TypeSpecifier;
use PHPStan\Analyser\TypeSpecifierAwareExtension;
use PHPStan\Analyser\TypeSpecifierContext;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Type\Accessory\NonEmptyArrayType;
use PHPStan\Type\ArrayType;
use PHPStan\Type\FunctionTypeSpecifyingExtension;
use PHPStan\Type\MixedType;
use PHPStan\Type\TypeCombinator;
use function count;
use function strtolower;

/**
* array_any($array, $callback):
* - true => at least one element satisfies the predicate, so the array is
* non-empty (holds even when the callback cannot be analysed).
* - false => no element satisfies the predicate (an empty array qualifies), so
* element value/key types are narrowed by the predicate being falsey.
*/
#[AutowiredService]
final class ArrayAnyFunctionTypeSpecifyingExtension implements FunctionTypeSpecifyingExtension, TypeSpecifierAwareExtension
{

private TypeSpecifier $typeSpecifier;

public function __construct(
private ArrayPredicateCallbackResolver $predicateCallbackResolver,
private ArrayAllAnyNarrowingHelper $narrowingHelper,
)
{
}

public function isFunctionSupported(FunctionReflection $functionReflection, FuncCall $node, TypeSpecifierContext $context): bool
{
return strtolower($functionReflection->getName()) === 'array_any'
&& !$context->null();
}

public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes
{
$args = $node->getArgs();
if (count($args) < 2 || !$scope instanceof MutatingScope) {
return new SpecifiedTypes();
}

$arrayArg = $args[0]->value;
$callbackArg = $args[1]->value;

$arrayType = $scope->getType($arrayArg);
if ($arrayType->isArray()->no()) {
return new SpecifiedTypes();
}

if ($context->false()) {
// No element satisfies the predicate: narrow value/key types by the
// predicate being falsey.
$predicates = $this->predicateCallbackResolver->resolve($scope, $callbackArg, ArrayCallbackParameterMapping::valueAndKey());
if ($predicates === null || count($predicates) !== 1) {
return new SpecifiedTypes();
}

$narrowedType = $this->narrowingHelper->narrowArrayType($scope, $arrayType, $predicates[0], false);
if ($narrowedType === null) {
return new SpecifiedTypes();
}

return $this->narrowingHelper->leaveResultUndecided(
$this->typeSpecifier->create($arrayArg, $narrowedType, TypeSpecifierContext::createTruthy(), $scope),
$node,
$arrayType,
$narrowedType,
);
}

// At least one element satisfies the predicate: the array is non-empty.
$nonEmptyType = $arrayType->isArray()->yes()
? new NonEmptyArrayType()
: TypeCombinator::intersect(new ArrayType(new MixedType(), new MixedType()), new NonEmptyArrayType());

return $this->narrowingHelper->leaveResultUndecided(
$this->typeSpecifier->create($arrayArg, $nonEmptyType, TypeSpecifierContext::createTruthy(), $scope),
$node,
$arrayType,
$nonEmptyType,
);
}

public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void
{
$this->typeSpecifier = $typeSpecifier;
}

}
Loading
Loading