diff --git a/src/Type/Php/ArrayAllAnyNarrowingHelper.php b/src/Type/Php/ArrayAllAnyNarrowingHelper.php new file mode 100644 index 0000000000..84ec714cbd --- /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 new file mode 100644 index 0000000000..9828699502 --- /dev/null +++ b/src/Type/Php/ArrayAllFunctionTypeSpecifyingExtension.php @@ -0,0 +1,90 @@ + 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->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; + } + +} diff --git a/src/Type/Php/ArrayAnyFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayAnyFunctionTypeSpecifyingExtension.php new file mode 100644 index 0000000000..bdccaaf35b --- /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 0000000000..d5e68cff6b --- /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 0000000000..bbe18e914f --- /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 83de6b7f62..2210b40bd3 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,34 @@ 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); + if (!$scope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + + if ($mode === self::USE_ITEM) { + $mapping = ArrayCallbackParameterMapping::item(); + } elseif ($mode === self::USE_KEY) { + $mapping = ArrayCallbackParameterMapping::key(); } 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; - } + $mapping = ArrayCallbackParameterMapping::valueAndKey(); + } - $expr = new FuncCall($funcName, $args); - $results[] = $this->filterByTruthyValue($scope, $itemVar, $arrayArgType, $keyVar, $expr); - } - return TypeCombinator::union(...$results); + $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 +115,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 +125,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 +141,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 +157,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 +169,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 +202,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 0000000000..f25f95f4ae --- /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 new file mode 100644 index 0000000000..61ae84c2c5 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-all.php @@ -0,0 +1,334 @@ += 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("non-empty-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("non-empty-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("non-empty-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("non-empty-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("non-empty-array", $array); + } + assertType("array", $array); + } + + /** + * @param array $array + */ + public function test6($array) { + if (array_all($array, fn ($value) => is_float(1))) { + // the predicate can never be true, so array_all only holds for the empty array + assertType("array{}", $array); + } else { + assertType("non-empty-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("non-empty-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("non-empty-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("non-empty-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("non-empty-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("non-empty-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("non-empty-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("non-empty-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("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); + } + } + + /** + * 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); + } + +} + +/** + * @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 0000000000..95328082ca --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-any.php @@ -0,0 +1,166 @@ += 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); + } + } + + /** + * 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); + } + +} + +/** + * @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 5f357bd51b..dd23415fac 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -294,6 +294,18 @@ 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.', + 20, + '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 0000000000..3e8db06be4 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/array-all-non-empty-list.php @@ -0,0 +1,25 @@ += 8.4 + +namespace ArrayAllNonEmptyList; + +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) => false)) { + echo 'never'; + } + } + +}