From deeffcc966008a0408dad63b2460c6c04d4f9300 Mon Sep 17 00:00:00 2001 From: uekann Date: Fri, 10 Jul 2026 18:14:20 +0900 Subject: [PATCH 1/4] Specify array element types from array_all() callback Supports arrow functions and single-return closures, e.g. array_all($arr, fn ($v, $k) => is_int($v) && is_string($k)). Partially resolves https://github.com/phpstan/phpstan/issues/12939 Original work from https://github.com/phpstan/phpstan-src/pull/5134, squashed and rebased onto 2.2.x. --- ...rrayAllFunctionTypeSpecifyingExtension.php | 112 ++++++++++ tests/PHPStan/Analyser/nsrt/array-all.php | 191 ++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 src/Type/Php/ArrayAllFunctionTypeSpecifyingExtension.php create mode 100644 tests/PHPStan/Analyser/nsrt/array-all.php diff --git a/src/Type/Php/ArrayAllFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayAllFunctionTypeSpecifyingExtension.php new file mode 100644 index 00000000000..87dd6811582 --- /dev/null +++ b/src/Type/Php/ArrayAllFunctionTypeSpecifyingExtension.php @@ -0,0 +1,112 @@ +getName()) === 'array_all' + && !$context->null(); + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $args = $node->getArgs(); + if (!$context->truthy() || count($args) < 2) { + return new SpecifiedTypes(); + } + + $array = $args[0]->value; + $callable = $args[1]->value; + if ($callable instanceof Expr\ArrowFunction) { + $callableExpr = $callable->expr; + } elseif ( + $callable instanceof Expr\Closure && + count($callable->stmts) === 1 && + $callable->stmts[0] instanceof Stmt\Return_ && + isset($callable->stmts[0]->expr) + ) { + $callableExpr = $callable->stmts[0]->expr; + } else { + return new SpecifiedTypes(); + } + + $callableParams = $callable->params; + $specifiedTypesInFuncCall = $this->typeSpecifier->specifyTypesInCondition($scope, $callableExpr, $context)->getSureTypes(); + + if ( + isset($callableParams[0]) && + $callableParams[0]->var instanceof Variable && + is_string($callableParams[0]->var->name) + ) { + $valueType = $this->fetchTypeByVariable($specifiedTypesInFuncCall, $callableParams[0]->var->name); + } + + if ( + isset($callableParams[1]) && + $callableParams[1]->var instanceof Variable && + is_string($callableParams[1]->var->name) + ) { + $keyType = $this->fetchTypeByVariable($specifiedTypesInFuncCall, $callableParams[1]->var->name); + } + + if (isset($keyType) || isset($valueType)) { + return $this->typeSpecifier->create( + $array, + new ArrayType($keyType ?? new MixedType(), $valueType ?? new MixedType()), + $context, + $scope, + ); + } + + return new SpecifiedTypes(); + } + + /** + * @param array $specifiedTypes + */ + private function fetchTypeByVariable(array $specifiedTypes, string $variableName): ?Type + { + $specifiedTypeOfKey = array_find( + $specifiedTypes, + static fn ($specifiedType) => $specifiedType[0] instanceof Expr\Variable && $specifiedType[0]->name === $variableName, + ); + + if (isset($specifiedTypeOfKey)) { + return $specifiedTypeOfKey[1]; + } + + return null; + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/array-all.php b/tests/PHPStan/Analyser/nsrt/array-all.php new file mode 100644 index 00000000000..8ec15008056 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-all.php @@ -0,0 +1,191 @@ += 8.4 + +namespace ArrayAll; + +use DateTime; +use DateTimeImmutable; + +use function PHPStan\Testing\assertType; + +class Foo { + + /** + * @param array $array + */ + public function test1($array) { + if (array_all($array, fn ($value) => is_int($value))) { + assertType("array", $array); + } else { + assertType("array", $array); + } + assertType("array", $array); + } + + /** + * @param array $array + */ + public function test2($array) { + if (array_all($array, fn ($value, $key) => is_string($key))) { + assertType("array", $array); + } else { + assertType("array", $array); + } + assertType("array", $array); + } + + /** + * @param array $array + */ + public function test3($array) { + if (array_all($array, fn ($value, $key) => is_string($key) && is_int($value))) { + assertType("array", $array); + } else { + assertType("array", $array); + } + assertType("array", $array); + } + + /** + * @param array $array + */ + public function test4($array) { + if (array_all($array, fn ($value) => is_string($value) && is_numeric($value))) { + assertType("array", $array); + } else { + assertType("array", $array); + } + assertType("array", $array); + } + + /** + * @param array $array + */ + public function test5($array) { + if (array_all($array, fn ($value) => is_bool($value) || is_float($value))) { + assertType("array", $array); + } else { + assertType("array", $array); + } + assertType("array", $array); + } + + /** + * @param array $array + */ + public function test6($array) { + if (array_all($array, fn ($value) => is_float(1))) { + assertType("array", $array); + } else { + assertType("array", $array); + } + assertType("array", $array); + } + + /** + * @param array $array + */ + public function test7($array) { + if (array_all($array, fn ($value) => $value instanceof DateTime)) { + assertType("array", $array); + } else { + assertType("array", $array); + } + assertType("array", $array); + } + + /** + * @param array $array + */ + public function test8($array) { + if (array_all($array, fn ($value) => $value instanceof DateTime || $value instanceof DateTimeImmutable)) { + assertType("array", $array); + } else { + assertType("array", $array); + } + assertType("array", $array); + } + + /** + * @param list $array + */ + public function test9($array) { + if (array_all($array, fn ($value, $key) => is_int($key))) { + assertType("list", $array); + } else { + assertType("list", $array); + } + assertType("list", $array); + } + + /** + * @param non-empty-array $array + */ + public function test10($array) { + if (array_all($array, fn ($value, $key) => is_int($key))) { + assertType("non-empty-array", $array); + } else { + assertType("non-empty-array", $array); + } + assertType("non-empty-array", $array); + } + + /** + * @param array $array + */ + public function test11($array) { + if (array_all($array, function ($value) {return is_int($value);})) { + assertType("array", $array); + } else { + assertType("array", $array); + } + assertType("array", $array); + } + + /** + * @param array $array + */ + public function test12($array) { + if (array_all($array, function ($value) {$value = 1; return is_int($value);})) { + assertType("array", $array); + } else { + assertType("array", $array); + } + assertType("array", $array); + } + + /** + * @param array $array + */ + public function test13($array) { + if (array_all($array, function ($value, $key) {return is_int($value) && is_string($key);})) { + assertType("array", $array); + } else { + assertType("array", $array); + } + assertType("array", $array); + } + + /** + * @param array $array + */ + public function test14($array) { + if (array_all($array, function ($value, $key) {return;})) { + assertType("array", $array); + } else { + assertType("array", $array); + } + assertType("array", $array); + } + + /** + * @param array $array + */ + public function test15($array) { + if (array_all($array, fn ($value) => is_int($value)) === true) { + assertType("array", $array); + } else { + assertType("array", $array); + } + assertType("array", $array); + } +} From f4f1e13e2f49fbf349f9c5deadb3d1cd5ee4e232 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 10 Jul 2026 19:17:14 +0900 Subject: [PATCH 2/4] Narrow array_all()/array_any() through the array_filter() machinery Rewrites the previous commit's extension on top of the callback handling already used by array_filter(): element value/key types are assigned onto a scope, which is then filtered by the predicate. Negated predicates, bare truthiness and @phpstan-assert-if-true now narrow uniformly, and first-class callables, constant-string callables and constant array shapes are supported. Callback normalization and per-element narrowing move into shared services so ArrayFilterFunctionReturnTypeHelper and the new extensions cannot drift apart. By-ref and variadic callback parameters no longer narrow, since the callback may rewrite the elements. Fixes unsound narrowing to never: an empty array makes array_all() true, so a rejected element narrows the array to array{} and lets the scope intersection decide whether that is impossible. array_all() === false and array_any() === true imply a non-empty array, which holds even when the callback cannot be analysed. array_any() is otherwise the dual of array_all(), narrowing elements on its falsey side. Co-authored-by: uekann Co-authored-by: Claude Fable 5 Co-authored-by: Claude Opus 4.8 --- src/Type/Php/ArrayAllAnyNarrowingHelper.php | 142 +++++++++++ ...rrayAllFunctionTypeSpecifyingExtension.php | 94 +++----- ...rrayAnyFunctionTypeSpecifyingExtension.php | 92 ++++++++ .../Php/ArrayCallbackParameterMapping.php | 46 ++++ src/Type/Php/ArrayCallbackPredicate.php | 44 ++++ .../ArrayFilterFunctionReturnTypeHelper.php | 161 +++---------- .../Php/ArrayPredicateCallbackResolver.php | 220 ++++++++++++++++++ tests/PHPStan/Analyser/nsrt/array-all.php | 160 +++++++++++-- tests/PHPStan/Analyser/nsrt/array-any.php | 150 ++++++++++++ ...mpossibleCheckTypeFunctionCallRuleTest.php | 17 ++ .../data/array-all-non-empty-list.php | 18 ++ 11 files changed, 940 insertions(+), 204 deletions(-) create mode 100644 src/Type/Php/ArrayAllAnyNarrowingHelper.php create mode 100644 src/Type/Php/ArrayAnyFunctionTypeSpecifyingExtension.php create mode 100644 src/Type/Php/ArrayCallbackParameterMapping.php create mode 100644 src/Type/Php/ArrayCallbackPredicate.php create mode 100644 src/Type/Php/ArrayPredicateCallbackResolver.php create mode 100644 tests/PHPStan/Analyser/nsrt/array-any.php create mode 100644 tests/PHPStan/Rules/Comparison/data/array-all-non-empty-list.php diff --git a/src/Type/Php/ArrayAllAnyNarrowingHelper.php b/src/Type/Php/ArrayAllAnyNarrowingHelper.php new file mode 100644 index 00000000000..84ec714cbde --- /dev/null +++ b/src/Type/Php/ArrayAllAnyNarrowingHelper.php @@ -0,0 +1,142 @@ +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]; + } + +} diff --git a/src/Type/Php/ArrayAllFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayAllFunctionTypeSpecifyingExtension.php index 87dd6811582..9828699502b 100644 --- a/src/Type/Php/ArrayAllFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ArrayAllFunctionTypeSpecifyingExtension.php @@ -2,10 +2,8 @@ namespace PHPStan\Type\Php; -use PhpParser\Node\Expr; use PhpParser\Node\Expr\FuncCall; -use PhpParser\Node\Expr\Variable; -use PhpParser\Node\Stmt; +use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; @@ -13,21 +11,33 @@ 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\Type; -use function array_find; +use PHPStan\Type\TypeCombinator; use function count; -use function is_string; 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' @@ -37,71 +47,39 @@ public function isFunctionSupported(FunctionReflection $functionReflection, Func public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes { $args = $node->getArgs(); - if (!$context->truthy() || count($args) < 2) { + if (count($args) < 2 || !$scope instanceof MutatingScope) { return new SpecifiedTypes(); } - $array = $args[0]->value; - $callable = $args[1]->value; - if ($callable instanceof Expr\ArrowFunction) { - $callableExpr = $callable->expr; - } elseif ( - $callable instanceof Expr\Closure && - count($callable->stmts) === 1 && - $callable->stmts[0] instanceof Stmt\Return_ && - isset($callable->stmts[0]->expr) - ) { - $callableExpr = $callable->stmts[0]->expr; - } else { + $arrayArg = $args[0]->value; + $callbackArg = $args[1]->value; + + $arrayType = $scope->getType($arrayArg); + if ($arrayType->isArray()->no()) { return new SpecifiedTypes(); } - $callableParams = $callable->params; - $specifiedTypesInFuncCall = $this->typeSpecifier->specifyTypesInCondition($scope, $callableExpr, $context)->getSureTypes(); - - if ( - isset($callableParams[0]) && - $callableParams[0]->var instanceof Variable && - is_string($callableParams[0]->var->name) - ) { - $valueType = $this->fetchTypeByVariable($specifiedTypesInFuncCall, $callableParams[0]->var->name); - } + 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()); - if ( - isset($callableParams[1]) && - $callableParams[1]->var instanceof Variable && - is_string($callableParams[1]->var->name) - ) { - $keyType = $this->fetchTypeByVariable($specifiedTypesInFuncCall, $callableParams[1]->var->name); + return $this->typeSpecifier->create($arrayArg, $nonEmptyType, TypeSpecifierContext::createTruthy(), $scope); } - if (isset($keyType) || isset($valueType)) { - return $this->typeSpecifier->create( - $array, - new ArrayType($keyType ?? new MixedType(), $valueType ?? new MixedType()), - $context, - $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(); } - return new SpecifiedTypes(); - } - - /** - * @param array $specifiedTypes - */ - private function fetchTypeByVariable(array $specifiedTypes, string $variableName): ?Type - { - $specifiedTypeOfKey = array_find( - $specifiedTypes, - static fn ($specifiedType) => $specifiedType[0] instanceof Expr\Variable && $specifiedType[0]->name === $variableName, - ); - - if (isset($specifiedTypeOfKey)) { - return $specifiedTypeOfKey[1]; + $narrowedType = $this->narrowingHelper->narrowArrayType($scope, $arrayType, $predicates[0], true); + if ($narrowedType === null) { + return new SpecifiedTypes(); } - return null; + return $this->typeSpecifier->create($arrayArg, $narrowedType, TypeSpecifierContext::createTruthy(), $scope); } public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void diff --git a/src/Type/Php/ArrayAnyFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayAnyFunctionTypeSpecifyingExtension.php new file mode 100644 index 00000000000..bdccaaf35bf --- /dev/null +++ b/src/Type/Php/ArrayAnyFunctionTypeSpecifyingExtension.php @@ -0,0 +1,92 @@ + 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; + } + +} diff --git a/src/Type/Php/ArrayCallbackParameterMapping.php b/src/Type/Php/ArrayCallbackParameterMapping.php new file mode 100644 index 00000000000..d5e68cff6bc --- /dev/null +++ b/src/Type/Php/ArrayCallbackParameterMapping.php @@ -0,0 +1,46 @@ +itemPosition; + } + + public function getKeyPosition(): ?int + { + return $this->keyPosition; + } + +} diff --git a/src/Type/Php/ArrayCallbackPredicate.php b/src/Type/Php/ArrayCallbackPredicate.php new file mode 100644 index 00000000000..bbe18e914f3 --- /dev/null +++ b/src/Type/Php/ArrayCallbackPredicate.php @@ -0,0 +1,44 @@ +itemVar; + } + + public function getKeyVar(): ?Variable + { + return $this->keyVar; + } + + public function getExpr(): ?Expr + { + return $this->expr; + } + +} diff --git a/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php b/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php index 83de6b7f622..2726cd4f1f6 100644 --- a/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php +++ b/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php @@ -2,24 +2,14 @@ namespace PHPStan\Type\Php; -use PhpParser\Node\Arg; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\ArrowFunction; -use PhpParser\Node\Expr\Closure; -use PhpParser\Node\Expr\Error; -use PhpParser\Node\Expr\FuncCall; -use PhpParser\Node\Expr\MethodCall; -use PhpParser\Node\Expr\StaticCall; -use PhpParser\Node\Expr\Variable; use PhpParser\Node\Name; -use PhpParser\Node\Stmt\Return_; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Php\PhpVersion; use PHPStan\Reflection\ReflectionProvider; use PHPStan\ShouldNotHappenException; -use PHPStan\TrinaryLogic; use PHPStan\Type\ArrayType; use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\Constant\ConstantArrayType; @@ -35,9 +25,7 @@ use function array_map; use function count; use function in_array; -use function is_string; use function sprintf; -use function substr; #[AutowiredService] final class ArrayFilterFunctionReturnTypeHelper @@ -50,6 +38,7 @@ final class ArrayFilterFunctionReturnTypeHelper public function __construct( private ReflectionProvider $reflectionProvider, private PhpVersion $phpVersion, + private ArrayPredicateCallbackResolver $predicateCallbackResolver, ) { } @@ -91,62 +80,32 @@ public function getType(Scope $scope, ?Expr $arrayArg, ?Expr $callbackArg, ?Expr return new ArrayType($keyType, $itemType); } - if ($callbackArg instanceof Closure && count($callbackArg->stmts) === 1 && count($callbackArg->params) > 0) { - $statement = $callbackArg->stmts[0]; - if ($statement instanceof Return_ && $statement->expr !== null) { - if ($mode === self::USE_ITEM) { - $keyVar = null; - $itemVar = $callbackArg->params[0]->var; - } elseif ($mode === self::USE_KEY) { - $keyVar = $callbackArg->params[0]->var; - $itemVar = null; - } elseif ($mode === self::USE_BOTH) { - $keyVar = $callbackArg->params[1]->var ?? null; - $itemVar = $callbackArg->params[0]->var; - } - return $this->filterByTruthyValue($scope, $itemVar, $arrayArgType, $keyVar, $statement->expr); - } - } elseif ($callbackArg instanceof ArrowFunction && count($callbackArg->params) > 0) { - if ($mode === self::USE_ITEM) { - $keyVar = null; - $itemVar = $callbackArg->params[0]->var; - } elseif ($mode === self::USE_KEY) { - $keyVar = $callbackArg->params[0]->var; - $itemVar = null; - } elseif ($mode === self::USE_BOTH) { - $keyVar = $callbackArg->params[1]->var ?? null; - $itemVar = $callbackArg->params[0]->var; - } - return $this->filterByTruthyValue($scope, $itemVar, $arrayArgType, $keyVar, $callbackArg->expr); - } elseif ( - ($callbackArg instanceof FuncCall || $callbackArg instanceof MethodCall || $callbackArg instanceof StaticCall) - && $callbackArg->isFirstClassCallable() - ) { - [$args, $itemVar, $keyVar] = $this->createDummyArgs($mode); - $expr = clone $callbackArg; - $expr->args = $args; - return $this->filterByTruthyValue($scope, $itemVar, $arrayArgType, $keyVar, $expr); - } else { - $constantStrings = $scope->getType($callbackArg)->getConstantStrings(); - if (count($constantStrings) > 0) { - $results = []; - [$args, $itemVar, $keyVar] = $this->createDummyArgs($mode); - - foreach ($constantStrings as $constantString) { - $funcName = self::createFunctionName($constantString->getValue()); - if ($funcName === null) { - $results[] = new ErrorType(); - continue; - } + if (!$scope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } - $expr = new FuncCall($funcName, $args); - $results[] = $this->filterByTruthyValue($scope, $itemVar, $arrayArgType, $keyVar, $expr); - } - return TypeCombinator::union(...$results); + $mapping = match ($mode) { + self::USE_ITEM => ArrayCallbackParameterMapping::item(), + self::USE_KEY => ArrayCallbackParameterMapping::key(), + self::USE_BOTH => ArrayCallbackParameterMapping::valueAndKey(), + }; + + $predicates = $this->predicateCallbackResolver->resolve($scope, $callbackArg, $mapping); + if ($predicates === null) { + return new ArrayType($keyType, $itemType); + } + + $results = []; + foreach ($predicates as $predicate) { + if ($predicate->getExpr() === null) { + $results[] = new ErrorType(); + continue; } + + $results[] = $this->filterByTruthyValue($scope, $predicate, $arrayArgType); } - return new ArrayType($keyType, $itemType); + return TypeCombinator::union(...$results); } private function removeFalsey(Type $type): Type @@ -154,12 +113,8 @@ private function removeFalsey(Type $type): Type return $type->filterArrayRemovingFalsey(); } - private function filterByTruthyValue(Scope $scope, Error|Variable|null $itemVar, Type $arrayType, Error|Variable|null $keyVar, Expr $expr): Type + private function filterByTruthyValue(MutatingScope $scope, ArrayCallbackPredicate $predicate, Type $arrayType): Type { - if (!$scope instanceof MutatingScope) { - throw new ShouldNotHappenException(); - } - $constantArrays = $arrayType->getConstantArrays(); if (count($constantArrays) > 0) { $results = []; @@ -168,7 +123,7 @@ private function filterByTruthyValue(Scope $scope, Error|Variable|null $itemVar, $optionalKeys = $constantArray->getOptionalKeys(); foreach ($constantArray->getKeyTypes() as $i => $keyType) { $itemType = $constantArray->getValueTypes()[$i]; - [$newKeyType, $newItemType, $optional] = $this->processKeyAndItemType($scope, $keyType, $itemType, $itemVar, $keyVar, $expr); + [$newKeyType, $newItemType, $optional] = $this->processKeyAndItemType($scope, $keyType, $itemType, $predicate); $optional = $optional || in_array($i, $optionalKeys, true); if ($newKeyType instanceof NeverType || $newItemType instanceof NeverType) { continue; @@ -184,7 +139,7 @@ private function filterByTruthyValue(Scope $scope, Error|Variable|null $itemVar, if ($constantArray->isUnsealed()->yes()) { $unsealedTypes = $constantArray->getUnsealedTypes(); if ($unsealedTypes !== null) { - [$newKey, $newValue] = $this->processKeyAndItemType($scope, $unsealedTypes[0], $unsealedTypes[1], $itemVar, $keyVar, $expr); + [$newKey, $newValue] = $this->processKeyAndItemType($scope, $unsealedTypes[0], $unsealedTypes[1], $predicate); // Drop the unsealed slot when the predicate // rejects every possible extra (key or value // narrows to `Never`). @@ -200,7 +155,7 @@ private function filterByTruthyValue(Scope $scope, Error|Variable|null $itemVar, return TypeCombinator::union(...$results); } - [$newKeyType, $newItemType] = $this->processKeyAndItemType($scope, $arrayType->getIterableKeyType(), $arrayType->getIterableValueType(), $itemVar, $keyVar, $expr); + [$newKeyType, $newItemType] = $this->processKeyAndItemType($scope, $arrayType->getIterableKeyType(), $arrayType->getIterableValueType(), $predicate); if ($newItemType instanceof NeverType || $newKeyType instanceof NeverType) { return new ConstantArrayType([], []); @@ -212,24 +167,13 @@ private function filterByTruthyValue(Scope $scope, Error|Variable|null $itemVar, /** * @return array{Type, Type, bool} */ - private function processKeyAndItemType(MutatingScope $scope, Type $keyType, Type $itemType, Error|Variable|null $itemVar, Error|Variable|null $keyVar, Expr $expr): array + private function processKeyAndItemType(MutatingScope $scope, Type $keyType, Type $itemType, ArrayCallbackPredicate $predicate): array { - $itemVarName = null; - if ($itemVar !== null) { - if (!$itemVar instanceof Variable || !is_string($itemVar->name)) { - throw new ShouldNotHappenException(); - } - $itemVarName = $itemVar->name; - $scope = $scope->assignVariable($itemVarName, $itemType, new MixedType(), TrinaryLogic::createYes()); - } + [$scope, $itemVarName, $keyVarName] = $this->predicateCallbackResolver->assignPredicateVariables($scope, $predicate, $itemType, $keyType); - $keyVarName = null; - if ($keyVar !== null) { - if (!$keyVar instanceof Variable || !is_string($keyVar->name)) { - throw new ShouldNotHappenException(); - } - $keyVarName = $keyVar->name; - $scope = $scope->assignVariable($keyVarName, $keyType, new MixedType(), TrinaryLogic::createYes()); + $expr = $predicate->getExpr(); + if ($expr === null) { + throw new ShouldNotHappenException(); } $booleanResult = $scope->getType($expr)->toBoolean(); @@ -256,47 +200,6 @@ private function processKeyAndItemType(MutatingScope $scope, Type $keyType, Type ]; } - private static function createFunctionName(string $funcName): ?Name - { - if ($funcName === '') { - return null; - } - - if ($funcName[0] === '\\') { - $funcName = substr($funcName, 1); - - if ($funcName === '') { - return null; - } - - return new Name\FullyQualified($funcName); - } - - return new Name($funcName); - } - - /** - * @param self::USE_* $mode - * @return array{list, ?Variable, ?Variable} - */ - private function createDummyArgs(int $mode): array - { - if ($mode === self::USE_ITEM) { - $itemVar = new Variable('item'); - $keyVar = null; - $args = [new Arg($itemVar)]; - } elseif ($mode === self::USE_KEY) { - $itemVar = null; - $keyVar = new Variable('key'); - $args = [new Arg($keyVar)]; - } elseif ($mode === self::USE_BOTH) { - $itemVar = new Variable('item'); - $keyVar = new Variable('key'); - $args = [new Arg($itemVar), new Arg($keyVar)]; - } - return [$args, $itemVar, $keyVar]; - } - /** * @param non-empty-string $constantName */ diff --git a/src/Type/Php/ArrayPredicateCallbackResolver.php b/src/Type/Php/ArrayPredicateCallbackResolver.php new file mode 100644 index 00000000000..f25f95f4aef --- /dev/null +++ b/src/Type/Php/ArrayPredicateCallbackResolver.php @@ -0,0 +1,220 @@ +|null + */ + public function resolve(MutatingScope $scope, Expr $callbackArg, ArrayCallbackParameterMapping $mapping): ?array + { + if ($callbackArg instanceof ArrowFunction) { + $predicate = $this->fromParams($callbackArg->params, $mapping, $callbackArg->expr); + return $predicate === null ? null : [$predicate]; + } + + if ( + $callbackArg instanceof Closure + && count($callbackArg->stmts) === 1 + && $callbackArg->stmts[0] instanceof Return_ + && $callbackArg->stmts[0]->expr !== null + ) { + $predicate = $this->fromParams($callbackArg->params, $mapping, $callbackArg->stmts[0]->expr); + return $predicate === null ? null : [$predicate]; + } + + if ( + ($callbackArg instanceof FuncCall || $callbackArg instanceof MethodCall || $callbackArg instanceof StaticCall) + && $callbackArg->isFirstClassCallable() + ) { + [$args, $itemVar, $keyVar] = $this->createDummyArgs($mapping); + $expr = clone $callbackArg; + $expr->args = $args; + + return [new ArrayCallbackPredicate($itemVar, $keyVar, $expr)]; + } + + $constantStrings = $scope->getType($callbackArg)->getConstantStrings(); + if (count($constantStrings) > 0) { + [$args, $itemVar, $keyVar] = $this->createDummyArgs($mapping); + + $predicates = []; + foreach ($constantStrings as $constantString) { + $funcName = self::createFunctionName($constantString->getValue()); + if ($funcName === null) { + $predicates[] = new ArrayCallbackPredicate($itemVar, $keyVar, null); + continue; + } + + $predicates[] = new ArrayCallbackPredicate($itemVar, $keyVar, new FuncCall($funcName, $args)); + } + + return $predicates; + } + + return null; + } + + /** + * Assigns the element value/key types onto the scope for the predicate's + * variables, returning the new scope along with the variable names that were + * assigned (null when the callback does not receive that value or key). + * + * @return array{MutatingScope, ?string, ?string} + */ + public function assignPredicateVariables(MutatingScope $scope, ArrayCallbackPredicate $predicate, Type $itemType, Type $keyType): array + { + $itemVarName = null; + $itemVar = $predicate->getItemVar(); + if ($itemVar !== null) { + if (!is_string($itemVar->name)) { + throw new ShouldNotHappenException(); + } + $itemVarName = $itemVar->name; + $scope = $scope->assignVariable($itemVarName, $itemType, new MixedType(), TrinaryLogic::createYes()); + } + + $keyVarName = null; + $keyVar = $predicate->getKeyVar(); + if ($keyVar !== null) { + if (!is_string($keyVar->name)) { + throw new ShouldNotHappenException(); + } + $keyVarName = $keyVar->name; + $scope = $scope->assignVariable($keyVarName, $keyType, new MixedType(), TrinaryLogic::createYes()); + } + + return [$scope, $itemVarName, $keyVarName]; + } + + /** + * @param Param[] $params + */ + private function fromParams(array $params, ArrayCallbackParameterMapping $mapping, Expr $expr): ?ArrayCallbackPredicate + { + if (count($params) === 0) { + return null; + } + + $itemVar = null; + if ($mapping->getItemPosition() !== null) { + $var = $this->paramVariable($params, $mapping->getItemPosition()); + if ($var === false) { + return null; + } + $itemVar = $var; + } + + $keyVar = null; + if ($mapping->getKeyPosition() !== null) { + $var = $this->paramVariable($params, $mapping->getKeyPosition()); + if ($var === false) { + return null; + } + $keyVar = $var; + } + + return new ArrayCallbackPredicate($itemVar, $keyVar, $expr); + } + + /** + * Returns the variable at the given parameter position, null when the + * callback declares no such parameter, or false when the parameter cannot be + * used for narrowing (by-ref, variadic or non-variable/dynamic name). + * + * @param Param[] $params + */ + private function paramVariable(array $params, int $position): Variable|false|null + { + if (!isset($params[$position])) { + return null; + } + + $param = $params[$position]; + if ($param->byRef || $param->variadic || !$param->var instanceof Variable || !is_string($param->var->name)) { + return false; + } + + return $param->var; + } + + /** + * @return array{list, ?Variable, ?Variable} + */ + private function createDummyArgs(ArrayCallbackParameterMapping $mapping): array + { + $itemPosition = $mapping->getItemPosition(); + $keyPosition = $mapping->getKeyPosition(); + + $itemVar = $itemPosition !== null ? new Variable('item') : null; + $keyVar = $keyPosition !== null ? new Variable('key') : null; + + $args = []; + if ($itemVar !== null) { + $args[$itemPosition] = new Arg($itemVar); + } + if ($keyVar !== null) { + $args[$keyPosition] = new Arg($keyVar); + } + ksort($args); + + return [array_values($args), $itemVar, $keyVar]; + } + + private static function createFunctionName(string $funcName): ?Name + { + if ($funcName === '') { + return null; + } + + if ($funcName[0] === '\\') { + $funcName = substr($funcName, 1); + + if ($funcName === '') { + return null; + } + + return new Name\FullyQualified($funcName); + } + + return new Name($funcName); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/array-all.php b/tests/PHPStan/Analyser/nsrt/array-all.php index 8ec15008056..308d4a7cc3e 100644 --- a/tests/PHPStan/Analyser/nsrt/array-all.php +++ b/tests/PHPStan/Analyser/nsrt/array-all.php @@ -16,7 +16,7 @@ public function test1($array) { if (array_all($array, fn ($value) => is_int($value))) { assertType("array", $array); } else { - assertType("array", $array); + assertType("non-empty-array", $array); } assertType("array", $array); } @@ -28,9 +28,9 @@ public function test2($array) { if (array_all($array, fn ($value, $key) => is_string($key))) { assertType("array", $array); } else { - assertType("array", $array); + assertType("non-empty-array", $array); } - assertType("array", $array); + assertType("array", $array); } /** @@ -40,7 +40,7 @@ public function test3($array) { if (array_all($array, fn ($value, $key) => is_string($key) && is_int($value))) { assertType("array", $array); } else { - assertType("array", $array); + assertType("non-empty-array", $array); } assertType("array", $array); } @@ -52,7 +52,7 @@ public function test4($array) { if (array_all($array, fn ($value) => is_string($value) && is_numeric($value))) { assertType("array", $array); } else { - assertType("array", $array); + assertType("non-empty-array", $array); } assertType("array", $array); } @@ -64,7 +64,7 @@ public function test5($array) { if (array_all($array, fn ($value) => is_bool($value) || is_float($value))) { assertType("array", $array); } else { - assertType("array", $array); + assertType("non-empty-array", $array); } assertType("array", $array); } @@ -74,9 +74,10 @@ public function test5($array) { */ public function test6($array) { if (array_all($array, fn ($value) => is_float(1))) { - assertType("array", $array); + // the predicate can never be true, so array_all only holds for the empty array + assertType("array{}", $array); } else { - assertType("array", $array); + assertType("non-empty-array", $array); } assertType("array", $array); } @@ -88,7 +89,7 @@ public function test7($array) { if (array_all($array, fn ($value) => $value instanceof DateTime)) { assertType("array", $array); } else { - assertType("array", $array); + assertType("non-empty-array", $array); } assertType("array", $array); } @@ -100,7 +101,7 @@ public function test8($array) { if (array_all($array, fn ($value) => $value instanceof DateTime || $value instanceof DateTimeImmutable)) { assertType("array", $array); } else { - assertType("array", $array); + assertType("non-empty-array", $array); } assertType("array", $array); } @@ -112,7 +113,7 @@ public function test9($array) { if (array_all($array, fn ($value, $key) => is_int($key))) { assertType("list", $array); } else { - assertType("list", $array); + assertType("non-empty-list", $array); } assertType("list", $array); } @@ -126,7 +127,7 @@ public function test10($array) { } else { assertType("non-empty-array", $array); } - assertType("non-empty-array", $array); + assertType("non-empty-array", $array); } /** @@ -136,7 +137,7 @@ public function test11($array) { if (array_all($array, function ($value) {return is_int($value);})) { assertType("array", $array); } else { - assertType("array", $array); + assertType("non-empty-array", $array); } assertType("array", $array); } @@ -148,7 +149,7 @@ public function test12($array) { if (array_all($array, function ($value) {$value = 1; return is_int($value);})) { assertType("array", $array); } else { - assertType("array", $array); + assertType("non-empty-array", $array); } assertType("array", $array); } @@ -160,7 +161,7 @@ public function test13($array) { if (array_all($array, function ($value, $key) {return is_int($value) && is_string($key);})) { assertType("array", $array); } else { - assertType("array", $array); + assertType("non-empty-array", $array); } assertType("array", $array); } @@ -172,7 +173,7 @@ public function test14($array) { if (array_all($array, function ($value, $key) {return;})) { assertType("array", $array); } else { - assertType("array", $array); + assertType("non-empty-array", $array); } assertType("array", $array); } @@ -184,8 +185,133 @@ public function test15($array) { if (array_all($array, fn ($value) => is_int($value)) === true) { assertType("array", $array); } else { - assertType("array", $array); + assertType("non-empty-array", $array); } assertType("array", $array); } + + /** + * A negated predicate narrows via sureNotTypes - the previous + * implementation could not express this. + * + * @param array $array + */ + public function testNegatedIsNull($array) { + if (array_all($array, fn ($value) => !is_null($value))) { + assertType("array", $array); + } + } + + /** + * @param array $array + */ + public function testNotIdenticalNull($array) { + if (array_all($array, fn ($value) => $value !== null)) { + assertType("array", $array); + } + } + + /** + * Bare truthiness of the value. + * + * @param array $array + */ + public function testBareTruthiness($array) { + if (array_all($array, fn ($value) => $value)) { + assertType("array", $array); + } + } + + /** + * A possibly-empty list stays sound: the empty list makes array_all true, + * so the narrowed type is array{}, not never. + * + * @param list $array + */ + public function testListStringKey($array) { + if (array_all($array, fn ($value, $key) => is_string($key))) { + assertType("array{}", $array); + } else { + assertType("non-empty-list", $array); + } + } + + /** + * First-class callable with a two-parameter predicate. + * + * @param array $array + */ + public function testFirstClassCallable($array) { + if (array_all($array, self::isIntValue(...))) { + assertType("array", $array); + } + } + + /** + * Constant-string callable naming a two-parameter user function. + * + * @param array $array + */ + public function testStringCallable($array) { + if (array_all($array, '\ArrayAll\isIntValue')) { + assertType("array", $array); + } + } + + /** + * @phpstan-assert-if-true int $value + */ + public static function isIntValue(mixed $value, int|string $key): bool { + return is_int($value); + } + + /** + * A predicate using @phpstan-assert-if-true through a static method call. + * + * @param array $array + */ + public function testAssertIfTrue($array) { + if (array_all($array, fn ($value) => self::isDateTime($value))) { + assertType("array", $array); + } + } + + /** + * @phpstan-assert-if-true DateTime $value + */ + public static function isDateTime(mixed $value): bool { + return $value instanceof DateTime; + } + + /** + * Array shapes are narrowed per offset: the required int offsets stay, the + * optional string offset that cannot be int is dropped. + * + * @param array{a: int, b?: string, c: int} $array + */ + public function testShape($array) { + if (array_all($array, fn ($value) => is_int($value))) { + assertType("array{a: int, c: int}", $array); + } + } + + /** + * A by-ref callback parameter disables narrowing (the callback may rewrite + * elements). + * + * @param array $array + */ + public function testByRefParameter($array) { + if (array_all($array, function (&$value) { return is_int($value); })) { + assertType("array", $array); + } + } + +} + +/** + * @phpstan-assert-if-true int $value + */ +function isIntValue(mixed $value, int|string $key): bool { + return is_int($value); } diff --git a/tests/PHPStan/Analyser/nsrt/array-any.php b/tests/PHPStan/Analyser/nsrt/array-any.php new file mode 100644 index 00000000000..cd7a136decb --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-any.php @@ -0,0 +1,150 @@ += 8.4 + +namespace ArrayAny; + +use DateTime; + +use function PHPStan\Testing\assertType; + +class Foo { + + /** + * array_any true means at least one element matches (so the array is + * non-empty); false means no element matches, narrowing every element by + * the predicate being falsey. + * + * @param array $array + */ + public function test1($array) { + if (array_any($array, fn ($value) => $value === null)) { + assertType("non-empty-array", $array); + } else { + assertType("array", $array); + } + assertType("array", $array); + } + + /** + * @param array $array + */ + public function test2($array) { + if (array_any($array, fn ($value) => is_int($value))) { + assertType("non-empty-array", $array); + } else { + assertType("array", $array); + } + assertType("array", $array); + } + + /** + * The dual of `!is_null` for array_all: `!array_any(... === null)` narrows + * away null. + * + * @param array $array + */ + public function testNegated($array) { + if (!array_any($array, fn ($value) => $value === null)) { + assertType("array", $array); + } + } + + /** + * @param array $array + */ + public function testKey($array) { + if (array_any($array, fn ($value, $key) => is_string($key))) { + assertType("non-empty-array", $array); + } else { + assertType("array", $array); + } + } + + /** + * @param list $array + */ + public function testList($array) { + if (array_any($array, fn ($value, $key) => is_string($key))) { + assertType("non-empty-list", $array); + } else { + assertType("list", $array); + } + } + + /** + * @param non-empty-array $array + */ + public function testNonEmpty($array) { + if (array_any($array, fn ($value) => is_int($value))) { + assertType("non-empty-array", $array); + } else { + assertType("non-empty-array", $array); + } + } + + /** + * First-class callable predicate, narrowing on the falsey side. + * + * @param array $array + */ + public function testFirstClassCallable($array) { + if (!array_any($array, self::isIntValue(...))) { + assertType("array", $array); + } + } + + /** + * Constant-string callable predicate, narrowing on the falsey side. + * + * @param array $array + */ + public function testStringCallable($array) { + if (!array_any($array, '\ArrayAny\isIntValue')) { + assertType("array", $array); + } + } + + /** + * @phpstan-assert-if-true int $value + */ + public static function isIntValue(mixed $value, int|string $key): bool { + return is_int($value); + } + + /** + * @param array $array + */ + public function testAssertIfTrue($array) { + if (!array_any($array, fn ($value) => $value instanceof DateTime)) { + assertType("array", $array); + } + } + + /** + * Optional offsets that would match are dropped; required offsets that + * cannot fail make the whole shape impossible. + * + * @param array{a?: int, b: string} $array + */ + public function testShapeOptionalDropped($array) { + if (!array_any($array, fn ($value) => is_int($value))) { + assertType("array{b: string}", $array); + } + } + + /** + * @param array{a: int, b: string} $array + */ + public function testShapeRequiredImpossible($array) { + if (!array_any($array, fn ($value) => is_int($value))) { + assertType("*NEVER*", $array); + } + } + +} + +/** + * @phpstan-assert-if-true int $value + */ +function isIntValue(mixed $value, int|string $key): bool { + return is_int($value); +} diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 5f357bd51b4..a7851abb3f1 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -294,6 +294,23 @@ public function testBug7898(): void $this->analyse([__DIR__ . '/data/bug-7898.php'], []); } + public function testArrayAllNonEmptyList(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/array-all-non-empty-list.php'], [ + [ + 'Call to function array_all() with non-empty-list and Closure(mixed, mixed): false will always evaluate to false.', + 13, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', + ], + [ + 'Call to function is_string() with int<0, max> will always evaluate to false.', + 13, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', + ], + ]); + } + #[RequiresPhp('>= 8.1.0')] public function testStructExists(): void { diff --git a/tests/PHPStan/Rules/Comparison/data/array-all-non-empty-list.php b/tests/PHPStan/Rules/Comparison/data/array-all-non-empty-list.php new file mode 100644 index 00000000000..ef73904dc60 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/array-all-non-empty-list.php @@ -0,0 +1,18 @@ += 8.4 + +namespace ArrayAllNonEmptyList; + +class Foo +{ + + /** + * @param non-empty-list $array + */ + public function doFoo(array $array): void + { + if (array_all($array, fn ($value, $key) => is_string($key))) { + echo 'never'; + } + } + +} From 116d779acee18d8ec3d99d835942b70bb50be766 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 14 Jul 2026 01:33:01 +0900 Subject: [PATCH 3/4] Avoid match expression for PHP 7.4 downgrade compatibility The simple-downgrade tooling used by the 7.4/8.0/8.1 lint CI jobs cannot transform `match`, and it was the only real match expression left in src/. --- src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php b/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php index 2726cd4f1f6..2210b40bd3e 100644 --- a/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php +++ b/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php @@ -84,11 +84,13 @@ public function getType(Scope $scope, ?Expr $arrayArg, ?Expr $callbackArg, ?Expr throw new ShouldNotHappenException(); } - $mapping = match ($mode) { - self::USE_ITEM => ArrayCallbackParameterMapping::item(), - self::USE_KEY => ArrayCallbackParameterMapping::key(), - self::USE_BOTH => ArrayCallbackParameterMapping::valueAndKey(), - }; + if ($mode === self::USE_ITEM) { + $mapping = ArrayCallbackParameterMapping::item(); + } elseif ($mode === self::USE_KEY) { + $mapping = ArrayCallbackParameterMapping::key(); + } else { + $mapping = ArrayCallbackParameterMapping::valueAndKey(); + } $predicates = $this->predicateCallbackResolver->resolve($scope, $callbackArg, $mapping); if ($predicates === null) { From bbb1116340310c97fa69b994a5bdade72d13856e Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 14 Jul 2026 02:11:50 +0900 Subject: [PATCH 4/4] Cover the falsey-but-not-false context and stabilize the CI message Add array_all()/array_any() `=== false` test cases whose else branch is the truthy result of the call. This reaches the falsey-but-not-false type-specifier context that a plain `if (array_all(...))` never hits, so the `$context->false()` discriminator is now covered by mutation testing. Also make the array_all() impossible-check reproducer use a literal-false predicate: `is_string($key)` on a list key is inferred as `false` on current PHP but as `bool` on the downgraded 7.4/8.0 builds, which made the reported closure type - and thus the expected error message - unstable across the CI matrix. Co-authored-by: Claude Opus 4.8 --- tests/PHPStan/Analyser/nsrt/array-all.php | 17 +++++++++++++++++ tests/PHPStan/Analyser/nsrt/array-any.php | 16 ++++++++++++++++ .../ImpossibleCheckTypeFunctionCallRuleTest.php | 7 +------ .../data/array-all-non-empty-list.php | 9 ++++++++- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/array-all.php b/tests/PHPStan/Analyser/nsrt/array-all.php index 308d4a7cc3e..61ae84c2c5d 100644 --- a/tests/PHPStan/Analyser/nsrt/array-all.php +++ b/tests/PHPStan/Analyser/nsrt/array-all.php @@ -307,6 +307,23 @@ public function testByRefParameter($array) { } } + /** + * Strict `=== false` comparison. The else branch is the truthy result of + * array_all(), so elements are narrowed there; it exercises the falsey-but- + * not-false type-specifier context, which a plain `if (array_all(...))` does + * not reach. + * + * @param array $array + */ + public function testStrictFalseComparison($array) { + if (array_all($array, fn ($value) => is_int($value)) === false) { + assertType("non-empty-array", $array); + } else { + assertType("array", $array); + } + assertType("array", $array); + } + } /** diff --git a/tests/PHPStan/Analyser/nsrt/array-any.php b/tests/PHPStan/Analyser/nsrt/array-any.php index cd7a136decb..95328082ca8 100644 --- a/tests/PHPStan/Analyser/nsrt/array-any.php +++ b/tests/PHPStan/Analyser/nsrt/array-any.php @@ -140,6 +140,22 @@ public function testShapeRequiredImpossible($array) { } } + /** + * Strict `=== false` comparison. The else branch is the truthy result of + * array_any() (at least one match, so the array is non-empty); it exercises + * the falsey-but-not-false type-specifier context. + * + * @param array $array + */ + public function testStrictFalseComparison($array) { + if (array_any($array, fn ($value) => $value === null) === false) { + assertType("array", $array); + } else { + assertType("non-empty-array", $array); + } + assertType("array", $array); + } + } /** diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index a7851abb3f1..dd23415fac5 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -300,12 +300,7 @@ public function testArrayAllNonEmptyList(): void $this->analyse([__DIR__ . '/data/array-all-non-empty-list.php'], [ [ 'Call to function array_all() with non-empty-list and Closure(mixed, mixed): false will always evaluate to false.', - 13, - 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', - ], - [ - 'Call to function is_string() with int<0, max> will always evaluate to false.', - 13, + 20, 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', ], ]); diff --git a/tests/PHPStan/Rules/Comparison/data/array-all-non-empty-list.php b/tests/PHPStan/Rules/Comparison/data/array-all-non-empty-list.php index ef73904dc60..3e8db06be47 100644 --- a/tests/PHPStan/Rules/Comparison/data/array-all-non-empty-list.php +++ b/tests/PHPStan/Rules/Comparison/data/array-all-non-empty-list.php @@ -6,11 +6,18 @@ class Foo { /** + * A predicate that can never hold makes array_all() on a non-empty array + * impossible: every element would have to satisfy it, but the array is + * guaranteed to have at least one element. A literal-false predicate keeps + * the reported closure type stable across PHP versions (a predicate like + * is_string($key) is inferred as false on newer PHP but bool on the + * downgraded builds). + * * @param non-empty-list $array */ public function doFoo(array $array): void { - if (array_all($array, fn ($value, $key) => is_string($key))) { + if (array_all($array, fn ($value, $key) => false)) { echo 'never'; } }