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

namespace PHPStan\Type\Php;

use PHPStan\Analyser\MutatingScope;
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);
}

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];
}

}
90 changes: 90 additions & 0 deletions src/Type/Php/ArrayAllFunctionTypeSpecifyingExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?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()) {

Check warning on line 62 in src/Type/Php/ArrayAllFunctionTypeSpecifyingExtension.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "PHPStan\Infection\TrueTruthyFalseFalseyTypeSpecifierContextMutator": @@ @@ return new SpecifiedTypes(); } - if ($context->false()) { + if ($context->falsey()) { // At least one element fails the predicate: the array is non-empty. $nonEmptyType = $arrayType->isArray()->yes() ? new NonEmptyArrayType()
// 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->typeSpecifier->create($arrayArg, $nonEmptyType, TypeSpecifierContext::createTruthy(), $scope);
}

// 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->typeSpecifier->create($arrayArg, $narrowedType, TypeSpecifierContext::createTruthy(), $scope);
}

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

}
92 changes: 92 additions & 0 deletions src/Type/Php/ArrayAnyFunctionTypeSpecifyingExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?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->typeSpecifier->create($arrayArg, $narrowedType, TypeSpecifierContext::createTruthy(), $scope);
}

// 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->typeSpecifier->create($arrayArg, $nonEmptyType, TypeSpecifierContext::createTruthy(), $scope);
}

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

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

namespace PHPStan\Type\Php;

/**
* Describes which positional parameters of an array callback receive the
* element value and the element key. `array_filter` varies this by its flag
* argument (USE_ITEM / USE_KEY / USE_BOTH), while `array_all` / `array_any`
* always pass value first and key second.
*/
final class ArrayCallbackParameterMapping
{

private function __construct(
private ?int $itemPosition,
private ?int $keyPosition,
)
{
}

public static function item(): self
{
return new self(0, null);
}

public static function key(): self
{
return new self(null, 0);
}

public static function valueAndKey(): self
{
return new self(0, 1);
}

public function getItemPosition(): ?int
{
return $this->itemPosition;
}

public function getKeyPosition(): ?int
{
return $this->keyPosition;
}

}
Loading
Loading