diff --git a/conf/config.neon b/conf/config.neon index f9d8ca018e..11e845136d 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -325,6 +325,11 @@ services: tags: - phpstan.parser.richParserNodeVisitor + - + class: PHPStan\Parser\ArrayFindArgVisitor + tags: + - phpstan.parser.richParserNodeVisitor + - class: PHPStan\Parser\ArrayMapArgVisitor tags: @@ -1220,6 +1225,16 @@ services: tags: - phpstan.broker.dynamicFunctionReturnTypeExtension + - + class: PHPStan\Type\Php\ArrayFindFunctionReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension + + - + class: PHPStan\Type\Php\ArrayFindKeyFunctionReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension + - class: PHPStan\Type\Php\ArrayKeyDynamicReturnTypeExtension tags: diff --git a/src/Parser/ArrayFindArgVisitor.php b/src/Parser/ArrayFindArgVisitor.php new file mode 100644 index 0000000000..8b25b36491 --- /dev/null +++ b/src/Parser/ArrayFindArgVisitor.php @@ -0,0 +1,28 @@ +name instanceof Node\Name) { + $functionName = $node->name->toLowerString(); + if (in_array($functionName, ['array_find', 'array_find_key'], true)) { + $args = $node->getRawArgs(); + if (isset($args[0])) { + $args[0]->setAttribute(self::ATTRIBUTE_NAME, true); + } + } + } + return null; + } + +} diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index 619ee2aa81..07a7c28080 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\Scope; use PHPStan\Node\Expr\ParameterVariableOriginalValueExpr; use PHPStan\Parser\ArrayFilterArgVisitor; +use PHPStan\Parser\ArrayFindArgVisitor; use PHPStan\Parser\ArrayMapArgVisitor; use PHPStan\Parser\ArrayWalkArgVisitor; use PHPStan\Parser\ClosureBindArgVisitor; @@ -257,6 +258,37 @@ public static function selectFromArgs( ]; } + if (isset($args[0]) && (bool) $args[0]->getAttribute(ArrayFindArgVisitor::ATTRIBUTE_NAME)) { + $acceptor = $parametersAcceptors[0]; + $parameters = $acceptor->getParameters(); + $argType = $scope->getType($args[0]->value); + $parameters[1] = new NativeParameterReflection( + $parameters[1]->getName(), + $parameters[1]->isOptional(), + new CallableType( + [ + new DummyParameter('value', $scope->getIterableValueType($argType), false, PassedByReference::createNo(), false, null), + new DummyParameter('key', $scope->getIterableKeyType($argType), false, PassedByReference::createNo(), false, null), + ], + new BooleanType(), + false, + ), + $parameters[1]->passedByReference(), + $parameters[1]->isVariadic(), + $parameters[1]->getDefaultValue(), + ); + $parametersAcceptors = [ + new FunctionVariant( + $acceptor->getTemplateTypeMap(), + $acceptor->getResolvedTemplateTypeMap(), + $parameters, + $acceptor->isVariadic(), + $acceptor->getReturnType(), + $acceptor instanceof ParametersAcceptorWithPhpDocs ? $acceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + ), + ]; + } + if (isset($args[0])) { $closureBindToVar = $args[0]->getAttribute(ClosureBindToVarVisitor::ATTRIBUTE_NAME); if ( diff --git a/src/Type/Php/ArrayFindFunctionReturnTypeExtension.php b/src/Type/Php/ArrayFindFunctionReturnTypeExtension.php new file mode 100644 index 0000000000..220d8fa0ef --- /dev/null +++ b/src/Type/Php/ArrayFindFunctionReturnTypeExtension.php @@ -0,0 +1,46 @@ +getName() === 'array_find'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) < 2) { + return null; + } + + $arrayType = $scope->getType($functionCall->getArgs()[0]->value); + if (count($arrayType->getArrays()) < 1) { + return null; + } + + $arrayArg = $functionCall->getArgs()[0]->value ?? null; + $callbackArg = $functionCall->getArgs()[1]->value ?? null; + + $resultTypes = $this->arrayFilterFunctionReturnTypeHelper->getType($scope, $arrayArg, $callbackArg, null); + $resultType = TypeCombinator::union(...array_map(static fn ($type) => $type->getIterableValueType(), $resultTypes->getArrays())); + + return $resultTypes->isIterableAtLeastOnce()->yes() ? $resultType : TypeCombinator::addNull($resultType); + } + +} diff --git a/src/Type/Php/ArrayFindKeyFunctionReturnTypeExtension.php b/src/Type/Php/ArrayFindKeyFunctionReturnTypeExtension.php new file mode 100644 index 0000000000..97c514f427 --- /dev/null +++ b/src/Type/Php/ArrayFindKeyFunctionReturnTypeExtension.php @@ -0,0 +1,36 @@ +getName() === 'array_find_key'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) < 2) { + return null; + } + + $arrayType = $scope->getType($functionCall->getArgs()[0]->value); + if (count($arrayType->getArrays()) < 1) { + return null; + } + + return TypeCombinator::union($arrayType->getIterableKeyType(), new NullType()); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/array-find-key.php b/tests/PHPStan/Analyser/nsrt/array-find-key.php new file mode 100644 index 0000000000..5caf828f53 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-find-key.php @@ -0,0 +1,62 @@ + $array + * @param callable(mixed, array-key=): mixed $callback + * @return ?array-key + */ + function array_find_key(array $array, callable $callback) + { + foreach ($array as $key => $value) { + if ($callback($value, $key)) { // @phpstan-ignore if.condNotBoolean + return $key; + } + } + + return null; + } + } + +} + +namespace ArrayFindKey +{ + + use function PHPStan\Testing\assertType; + + /** + * @param array $array + * @phpstan-ignore missingType.callable + */ + function testMixed(array $array, callable $callback): void + { + assertType('int|string|null', array_find_key($array, $callback)); + assertType('int|string|null', array_find_key($array, 'is_int')); + } + + /** + * @param array{1, 'foo', \DateTime} $array + * @phpstan-ignore missingType.callable + */ + function testConstant(array $array, callable $callback): void + { + assertType("0|1|2|null", array_find_key($array, $callback)); + assertType("0|1|2|null", array_find_key($array, 'is_int')); + } + + function testCallback(): void + { + $subject = ['foo' => 1, 'bar' => null, 'buz' => '']; + $result = array_find_key($subject, function ($value, $key) { + assertType("array{value: 1|''|null, key: 'bar'|'buz'|'foo'}", compact('value', 'key')); + + return is_int($value); + }); + + assertType("'bar'|'buz'|'foo'|null", $result); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/array-find.php b/tests/PHPStan/Analyser/nsrt/array-find.php new file mode 100644 index 0000000000..f3b5b0b822 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-find.php @@ -0,0 +1,79 @@ + $array + * @param callable(mixed, array-key=): mixed $callback + * @return mixed + */ + function array_find(array $array, callable $callback) + { + foreach ($array as $key => $value) { + if ($callback($value, $key)) { // @phpstan-ignore if.condNotBoolean + return $value; + } + } + + return null; + } + } + +} + +namespace ArrayFind +{ + + use function PHPStan\Testing\assertType; + + /** + * @param array $array + * @param non-empty-array $non_empty_array + * @phpstan-ignore missingType.callable + */ + function testMixed(array $array, array $non_empty_array, callable $callback): void + { + assertType('mixed', array_find($array, $callback)); + assertType('int|null', array_find($array, 'is_int')); + assertType('mixed', array_find($non_empty_array, $callback)); + assertType('int|null', array_find($non_empty_array, 'is_int')); + } + + /** + * @param array{1, 'foo', \DateTime} $array + * @phpstan-ignore missingType.callable + */ + function testConstant(array $array, callable $callback): void + { + assertType("1|'foo'|DateTime|null", array_find($array, $callback)); + assertType('1', array_find($array, 'is_int')); + } + + /** + * @param array $array + * @param non-empty-array $non_empty_array + * @phpstan-ignore missingType.callable + */ + function testInt(array $array, array $non_empty_array, callable $callback): void + { + assertType('int|null', array_find($array, $callback)); + assertType('int|null', array_find($array, 'is_int')); + assertType('int|null', array_find($non_empty_array, $callback)); + // should be 'int' + assertType('int|null', array_find($non_empty_array, 'is_int')); + } + + function testCallback(): void + { + $subject = ['foo' => 1, 'bar' => null, 'buz' => '']; + $result = array_find($subject, function ($value, $key) { + assertType("array{value: 1|''|null, key: 'bar'|'buz'|'foo'}", compact('value', 'key')); + + return is_int($value); + }); + + assertType("1|''|null", $result); + } + +} diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 4b00fb720e..cec109d5f8 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -886,6 +886,58 @@ public function testArrayFilterCallback(bool $checkExplicitMixed): void $this->analyse([__DIR__ . '/data/array_filter_callback.php'], $errors); } + public function testArrayFindCallback(): void + { + $this->analyse([__DIR__ . '/data/array_find.php'], [ + [ + 'Parameter #2 $callback of function array_find expects callable(1|2, \'bar\'|\'foo\'): bool, Closure(string, int): bool given.', + 22, + ], + [ + 'Parameter #2 $callback of function array_find expects callable(1|2, \'bar\'|\'foo\'): bool, Closure(string, int): bool given.', + 30, + ], + [ + 'Parameter #2 $callback of function array_find expects callable(1|2, \'bar\'|\'foo\'): bool, Closure(int, string): (\'bar\'|\'foo\') given.', + 36, + ], + [ + 'Parameter #2 $callback of function array_find expects callable(mixed, int|string): bool, Closure(string, array): false given.', + 49, + ], + [ + 'Parameter #2 $callback of function array_find expects callable(mixed, int|string): bool, Closure(string, int): array{} given.', + 52, + ], + ]); + } + + public function testArrayFindKeyCallback(): void + { + $this->analyse([__DIR__ . '/data/array_find_key.php'], [ + [ + 'Parameter #2 $callback of function array_find_key expects callable(1|2, \'bar\'|\'foo\'): bool, Closure(string, int): bool given.', + 22, + ], + [ + 'Parameter #2 $callback of function array_find_key expects callable(1|2, \'bar\'|\'foo\'): bool, Closure(string, int): bool given.', + 30, + ], + [ + 'Parameter #2 $callback of function array_find_key expects callable(1|2, \'bar\'|\'foo\'): bool, Closure(int, string): (\'bar\'|\'foo\') given.', + 36, + ], + [ + 'Parameter #2 $callback of function array_find_key expects callable(mixed, int|string): bool, Closure(string, array): false given.', + 49, + ], + [ + 'Parameter #2 $callback of function array_find_key expects callable(mixed, int|string): bool, Closure(string, int): array{} given.', + 52, + ], + ]); + } + public function testBug5356(): void { $this->analyse([__DIR__ . '/data/bug-5356.php'], [ diff --git a/tests/PHPStan/Rules/Functions/data/array_find.php b/tests/PHPStan/Rules/Functions/data/array_find.php new file mode 100644 index 0000000000..a749bd8294 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/array_find.php @@ -0,0 +1,53 @@ += 8.4 + +// ok +array_find( + ['foo' => 1, 'bar' => 2], + function($value, $key) { + return $key === 0; + } +); + +// ok +array_find( + ['foo' => 1, 'bar' => 2], + function(int $value, string $key) { + return $key === 0; + } +); + +// bad parameters +array_find( + ['foo' => 1, 'bar' => 2], + function(string $value, int $key): bool { + return $key === 0; + } +); + +// bad parameters +array_find( + ['foo' => 1, 'bar' => 2], + fn (string $item, int $key) => $key === 0, +); + +// bad return type +array_find( + ['foo' => 1, 'bar' => 2], + function(int $value, string $key) { + return $key; + }, +); + +if (is_array($array)) { + // ok + array_find($array, fn ($value, $key) => $key === 0); + + // ok + array_find($array, fn (string $value, int $key) => $key === 0); + + // bad parameters + array_find($array, fn (string $item, array $key) => $key === 0); + + // bad return type + array_find($array, fn (string $value, int $key): array => []); +} diff --git a/tests/PHPStan/Rules/Functions/data/array_find_key.php b/tests/PHPStan/Rules/Functions/data/array_find_key.php new file mode 100644 index 0000000000..393468d42c --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/array_find_key.php @@ -0,0 +1,53 @@ += 8.4 + +// ok +array_find_key( + ['foo' => 1, 'bar' => 2], + function($value, $key) { + return $key === 0; + } +); + +// ok +array_find_key( + ['foo' => 1, 'bar' => 2], + function(int $value, string $key) { + return $key === 0; + } +); + +// bad parameters +array_find_key( + ['foo' => 1, 'bar' => 2], + function(string $value, int $key): bool { + return $key === 0; + } +); + +// bad parameters +array_find_key( + ['foo' => 1, 'bar' => 2], + fn (string $item, int $key) => $key === 0, +); + +// bad return type +array_find_key( + ['foo' => 1, 'bar' => 2], + function(int $value, string $key) { + return $key; + }, +); + +if (is_array($array)) { + // ok + array_find_key($array, fn ($value, $key) => $key === 0); + + // ok + array_find_key($array, fn (string $value, int $key) => $key === 0); + + // bad parameters + array_find_key($array, fn (string $item, array $key) => $key === 0); + + // bad return type + array_find_key($array, fn (string $value, int $key): array => []); +}