Skip to content

Commit 9798bd4

Browse files
authored
Add ArrayFindFunctionReturnTypeExtension
1 parent 3f8c27d commit 9798bd4

File tree

10 files changed

+456
-0
lines changed

10 files changed

+456
-0
lines changed

conf/config.neon

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,11 @@ services:
325325
tags:
326326
- phpstan.parser.richParserNodeVisitor
327327

328+
-
329+
class: PHPStan\Parser\ArrayFindArgVisitor
330+
tags:
331+
- phpstan.parser.richParserNodeVisitor
332+
328333
-
329334
class: PHPStan\Parser\ArrayMapArgVisitor
330335
tags:
@@ -1220,6 +1225,16 @@ services:
12201225
tags:
12211226
- phpstan.broker.dynamicFunctionReturnTypeExtension
12221227

1228+
-
1229+
class: PHPStan\Type\Php\ArrayFindFunctionReturnTypeExtension
1230+
tags:
1231+
- phpstan.broker.dynamicFunctionReturnTypeExtension
1232+
1233+
-
1234+
class: PHPStan\Type\Php\ArrayFindKeyFunctionReturnTypeExtension
1235+
tags:
1236+
- phpstan.broker.dynamicFunctionReturnTypeExtension
1237+
12231238
-
12241239
class: PHPStan\Type\Php\ArrayKeyDynamicReturnTypeExtension
12251240
tags:

src/Parser/ArrayFindArgVisitor.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Parser;
4+
5+
use PhpParser\Node;
6+
use PhpParser\NodeVisitorAbstract;
7+
use function in_array;
8+
9+
final class ArrayFindArgVisitor extends NodeVisitorAbstract
10+
{
11+
12+
public const ATTRIBUTE_NAME = 'isArrayFindArg';
13+
14+
public function enterNode(Node $node): ?Node
15+
{
16+
if ($node instanceof Node\Expr\FuncCall && $node->name instanceof Node\Name) {
17+
$functionName = $node->name->toLowerString();
18+
if (in_array($functionName, ['array_find', 'array_find_key'], true)) {
19+
$args = $node->getRawArgs();
20+
if (isset($args[0])) {
21+
$args[0]->setAttribute(self::ATTRIBUTE_NAME, true);
22+
}
23+
}
24+
}
25+
return null;
26+
}
27+
28+
}

src/Reflection/ParametersAcceptorSelector.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use PHPStan\Analyser\Scope;
1010
use PHPStan\Node\Expr\ParameterVariableOriginalValueExpr;
1111
use PHPStan\Parser\ArrayFilterArgVisitor;
12+
use PHPStan\Parser\ArrayFindArgVisitor;
1213
use PHPStan\Parser\ArrayMapArgVisitor;
1314
use PHPStan\Parser\ArrayWalkArgVisitor;
1415
use PHPStan\Parser\ClosureBindArgVisitor;
@@ -257,6 +258,37 @@ public static function selectFromArgs(
257258
];
258259
}
259260

261+
if (isset($args[0]) && (bool) $args[0]->getAttribute(ArrayFindArgVisitor::ATTRIBUTE_NAME)) {
262+
$acceptor = $parametersAcceptors[0];
263+
$parameters = $acceptor->getParameters();
264+
$argType = $scope->getType($args[0]->value);
265+
$parameters[1] = new NativeParameterReflection(
266+
$parameters[1]->getName(),
267+
$parameters[1]->isOptional(),
268+
new CallableType(
269+
[
270+
new DummyParameter('value', $scope->getIterableValueType($argType), false, PassedByReference::createNo(), false, null),
271+
new DummyParameter('key', $scope->getIterableKeyType($argType), false, PassedByReference::createNo(), false, null),
272+
],
273+
new BooleanType(),
274+
false,
275+
),
276+
$parameters[1]->passedByReference(),
277+
$parameters[1]->isVariadic(),
278+
$parameters[1]->getDefaultValue(),
279+
);
280+
$parametersAcceptors = [
281+
new FunctionVariant(
282+
$acceptor->getTemplateTypeMap(),
283+
$acceptor->getResolvedTemplateTypeMap(),
284+
$parameters,
285+
$acceptor->isVariadic(),
286+
$acceptor->getReturnType(),
287+
$acceptor instanceof ParametersAcceptorWithPhpDocs ? $acceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(),
288+
),
289+
];
290+
}
291+
260292
if (isset($args[0])) {
261293
$closureBindToVar = $args[0]->getAttribute(ClosureBindToVarVisitor::ATTRIBUTE_NAME);
262294
if (
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Php;
4+
5+
use PhpParser\Node\Expr\FuncCall;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\Reflection\FunctionReflection;
8+
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
9+
use PHPStan\Type\Type;
10+
use PHPStan\Type\TypeCombinator;
11+
use function array_map;
12+
use function count;
13+
14+
final class ArrayFindFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
15+
{
16+
17+
public function __construct(private ArrayFilterFunctionReturnTypeHelper $arrayFilterFunctionReturnTypeHelper)
18+
{
19+
}
20+
21+
public function isFunctionSupported(FunctionReflection $functionReflection): bool
22+
{
23+
return $functionReflection->getName() === 'array_find';
24+
}
25+
26+
public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type
27+
{
28+
if (count($functionCall->getArgs()) < 2) {
29+
return null;
30+
}
31+
32+
$arrayType = $scope->getType($functionCall->getArgs()[0]->value);
33+
if (count($arrayType->getArrays()) < 1) {
34+
return null;
35+
}
36+
37+
$arrayArg = $functionCall->getArgs()[0]->value ?? null;
38+
$callbackArg = $functionCall->getArgs()[1]->value ?? null;
39+
40+
$resultTypes = $this->arrayFilterFunctionReturnTypeHelper->getType($scope, $arrayArg, $callbackArg, null);
41+
$resultType = TypeCombinator::union(...array_map(static fn ($type) => $type->getIterableValueType(), $resultTypes->getArrays()));
42+
43+
return $resultTypes->isIterableAtLeastOnce()->yes() ? $resultType : TypeCombinator::addNull($resultType);
44+
}
45+
46+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Php;
4+
5+
use PhpParser\Node\Expr\FuncCall;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\Reflection\FunctionReflection;
8+
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
9+
use PHPStan\Type\NullType;
10+
use PHPStan\Type\Type;
11+
use PHPStan\Type\TypeCombinator;
12+
use function count;
13+
14+
final class ArrayFindKeyFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
15+
{
16+
17+
public function isFunctionSupported(FunctionReflection $functionReflection): bool
18+
{
19+
return $functionReflection->getName() === 'array_find_key';
20+
}
21+
22+
public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type
23+
{
24+
if (count($functionCall->getArgs()) < 2) {
25+
return null;
26+
}
27+
28+
$arrayType = $scope->getType($functionCall->getArgs()[0]->value);
29+
if (count($arrayType->getArrays()) < 1) {
30+
return null;
31+
}
32+
33+
return TypeCombinator::union($arrayType->getIterableKeyType(), new NullType());
34+
}
35+
36+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
namespace {
4+
5+
if (!function_exists('array_find_key')) {
6+
/**
7+
* @param array<mixed> $array
8+
* @param callable(mixed, array-key=): mixed $callback
9+
* @return ?array-key
10+
*/
11+
function array_find_key(array $array, callable $callback)
12+
{
13+
foreach ($array as $key => $value) {
14+
if ($callback($value, $key)) { // @phpstan-ignore if.condNotBoolean
15+
return $key;
16+
}
17+
}
18+
19+
return null;
20+
}
21+
}
22+
23+
}
24+
25+
namespace ArrayFindKey
26+
{
27+
28+
use function PHPStan\Testing\assertType;
29+
30+
/**
31+
* @param array<mixed> $array
32+
* @phpstan-ignore missingType.callable
33+
*/
34+
function testMixed(array $array, callable $callback): void
35+
{
36+
assertType('int|string|null', array_find_key($array, $callback));
37+
assertType('int|string|null', array_find_key($array, 'is_int'));
38+
}
39+
40+
/**
41+
* @param array{1, 'foo', \DateTime} $array
42+
* @phpstan-ignore missingType.callable
43+
*/
44+
function testConstant(array $array, callable $callback): void
45+
{
46+
assertType("0|1|2|null", array_find_key($array, $callback));
47+
assertType("0|1|2|null", array_find_key($array, 'is_int'));
48+
}
49+
50+
function testCallback(): void
51+
{
52+
$subject = ['foo' => 1, 'bar' => null, 'buz' => ''];
53+
$result = array_find_key($subject, function ($value, $key) {
54+
assertType("array{value: 1|''|null, key: 'bar'|'buz'|'foo'}", compact('value', 'key'));
55+
56+
return is_int($value);
57+
});
58+
59+
assertType("'bar'|'buz'|'foo'|null", $result);
60+
}
61+
62+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
namespace {
4+
5+
if (!function_exists('array_find')) {
6+
/**
7+
* @param array<mixed> $array
8+
* @param callable(mixed, array-key=): mixed $callback
9+
* @return mixed
10+
*/
11+
function array_find(array $array, callable $callback)
12+
{
13+
foreach ($array as $key => $value) {
14+
if ($callback($value, $key)) { // @phpstan-ignore if.condNotBoolean
15+
return $value;
16+
}
17+
}
18+
19+
return null;
20+
}
21+
}
22+
23+
}
24+
25+
namespace ArrayFind
26+
{
27+
28+
use function PHPStan\Testing\assertType;
29+
30+
/**
31+
* @param array<mixed> $array
32+
* @param non-empty-array<mixed> $non_empty_array
33+
* @phpstan-ignore missingType.callable
34+
*/
35+
function testMixed(array $array, array $non_empty_array, callable $callback): void
36+
{
37+
assertType('mixed', array_find($array, $callback));
38+
assertType('int|null', array_find($array, 'is_int'));
39+
assertType('mixed', array_find($non_empty_array, $callback));
40+
assertType('int|null', array_find($non_empty_array, 'is_int'));
41+
}
42+
43+
/**
44+
* @param array{1, 'foo', \DateTime} $array
45+
* @phpstan-ignore missingType.callable
46+
*/
47+
function testConstant(array $array, callable $callback): void
48+
{
49+
assertType("1|'foo'|DateTime|null", array_find($array, $callback));
50+
assertType('1', array_find($array, 'is_int'));
51+
}
52+
53+
/**
54+
* @param array<int> $array
55+
* @param non-empty-array<int> $non_empty_array
56+
* @phpstan-ignore missingType.callable
57+
*/
58+
function testInt(array $array, array $non_empty_array, callable $callback): void
59+
{
60+
assertType('int|null', array_find($array, $callback));
61+
assertType('int|null', array_find($array, 'is_int'));
62+
assertType('int|null', array_find($non_empty_array, $callback));
63+
// should be 'int'
64+
assertType('int|null', array_find($non_empty_array, 'is_int'));
65+
}
66+
67+
function testCallback(): void
68+
{
69+
$subject = ['foo' => 1, 'bar' => null, 'buz' => ''];
70+
$result = array_find($subject, function ($value, $key) {
71+
assertType("array{value: 1|''|null, key: 'bar'|'buz'|'foo'}", compact('value', 'key'));
72+
73+
return is_int($value);
74+
});
75+
76+
assertType("1|''|null", $result);
77+
}
78+
79+
}

tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -886,6 +886,58 @@ public function testArrayFilterCallback(bool $checkExplicitMixed): void
886886
$this->analyse([__DIR__ . '/data/array_filter_callback.php'], $errors);
887887
}
888888

889+
public function testArrayFindCallback(): void
890+
{
891+
$this->analyse([__DIR__ . '/data/array_find.php'], [
892+
[
893+
'Parameter #2 $callback of function array_find expects callable(1|2, \'bar\'|\'foo\'): bool, Closure(string, int): bool given.',
894+
22,
895+
],
896+
[
897+
'Parameter #2 $callback of function array_find expects callable(1|2, \'bar\'|\'foo\'): bool, Closure(string, int): bool given.',
898+
30,
899+
],
900+
[
901+
'Parameter #2 $callback of function array_find expects callable(1|2, \'bar\'|\'foo\'): bool, Closure(int, string): (\'bar\'|\'foo\') given.',
902+
36,
903+
],
904+
[
905+
'Parameter #2 $callback of function array_find expects callable(mixed, int|string): bool, Closure(string, array): false given.',
906+
49,
907+
],
908+
[
909+
'Parameter #2 $callback of function array_find expects callable(mixed, int|string): bool, Closure(string, int): array{} given.',
910+
52,
911+
],
912+
]);
913+
}
914+
915+
public function testArrayFindKeyCallback(): void
916+
{
917+
$this->analyse([__DIR__ . '/data/array_find_key.php'], [
918+
[
919+
'Parameter #2 $callback of function array_find_key expects callable(1|2, \'bar\'|\'foo\'): bool, Closure(string, int): bool given.',
920+
22,
921+
],
922+
[
923+
'Parameter #2 $callback of function array_find_key expects callable(1|2, \'bar\'|\'foo\'): bool, Closure(string, int): bool given.',
924+
30,
925+
],
926+
[
927+
'Parameter #2 $callback of function array_find_key expects callable(1|2, \'bar\'|\'foo\'): bool, Closure(int, string): (\'bar\'|\'foo\') given.',
928+
36,
929+
],
930+
[
931+
'Parameter #2 $callback of function array_find_key expects callable(mixed, int|string): bool, Closure(string, array): false given.',
932+
49,
933+
],
934+
[
935+
'Parameter #2 $callback of function array_find_key expects callable(mixed, int|string): bool, Closure(string, int): array{} given.',
936+
52,
937+
],
938+
]);
939+
}
940+
889941
public function testBug5356(): void
890942
{
891943
$this->analyse([__DIR__ . '/data/bug-5356.php'], [

0 commit comments

Comments
 (0)