diff --git a/conf/config.neon b/conf/config.neon index 236aed720d..e946b92542 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -1186,7 +1186,7 @@ services: - phpstan.broker.dynamicFunctionReturnTypeExtension - - class: PHPStan\Type\Php\ArrayFilterFunctionReturnTypeReturnTypeExtension + class: PHPStan\Type\Php\ArrayFilterFunctionReturnTypeExtension tags: - phpstan.broker.dynamicFunctionReturnTypeExtension diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 77ff81fd1a..d06f9c4923 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1301,7 +1301,7 @@ parameters: - message: "#^Doing instanceof PHPStan\\\\Type\\\\Constant\\\\ConstantBooleanType is error\\-prone and deprecated\\. Use Type\\:\\:isTrue\\(\\) or Type\\:\\:isFalse\\(\\) instead\\.$#" count: 1 - path: src/Type/Php/ArrayFilterFunctionReturnTypeReturnTypeExtension.php + path: src/Type/Php/ArrayFilterFunctionReturnTypeExtension.php - message: "#^Doing instanceof PHPStan\\\\Type\\\\Constant\\\\ConstantStringType is error\\-prone and deprecated\\. Use Type\\:\\:getConstantStrings\\(\\) instead\\.$#" diff --git a/src/Type/Php/ArrayFilterFunctionReturnTypeReturnTypeExtension.php b/src/Type/Php/ArrayFilterFunctionReturnTypeExtension.php similarity index 60% rename from src/Type/Php/ArrayFilterFunctionReturnTypeReturnTypeExtension.php rename to src/Type/Php/ArrayFilterFunctionReturnTypeExtension.php index c44d17a250..4672fd1a96 100644 --- a/src/Type/Php/ArrayFilterFunctionReturnTypeReturnTypeExtension.php +++ b/src/Type/Php/ArrayFilterFunctionReturnTypeExtension.php @@ -6,22 +6,24 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\ArrowFunction; use PhpParser\Node\Expr\Closure; -use PhpParser\Node\Expr\ConstFetch; 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\Scalar\String_; use PhpParser\Node\Stmt\Return_; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\Scope; use PHPStan\Reflection\FunctionReflection; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\ShouldNotHappenException; use PHPStan\Type\ArrayType; use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\Constant\ConstantBooleanType; +use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; use PHPStan\Type\ErrorType; use PHPStan\Type\MixedType; @@ -35,12 +37,20 @@ use function count; use function in_array; use function is_string; -use function strtolower; +use function sprintf; use function substr; -final class ArrayFilterFunctionReturnTypeReturnTypeExtension implements DynamicFunctionReturnTypeExtension +final class ArrayFilterFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension { + private const USE_BOTH = 1; + private const USE_KEY = 2; + private const USE_ITEM = 3; + + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + public function isFunctionSupported(FunctionReflection $functionReflection): bool { return $functionReflection->getName() === 'array_filter'; @@ -72,70 +82,69 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, ]); } - if ($callbackArg === null || ($callbackArg instanceof ConstFetch && strtolower($callbackArg->name->getParts()[0]) === 'null')) { + if ($callbackArg === null || $scope->getType($callbackArg)->isNull()->yes()) { return TypeCombinator::union( ...array_map([$this, 'removeFalsey'], $arrayArgType->getArrays()), ); } - if ($flagArg === null) { - if ($callbackArg instanceof Closure && count($callbackArg->stmts) === 1 && count($callbackArg->params) > 0) { - $statement = $callbackArg->stmts[0]; - if ($statement instanceof Return_ && $statement->expr !== null) { - return $this->filterByTruthyValue($scope, $callbackArg->params[0]->var, $arrayArgType, null, $statement->expr); - } - } elseif ($callbackArg instanceof ArrowFunction && count($callbackArg->params) > 0) { - return $this->filterByTruthyValue($scope, $callbackArg->params[0]->var, $arrayArgType, null, $callbackArg->expr); - } elseif ($callbackArg instanceof String_) { - $funcName = self::createFunctionName($callbackArg->value); - if ($funcName === null) { - return new ErrorType(); - } - - $itemVar = new Variable('item'); - $expr = new FuncCall($funcName, [new Arg($itemVar)]); - return $this->filterByTruthyValue($scope, $itemVar, $arrayArgType, null, $expr); - } + $mode = $this->determineMode($flagArg, $scope); + if ($mode === null) { + return new ArrayType($keyType, $itemType); } - if ($flagArg instanceof ConstFetch && $flagArg->name->getParts()[0] === 'ARRAY_FILTER_USE_KEY') { - if ($callbackArg instanceof Closure && count($callbackArg->stmts) === 1 && count($callbackArg->params) > 0) { - $statement = $callbackArg->stmts[0]; - if ($statement instanceof Return_ && $statement->expr !== null) { - return $this->filterByTruthyValue($scope, null, $arrayArgType, $callbackArg->params[0]->var, $statement->expr); + 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; } - } elseif ($callbackArg instanceof ArrowFunction && count($callbackArg->params) > 0) { - return $this->filterByTruthyValue($scope, null, $arrayArgType, $callbackArg->params[0]->var, $callbackArg->expr); - } elseif ($callbackArg instanceof String_) { - $funcName = self::createFunctionName($callbackArg->value); - if ($funcName === null) { - return new ErrorType(); - } - - $keyVar = new Variable('key'); - $expr = new FuncCall($funcName, [new Arg($keyVar)]); - return $this->filterByTruthyValue($scope, null, $arrayArgType, $keyVar, $expr); + 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 ($flagArg instanceof ConstFetch && $flagArg->name->getParts()[0] === 'ARRAY_FILTER_USE_BOTH') { - if ($callbackArg instanceof Closure && count($callbackArg->stmts) === 1 && count($callbackArg->params) > 0) { - $statement = $callbackArg->stmts[0]; - if ($statement instanceof Return_ && $statement->expr !== null) { - return $this->filterByTruthyValue($scope, $callbackArg->params[0]->var, $arrayArgType, $callbackArg->params[1]->var ?? null, $statement->expr); - } - } elseif ($callbackArg instanceof ArrowFunction && count($callbackArg->params) > 0) { - return $this->filterByTruthyValue($scope, $callbackArg->params[0]->var, $arrayArgType, $callbackArg->params[1]->var ?? null, $callbackArg->expr); - } elseif ($callbackArg instanceof String_) { - $funcName = self::createFunctionName($callbackArg->value); - if ($funcName === null) { - return new ErrorType(); + $expr = new FuncCall($funcName, $args); + $results[] = $this->filterByTruthyValue($scope, $itemVar, $arrayArgType, $keyVar, $expr); } - - $itemVar = new Variable('item'); - $keyVar = new Variable('key'); - $expr = new FuncCall($funcName, [new Arg($itemVar), new Arg($keyVar)]); - return $this->filterByTruthyValue($scope, $itemVar, $arrayArgType, $keyVar, $expr); + return TypeCombinator::union(...$results); } } @@ -280,4 +289,63 @@ private static function createFunctionName(string $funcName): ?Name 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 + */ + private function getConstant(string $constantName): int + { + $constant = $this->reflectionProvider->getConstant(new Name($constantName), null); + $valueType = $constant->getValueType(); + if (!$valueType instanceof ConstantIntegerType) { + throw new ShouldNotHappenException(sprintf('Constant %s does not have integer type.', $constantName)); + } + + return $valueType->getValue(); + } + + /** + * @return self::USE_*|null + */ + private function determineMode(?Expr $flagArg, Scope $scope): ?int + { + if ($flagArg === null) { + return self::USE_ITEM; + } + + $flagValues = $scope->getType($flagArg)->getConstantScalarValues(); + if (count($flagValues) !== 1) { + return null; + } + + if ($flagValues[0] === $this->getConstant('ARRAY_FILTER_USE_KEY')) { + return self::USE_KEY; + } elseif ($flagValues[0] === $this->getConstant('ARRAY_FILTER_USE_BOTH')) { + return self::USE_BOTH; + } + + return null; + } + } diff --git a/tests/PHPStan/Analyser/nsrt/array-filter-string-callables.php b/tests/PHPStan/Analyser/nsrt/array-filter-string-callables.php index ef684865fd..f6e0b6c65e 100644 --- a/tests/PHPStan/Analyser/nsrt/array-filter-string-callables.php +++ b/tests/PHPStan/Analyser/nsrt/array-filter-string-callables.php @@ -77,3 +77,14 @@ public static function isString($value): bool return is_string($value); } } + +function unionOfCallableStrings(): void +{ + $func = rand(0, 1) === 1 ? 'is_string' : 'is_int'; + $list = [ + 1, + 2, + 'foo', + ]; + assertType("array{1, 2}|array{2: 'foo'}", array_filter($list, $func)); +} diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index 6303bdcdc4..2af1147c65 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -1044,4 +1044,9 @@ public function testBug3759(): void $this->analyse([__DIR__ . '/data/bug-3759.php'], []); } + public function testBug11337(): void + { + $this->analyse([__DIR__ . '/data/bug-11337.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-11337.php b/tests/PHPStan/Rules/Methods/data/bug-11337.php new file mode 100644 index 0000000000..49dce6d7a8 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-11337.php @@ -0,0 +1,72 @@ += 8.1 +declare(strict_types = 1); + +namespace Bug11337; + +use function array_filter; + +class Foo +{ + + /** + * @return array<\stdClass> + */ + public function testFunction(): array + { + $objects = [ + new \stdClass(), + null, + new \stdClass(), + null, + ]; + + return array_filter($objects, is_object(...)); + } + + /** + * @return array<1|2> + */ + public function testMethod(): array + { + $objects = [ + 1, + 2, + -4, + 0, + -1, + ]; + + return array_filter($objects, $this->isPositive(...)); + } + + /** + * @return array<'foo'|'bar'> + */ + public function testStaticMethod(): array + { + $objects = [ + '', + 'foo', + '', + 'bar', + ]; + + return array_filter($objects, self::isNonEmptyString(...)); + } + + /** + * @phpstan-assert-if-true int<1, max> $n + */ + private function isPositive(int $n): bool + { + return $n > 0; + } + + /** + * @phpstan-assert-if-true non-empty-string $str + */ + private static function isNonEmptyString(string $str): bool + { + return \strlen($str) > 0; + } +}