Skip to content

Commit 3e52bb0

Browse files
takaramondrejmirtes
authored andcommitted
Refactor ArrayFilterFunctionReturnTypeReturnTypeExtension and support first-class callable
1 parent 2e345b8 commit 3e52bb0

File tree

6 files changed

+215
-59
lines changed

6 files changed

+215
-59
lines changed

conf/config.neon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1186,7 +1186,7 @@ services:
11861186
- phpstan.broker.dynamicFunctionReturnTypeExtension
11871187

11881188
-
1189-
class: PHPStan\Type\Php\ArrayFilterFunctionReturnTypeReturnTypeExtension
1189+
class: PHPStan\Type\Php\ArrayFilterFunctionReturnTypeExtension
11901190
tags:
11911191
- phpstan.broker.dynamicFunctionReturnTypeExtension
11921192

phpstan-baseline.neon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1301,7 +1301,7 @@ parameters:
13011301
-
13021302
message: "#^Doing instanceof PHPStan\\\\Type\\\\Constant\\\\ConstantBooleanType is error\\-prone and deprecated\\. Use Type\\:\\:isTrue\\(\\) or Type\\:\\:isFalse\\(\\) instead\\.$#"
13031303
count: 1
1304-
path: src/Type/Php/ArrayFilterFunctionReturnTypeReturnTypeExtension.php
1304+
path: src/Type/Php/ArrayFilterFunctionReturnTypeExtension.php
13051305

13061306
-
13071307
message: "#^Doing instanceof PHPStan\\\\Type\\\\Constant\\\\ConstantStringType is error\\-prone and deprecated\\. Use Type\\:\\:getConstantStrings\\(\\) instead\\.$#"

src/Type/Php/ArrayFilterFunctionReturnTypeReturnTypeExtension.php renamed to src/Type/Php/ArrayFilterFunctionReturnTypeExtension.php

Lines changed: 125 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,24 @@
66
use PhpParser\Node\Expr;
77
use PhpParser\Node\Expr\ArrowFunction;
88
use PhpParser\Node\Expr\Closure;
9-
use PhpParser\Node\Expr\ConstFetch;
109
use PhpParser\Node\Expr\Error;
1110
use PhpParser\Node\Expr\FuncCall;
11+
use PhpParser\Node\Expr\MethodCall;
12+
use PhpParser\Node\Expr\StaticCall;
1213
use PhpParser\Node\Expr\Variable;
1314
use PhpParser\Node\Name;
14-
use PhpParser\Node\Scalar\String_;
1515
use PhpParser\Node\Stmt\Return_;
1616
use PHPStan\Analyser\MutatingScope;
1717
use PHPStan\Analyser\Scope;
1818
use PHPStan\Reflection\FunctionReflection;
19+
use PHPStan\Reflection\ReflectionProvider;
1920
use PHPStan\ShouldNotHappenException;
2021
use PHPStan\Type\ArrayType;
2122
use PHPStan\Type\BenevolentUnionType;
2223
use PHPStan\Type\Constant\ConstantArrayType;
2324
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
2425
use PHPStan\Type\Constant\ConstantBooleanType;
26+
use PHPStan\Type\Constant\ConstantIntegerType;
2527
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
2628
use PHPStan\Type\ErrorType;
2729
use PHPStan\Type\MixedType;
@@ -35,12 +37,20 @@
3537
use function count;
3638
use function in_array;
3739
use function is_string;
38-
use function strtolower;
40+
use function sprintf;
3941
use function substr;
4042

41-
final class ArrayFilterFunctionReturnTypeReturnTypeExtension implements DynamicFunctionReturnTypeExtension
43+
final class ArrayFilterFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
4244
{
4345

46+
private const USE_BOTH = 1;
47+
private const USE_KEY = 2;
48+
private const USE_ITEM = 3;
49+
50+
public function __construct(private ReflectionProvider $reflectionProvider)
51+
{
52+
}
53+
4454
public function isFunctionSupported(FunctionReflection $functionReflection): bool
4555
{
4656
return $functionReflection->getName() === 'array_filter';
@@ -72,70 +82,69 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
7282
]);
7383
}
7484

75-
if ($callbackArg === null || ($callbackArg instanceof ConstFetch && strtolower($callbackArg->name->getParts()[0]) === 'null')) {
85+
if ($callbackArg === null || $scope->getType($callbackArg)->isNull()->yes()) {
7686
return TypeCombinator::union(
7787
...array_map([$this, 'removeFalsey'], $arrayArgType->getArrays()),
7888
);
7989
}
8090

81-
if ($flagArg === null) {
82-
if ($callbackArg instanceof Closure && count($callbackArg->stmts) === 1 && count($callbackArg->params) > 0) {
83-
$statement = $callbackArg->stmts[0];
84-
if ($statement instanceof Return_ && $statement->expr !== null) {
85-
return $this->filterByTruthyValue($scope, $callbackArg->params[0]->var, $arrayArgType, null, $statement->expr);
86-
}
87-
} elseif ($callbackArg instanceof ArrowFunction && count($callbackArg->params) > 0) {
88-
return $this->filterByTruthyValue($scope, $callbackArg->params[0]->var, $arrayArgType, null, $callbackArg->expr);
89-
} elseif ($callbackArg instanceof String_) {
90-
$funcName = self::createFunctionName($callbackArg->value);
91-
if ($funcName === null) {
92-
return new ErrorType();
93-
}
94-
95-
$itemVar = new Variable('item');
96-
$expr = new FuncCall($funcName, [new Arg($itemVar)]);
97-
return $this->filterByTruthyValue($scope, $itemVar, $arrayArgType, null, $expr);
98-
}
91+
$mode = $this->determineMode($flagArg, $scope);
92+
if ($mode === null) {
93+
return new ArrayType($keyType, $itemType);
9994
}
10095

101-
if ($flagArg instanceof ConstFetch && $flagArg->name->getParts()[0] === 'ARRAY_FILTER_USE_KEY') {
102-
if ($callbackArg instanceof Closure && count($callbackArg->stmts) === 1 && count($callbackArg->params) > 0) {
103-
$statement = $callbackArg->stmts[0];
104-
if ($statement instanceof Return_ && $statement->expr !== null) {
105-
return $this->filterByTruthyValue($scope, null, $arrayArgType, $callbackArg->params[0]->var, $statement->expr);
96+
if ($callbackArg instanceof Closure && count($callbackArg->stmts) === 1 && count($callbackArg->params) > 0) {
97+
$statement = $callbackArg->stmts[0];
98+
if ($statement instanceof Return_ && $statement->expr !== null) {
99+
if ($mode === self::USE_ITEM) {
100+
$keyVar = null;
101+
$itemVar = $callbackArg->params[0]->var;
102+
} elseif ($mode === self::USE_KEY) {
103+
$keyVar = $callbackArg->params[0]->var;
104+
$itemVar = null;
105+
} elseif ($mode === self::USE_BOTH) {
106+
$keyVar = $callbackArg->params[1]->var ?? null;
107+
$itemVar = $callbackArg->params[0]->var;
106108
}
107-
} elseif ($callbackArg instanceof ArrowFunction && count($callbackArg->params) > 0) {
108-
return $this->filterByTruthyValue($scope, null, $arrayArgType, $callbackArg->params[0]->var, $callbackArg->expr);
109-
} elseif ($callbackArg instanceof String_) {
110-
$funcName = self::createFunctionName($callbackArg->value);
111-
if ($funcName === null) {
112-
return new ErrorType();
113-
}
114-
115-
$keyVar = new Variable('key');
116-
$expr = new FuncCall($funcName, [new Arg($keyVar)]);
117-
return $this->filterByTruthyValue($scope, null, $arrayArgType, $keyVar, $expr);
109+
return $this->filterByTruthyValue($scope, $itemVar, $arrayArgType, $keyVar, $statement->expr);
118110
}
119-
}
111+
} elseif ($callbackArg instanceof ArrowFunction && count($callbackArg->params) > 0) {
112+
if ($mode === self::USE_ITEM) {
113+
$keyVar = null;
114+
$itemVar = $callbackArg->params[0]->var;
115+
} elseif ($mode === self::USE_KEY) {
116+
$keyVar = $callbackArg->params[0]->var;
117+
$itemVar = null;
118+
} elseif ($mode === self::USE_BOTH) {
119+
$keyVar = $callbackArg->params[1]->var ?? null;
120+
$itemVar = $callbackArg->params[0]->var;
121+
}
122+
return $this->filterByTruthyValue($scope, $itemVar, $arrayArgType, $keyVar, $callbackArg->expr);
123+
} elseif (
124+
($callbackArg instanceof FuncCall || $callbackArg instanceof MethodCall || $callbackArg instanceof StaticCall)
125+
&& $callbackArg->isFirstClassCallable()
126+
) {
127+
[$args, $itemVar, $keyVar] = $this->createDummyArgs($mode);
128+
$expr = clone $callbackArg;
129+
$expr->args = $args;
130+
return $this->filterByTruthyValue($scope, $itemVar, $arrayArgType, $keyVar, $expr);
131+
} else {
132+
$constantStrings = $scope->getType($callbackArg)->getConstantStrings();
133+
if (count($constantStrings) > 0) {
134+
$results = [];
135+
[$args, $itemVar, $keyVar] = $this->createDummyArgs($mode);
136+
137+
foreach ($constantStrings as $constantString) {
138+
$funcName = self::createFunctionName($constantString->getValue());
139+
if ($funcName === null) {
140+
$results[] = new ErrorType();
141+
continue;
142+
}
120143

121-
if ($flagArg instanceof ConstFetch && $flagArg->name->getParts()[0] === 'ARRAY_FILTER_USE_BOTH') {
122-
if ($callbackArg instanceof Closure && count($callbackArg->stmts) === 1 && count($callbackArg->params) > 0) {
123-
$statement = $callbackArg->stmts[0];
124-
if ($statement instanceof Return_ && $statement->expr !== null) {
125-
return $this->filterByTruthyValue($scope, $callbackArg->params[0]->var, $arrayArgType, $callbackArg->params[1]->var ?? null, $statement->expr);
126-
}
127-
} elseif ($callbackArg instanceof ArrowFunction && count($callbackArg->params) > 0) {
128-
return $this->filterByTruthyValue($scope, $callbackArg->params[0]->var, $arrayArgType, $callbackArg->params[1]->var ?? null, $callbackArg->expr);
129-
} elseif ($callbackArg instanceof String_) {
130-
$funcName = self::createFunctionName($callbackArg->value);
131-
if ($funcName === null) {
132-
return new ErrorType();
144+
$expr = new FuncCall($funcName, $args);
145+
$results[] = $this->filterByTruthyValue($scope, $itemVar, $arrayArgType, $keyVar, $expr);
133146
}
134-
135-
$itemVar = new Variable('item');
136-
$keyVar = new Variable('key');
137-
$expr = new FuncCall($funcName, [new Arg($itemVar), new Arg($keyVar)]);
138-
return $this->filterByTruthyValue($scope, $itemVar, $arrayArgType, $keyVar, $expr);
147+
return TypeCombinator::union(...$results);
139148
}
140149
}
141150

@@ -280,4 +289,63 @@ private static function createFunctionName(string $funcName): ?Name
280289
return new Name($funcName);
281290
}
282291

292+
/**
293+
* @param self::USE_* $mode
294+
* @return array{list<Arg>, ?Variable, ?Variable}
295+
*/
296+
private function createDummyArgs(int $mode): array
297+
{
298+
if ($mode === self::USE_ITEM) {
299+
$itemVar = new Variable('item');
300+
$keyVar = null;
301+
$args = [new Arg($itemVar)];
302+
} elseif ($mode === self::USE_KEY) {
303+
$itemVar = null;
304+
$keyVar = new Variable('key');
305+
$args = [new Arg($keyVar)];
306+
} elseif ($mode === self::USE_BOTH) {
307+
$itemVar = new Variable('item');
308+
$keyVar = new Variable('key');
309+
$args = [new Arg($itemVar), new Arg($keyVar)];
310+
}
311+
return [$args, $itemVar, $keyVar];
312+
}
313+
314+
/**
315+
* @param non-empty-string $constantName
316+
*/
317+
private function getConstant(string $constantName): int
318+
{
319+
$constant = $this->reflectionProvider->getConstant(new Name($constantName), null);
320+
$valueType = $constant->getValueType();
321+
if (!$valueType instanceof ConstantIntegerType) {
322+
throw new ShouldNotHappenException(sprintf('Constant %s does not have integer type.', $constantName));
323+
}
324+
325+
return $valueType->getValue();
326+
}
327+
328+
/**
329+
* @return self::USE_*|null
330+
*/
331+
private function determineMode(?Expr $flagArg, Scope $scope): ?int
332+
{
333+
if ($flagArg === null) {
334+
return self::USE_ITEM;
335+
}
336+
337+
$flagValues = $scope->getType($flagArg)->getConstantScalarValues();
338+
if (count($flagValues) !== 1) {
339+
return null;
340+
}
341+
342+
if ($flagValues[0] === $this->getConstant('ARRAY_FILTER_USE_KEY')) {
343+
return self::USE_KEY;
344+
} elseif ($flagValues[0] === $this->getConstant('ARRAY_FILTER_USE_BOTH')) {
345+
return self::USE_BOTH;
346+
}
347+
348+
return null;
349+
}
350+
283351
}

tests/PHPStan/Analyser/nsrt/array-filter-string-callables.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,14 @@ public static function isString($value): bool
7777
return is_string($value);
7878
}
7979
}
80+
81+
function unionOfCallableStrings(): void
82+
{
83+
$func = rand(0, 1) === 1 ? 'is_string' : 'is_int';
84+
$list = [
85+
1,
86+
2,
87+
'foo',
88+
];
89+
assertType("array{1, 2}|array{2: 'foo'}", array_filter($list, $func));
90+
}

tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,4 +1044,9 @@ public function testBug3759(): void
10441044
$this->analyse([__DIR__ . '/data/bug-3759.php'], []);
10451045
}
10461046

1047+
public function testBug11337(): void
1048+
{
1049+
$this->analyse([__DIR__ . '/data/bug-11337.php'], []);
1050+
}
1051+
10471052
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php // lint >= 8.1
2+
declare(strict_types = 1);
3+
4+
namespace Bug11337;
5+
6+
use function array_filter;
7+
8+
class Foo
9+
{
10+
11+
/**
12+
* @return array<\stdClass>
13+
*/
14+
public function testFunction(): array
15+
{
16+
$objects = [
17+
new \stdClass(),
18+
null,
19+
new \stdClass(),
20+
null,
21+
];
22+
23+
return array_filter($objects, is_object(...));
24+
}
25+
26+
/**
27+
* @return array<1|2>
28+
*/
29+
public function testMethod(): array
30+
{
31+
$objects = [
32+
1,
33+
2,
34+
-4,
35+
0,
36+
-1,
37+
];
38+
39+
return array_filter($objects, $this->isPositive(...));
40+
}
41+
42+
/**
43+
* @return array<'foo'|'bar'>
44+
*/
45+
public function testStaticMethod(): array
46+
{
47+
$objects = [
48+
'',
49+
'foo',
50+
'',
51+
'bar',
52+
];
53+
54+
return array_filter($objects, self::isNonEmptyString(...));
55+
}
56+
57+
/**
58+
* @phpstan-assert-if-true int<1, max> $n
59+
*/
60+
private function isPositive(int $n): bool
61+
{
62+
return $n > 0;
63+
}
64+
65+
/**
66+
* @phpstan-assert-if-true non-empty-string $str
67+
*/
68+
private static function isNonEmptyString(string $str): bool
69+
{
70+
return \strlen($str) > 0;
71+
}
72+
}

0 commit comments

Comments
 (0)