Skip to content
Merged
15 changes: 15 additions & 0 deletions conf/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,11 @@ services:
tags:
- phpstan.parser.richParserNodeVisitor

-
class: PHPStan\Parser\ArrayFindArgVisitor
tags:
- phpstan.parser.richParserNodeVisitor

-
class: PHPStan\Parser\ArrayMapArgVisitor
tags:
Expand Down Expand Up @@ -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:
Expand Down
28 changes: 28 additions & 0 deletions src/Parser/ArrayFindArgVisitor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php declare(strict_types = 1);

namespace PHPStan\Parser;

use PhpParser\Node;
use PhpParser\NodeVisitorAbstract;
use function in_array;

final class ArrayFindArgVisitor extends NodeVisitorAbstract
{

public const ATTRIBUTE_NAME = 'isArrayFindArg';

public function enterNode(Node $node): ?Node
{
if ($node instanceof Node\Expr\FuncCall && $node->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;
}

}
32 changes: 32 additions & 0 deletions src/Reflection/ParametersAcceptorSelector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
Expand Down
46 changes: 46 additions & 0 deletions src/Type/Php/ArrayFindFunctionReturnTypeExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type\Php;

use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use function array_map;
use function count;

final class ArrayFindFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
{

public function __construct(private ArrayFilterFunctionReturnTypeHelper $arrayFilterFunctionReturnTypeHelper)
{
}

public function isFunctionSupported(FunctionReflection $functionReflection): bool
{
return $functionReflection->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);
}

}
36 changes: 36 additions & 0 deletions src/Type/Php/ArrayFindKeyFunctionReturnTypeExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type\Php;

use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
use PHPStan\Type\NullType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use function count;

final class ArrayFindKeyFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
{

public function isFunctionSupported(FunctionReflection $functionReflection): bool
{
return $functionReflection->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());
}

}
62 changes: 62 additions & 0 deletions tests/PHPStan/Analyser/nsrt/array-find-key.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

namespace {

if (!function_exists('array_find_key')) {
/**
* @param array<mixed> $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<mixed> $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'));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should test on non-empty-array and it should gives int|string

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add a test case, but if you pass a non-empty-array<mixed> the function may return any int|string|null.

https://3v4l.org/Biok8

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, my bad.

}

/**
* @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'));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be 0

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, that's correct. This PR does not implement the logic to detect this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the current behavior ? I assume the type is considered as mixed.

This means that merging the PR as if will introduce a lot of false positif PHPstan errors on level 7 (unions) when it's currently on level 9 (mixed).
That's why IMHO if a dynamic return type extension is introduced, the implementation need to handle such case.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the current behavior ? I assume the type is considered as mixed.

just copy the test over into the playground:
https://phpstan.org/r/ece55e5c-33bf-47cb-b538-35708d025dfa

}

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);
}

}
79 changes: 79 additions & 0 deletions tests/PHPStan/Analyser/nsrt/array-find.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

namespace {

if (!function_exists('array_find')) {
/**
* @param array<mixed> $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<mixed> $array
* @param non-empty-array<mixed> $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'));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should also test it on non empty array, and the result should be an int

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like it can be improved at the same time as array_filter().

https://phpstan.org/r/ab62bff1-d485-46ae-9dfa-1931ce320a25

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<int> $array
* @param non-empty-array<int> $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);
}

}
52 changes: 52 additions & 0 deletions tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'], [
Expand Down
Loading
Loading