diff --git a/src/Type/Php/ArrayAllFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayAllFunctionTypeSpecifyingExtension.php new file mode 100644 index 0000000000..b6103573a6 --- /dev/null +++ b/src/Type/Php/ArrayAllFunctionTypeSpecifyingExtension.php @@ -0,0 +1,250 @@ +getName()); + + return ($name === 'array_all' && $context->truthy()) + || ($name === 'array_any' && $context->falsey()); + } + + public function specifyTypes( + FunctionReflection $functionReflection, + FuncCall $node, + Scope $scope, + TypeSpecifierContext $context, + ): SpecifiedTypes + { + $args = $node->getArgs(); + if (count($args) < 2) { + return new SpecifiedTypes(); + } + + $arrayArg = $args[0]->value; + $callbackArg = $args[1]->value; + $arrayType = $scope->getType($arrayArg); + + $useTruthyFilter = strtolower($functionReflection->getName()) === 'array_all'; + + $narrowedType = $this->getNarrowedArrayType($scope, $arrayType, $callbackArg, $useTruthyFilter); + if ($narrowedType === null) { + return new SpecifiedTypes(); + } + + return $this->typeSpecifier->create( + $arrayArg, + $narrowedType, + TypeSpecifierContext::createTruthy(), + $scope, + )->setRootExpr($node); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + + private function getNarrowedArrayType(Scope $scope, Type $arrayType, Expr $callbackArg, bool $useTruthyFilter): ?Type + { + $callbackInfo = $this->extractCallbackInfo($scope, $callbackArg); + if ($callbackInfo === null) { + return null; + } + + [$itemVar, $keyVar, $expr] = $callbackInfo; + + return $this->narrowArrayType($scope, $arrayType, $itemVar, $keyVar, $expr, $useTruthyFilter); + } + + /** + * @return array{Error|Variable|null, Error|Variable|null, Expr}|null + */ + private function extractCallbackInfo(Scope $scope, Expr $callbackArg): ?array + { + if ($callbackArg instanceof Closure && count($callbackArg->stmts) === 1 && count($callbackArg->params) > 0) { + $statement = $callbackArg->stmts[0]; + if ($statement instanceof Return_ && $statement->expr !== null) { + $itemVar = $callbackArg->params[0]->var; + $keyVar = $callbackArg->params[1]->var ?? null; + return [$itemVar, $keyVar, $statement->expr]; + } + } elseif ($callbackArg instanceof ArrowFunction && count($callbackArg->params) > 0) { + $itemVar = $callbackArg->params[0]->var; + $keyVar = $callbackArg->params[1]->var ?? null; + return [$itemVar, $keyVar, $callbackArg->expr]; + } elseif ( + ($callbackArg instanceof FuncCall || $callbackArg instanceof MethodCall || $callbackArg instanceof StaticCall) + && $callbackArg->isFirstClassCallable() + ) { + $itemVar = new Variable('__item__'); + $args = [new Arg($itemVar)]; + $expr = clone $callbackArg; + $expr->args = $args; + return [$itemVar, null, $expr]; + } + + $constantStrings = $scope->getType($callbackArg)->getConstantStrings(); + if (count($constantStrings) > 0) { + $itemVar = new Variable('__item__'); + $args = [new Arg($itemVar)]; + foreach ($constantStrings as $constantString) { + $funcName = self::createFunctionName($constantString->getValue()); + if ($funcName === null) { + return null; + } + return [$itemVar, null, new FuncCall($funcName, $args)]; + } + } + + return null; + } + + private function narrowArrayType(Scope $scope, Type $arrayType, Error|Variable|null $itemVar, Error|Variable|null $keyVar, Expr $expr, bool $useTruthyFilter): ?Type + { + if (!$scope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + + $constantArrays = $arrayType->getConstantArrays(); + if (count($constantArrays) > 0) { + $results = []; + foreach ($constantArrays as $constantArray) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $optionalKeys = $constantArray->getOptionalKeys(); + foreach ($constantArray->getKeyTypes() as $i => $keyType) { + $itemType = $constantArray->getValueTypes()[$i]; + $narrowed = $this->narrowKeyAndItemType($scope, $keyType, $itemType, $itemVar, $keyVar, $expr, $useTruthyFilter); + if ($narrowed === null) { + return null; + } + [$newKeyType, $newItemType] = $narrowed; + $optional = in_array($i, $optionalKeys, true); + if ($newKeyType instanceof NeverType || $newItemType instanceof NeverType) { + if (!$optional) { + $results[] = new ConstantArrayType([], []); + } + continue; + } + $builder->setOffsetValueType($newKeyType, $newItemType, $optional); + } + $results[] = $builder->getArray(); + } + + return TypeCombinator::union(...$results); + } + + $narrowed = $this->narrowKeyAndItemType($scope, $arrayType->getIterableKeyType(), $arrayType->getIterableValueType(), $itemVar, $keyVar, $expr, $useTruthyFilter); + if ($narrowed === null) { + return null; + } + [$newKeyType, $newItemType] = $narrowed; + + if ($newItemType instanceof NeverType || $newKeyType instanceof NeverType) { + return new ConstantArrayType([], []); + } + + return new ArrayType($newKeyType, $newItemType); + } + + /** + * @return array{Type, Type}|null + */ + private function narrowKeyAndItemType(MutatingScope $scope, Type $keyType, Type $itemType, Error|Variable|null $itemVar, Error|Variable|null $keyVar, Expr $expr, bool $useTruthyFilter): ?array + { + $itemVarName = null; + if ($itemVar !== null) { + if (!$itemVar instanceof Variable || !is_string($itemVar->name)) { + return null; + } + $itemVarName = $itemVar->name; + $scope = $scope->assignVariable($itemVarName, $itemType, new MixedType(), TrinaryLogic::createYes()); + } + + $keyVarName = null; + if ($keyVar !== null) { + if (!$keyVar instanceof Variable || !is_string($keyVar->name)) { + return null; + } + $keyVarName = $keyVar->name; + $scope = $scope->assignVariable($keyVarName, $keyType, new MixedType(), TrinaryLogic::createYes()); + } + + if ($useTruthyFilter) { + $filteredScope = $scope->filterByTruthyValue($expr); + } else { + $filteredScope = $scope->filterByFalseyValue($expr); + } + + return [ + $keyVarName !== null ? $filteredScope->getVariableType($keyVarName) : $keyType, + $itemVarName !== null ? $filteredScope->getVariableType($itemVarName) : $itemType, + ]; + } + + 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-type-narrowing.php b/tests/PHPStan/Analyser/nsrt/array-all-type-narrowing.php new file mode 100644 index 0000000000..0e05ae3b70 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-all-type-narrowing.php @@ -0,0 +1,204 @@ += 8.4 + +declare(strict_types = 1); + +namespace ArrayAllTypeNarrowing; + +use function PHPStan\Testing\assertType; + +/** + * @phpstan-assert-if-true int $val + */ +function isInt(mixed $val): bool +{ + return is_int($val); +} + +/** + * @phpstan-assert-if-true string $val + */ +function isString(mixed $val): bool +{ + return is_string($val); +} + +// array_all with arrow function +/** @param array $array */ +function testArrayAllArrowFunction(array $array): void +{ + if (array_all($array, fn ($v) => is_int($v))) { + assertType('array', $array); + } + assertType('array', $array); +} + +// array_all with closure +/** @param array $array */ +function testArrayAllClosure(array $array): void +{ + if (array_all($array, function ($v) { return is_int($v); })) { + assertType('array', $array); + } +} + +// array_all with first-class callable (built-in) +/** @param array $array */ +function testArrayAllFirstClassCallable(array $array): void +{ + if (array_all($array, is_int(...))) { + assertType('array', $array); + } +} + +// array_all with first-class callable (phpstan-assert-if-true) +/** @param array $array */ +function testArrayAllAssertIfTrue(array $array): void +{ + if (array_all($array, isInt(...))) { + assertType('array', $array); + } +} + +// array_all with string callable +/** @param array $array */ +function testArrayAllStringCallable(array $array): void +{ + if (array_all($array, 'is_int')) { + assertType('array', $array); + } +} + +// array_all narrowing to string +/** @param array $array */ +function testArrayAllIsString(array $array): void +{ + if (array_all($array, fn ($v) => is_string($v))) { + assertType('array', $array); + } +} + +// array_all with instanceof +/** @param array $array */ +function testArrayAllInstanceof(array $array): void +{ + if (array_all($array, fn ($v) => $v instanceof \stdClass)) { + assertType('array', $array); + } +} + +// array_all key narrowing +/** @param array $array */ +function testArrayAllKeyNarrowing(array $array): void +{ + if (array_all($array, fn ($v, $k) => is_string($k))) { + assertType('array', $array); + } +} + +// array_all narrowing both key and value +/** @param array $array */ +function testArrayAllBothNarrowing(array $array): void +{ + if (array_all($array, fn ($v, $k) => is_string($k) && is_int($v))) { + assertType('array', $array); + } +} + +// array_all preserves key type when only value is narrowed +/** @param array $array */ +function testArrayAllPreservesKeyType(array $array): void +{ + if (array_all($array, fn ($v) => is_int($v))) { + assertType('array', $array); + } +} + +// array_any in falsey context +/** @param array $array */ +function testArrayAnyFalsey(array $array): void +{ + if (!array_any($array, fn ($v) => is_int($v))) { + assertType('array', $array); + } + assertType('array', $array); +} + +// array_any falsey with first-class callable +/** @param array $array */ +function testArrayAnyFalseyFirstClass(array $array): void +{ + if (!array_any($array, is_int(...))) { + assertType('array', $array); + } +} + +// array_any falsey with string callable +/** @param array $array */ +function testArrayAnyFalseyStringCallable(array $array): void +{ + if (!array_any($array, 'is_int')) { + assertType('array', $array); + } +} + +// array_all with non-empty-array preserves non-empty +/** @param non-empty-array $array */ +function testArrayAllNonEmptyArray(array $array): void +{ + if (array_all($array, fn ($v) => is_int($v))) { + assertType('non-empty-array', $array); + } +} + +// assert(array_all(...)) narrows the array +/** @param array $values */ +function testAssertArrayAll(array $values): void +{ + assert(array_all($values, fn ($v, $k) => is_string($k))); + assertType('array', $values); +} + +// array_all with list +/** @param list $array */ +function testArrayAllList(array $array): void +{ + if (array_all($array, fn ($v) => is_int($v))) { + assertType('list', $array); + } +} + +// array_all falsey does not narrow (we only know at least one doesn't match) +/** @param array $array */ +function testArrayAllFalsey(array $array): void +{ + if (!array_all($array, fn ($v) => is_int($v))) { + assertType('array', $array); + } +} + +// array_any truthy does not narrow value type (only one matches) +/** @param array $array */ +function testArrayAnyTruthy(array $array): void +{ + if (array_any($array, fn ($v) => is_int($v))) { + assertType('array', $array); + } +} + +// array_all with constant array +/** @param array{a: int|string, b: int|string} $array */ +function testArrayAllConstantArray(array $array): void +{ + if (array_all($array, fn ($v) => is_int($v))) { + assertType('array{a: int, b: int}', $array); + } +} + +// array_all with constant array with optional key +/** @param array{a: int|string, b?: int|string} $array */ +function testArrayAllConstantArrayOptional(array $array): void +{ + if (array_all($array, fn ($v) => is_int($v))) { + assertType('array{a: int, b?: int}', $array); + } +}