Skip to content

Commit e3867c0

Browse files
authored
Bleeding edge - check that values passed to array_sum/product are castable to number
1 parent 993db81 commit e3867c0

10 files changed

+343
-0
lines changed

conf/bleedingEdge.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
parameters:
22
featureToggles:
33
bleedingEdge: true
4+
checkParameterCastableToNumberFunctions: true
45
skipCheckGenericClasses!: []
56
stricterFunctionMap: true

conf/config.level5.neon

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ parameters:
55
checkFunctionArgumentTypes: true
66
checkArgumentsPassedByReference: true
77

8+
conditionalTags:
9+
PHPStan\Rules\Functions\ParameterCastableToNumberRule:
10+
phpstan.rules.rule: %featureToggles.checkParameterCastableToNumberFunctions%
11+
812
rules:
913
- PHPStan\Rules\DateTimeInstantiationRule
1014
- PHPStan\Rules\Functions\CallUserFuncRule
@@ -36,3 +40,5 @@ services:
3640
treatPhpDocTypesAsCertainTip: %tips.treatPhpDocTypesAsCertain%
3741
tags:
3842
- phpstan.rules.rule
43+
-
44+
class: PHPStan\Rules\Functions\ParameterCastableToNumberRule

conf/config.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ parameters:
2222
tooWideThrowType: true
2323
featureToggles:
2424
bleedingEdge: false
25+
checkParameterCastableToNumberFunctions: false
2526
skipCheckGenericClasses: []
2627
stricterFunctionMap: false
2728
fileExtensions:

conf/parametersSchema.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ parametersSchema:
2828
])
2929
featureToggles: structure([
3030
bleedingEdge: bool(),
31+
checkParameterCastableToNumberFunctions: bool(),
3132
skipCheckGenericClasses: listOf(string()),
3233
stricterFunctionMap: bool()
3334
])
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Functions;
4+
5+
use PhpParser\Node;
6+
use PhpParser\Node\Expr\FuncCall;
7+
use PHPStan\Analyser\Scope;
8+
use PHPStan\Reflection\ParametersAcceptorSelector;
9+
use PHPStan\Reflection\ReflectionProvider;
10+
use PHPStan\Rules\ParameterCastableToStringCheck;
11+
use PHPStan\Rules\Rule;
12+
use PHPStan\Type\Type;
13+
use function count;
14+
use function in_array;
15+
16+
/**
17+
* @implements Rule<Node\Expr\FuncCall>
18+
*/
19+
final class ParameterCastableToNumberRule implements Rule
20+
{
21+
22+
public function __construct(
23+
private ReflectionProvider $reflectionProvider,
24+
private ParameterCastableToStringCheck $parameterCastableToStringCheck,
25+
)
26+
{
27+
}
28+
29+
public function getNodeType(): string
30+
{
31+
return FuncCall::class;
32+
}
33+
34+
public function processNode(Node $node, Scope $scope): array
35+
{
36+
if (!($node->name instanceof Node\Name)) {
37+
return [];
38+
}
39+
40+
if (!$this->reflectionProvider->hasFunction($node->name, $scope)) {
41+
return [];
42+
}
43+
44+
$functionReflection = $this->reflectionProvider->getFunction($node->name, $scope);
45+
$functionName = $functionReflection->getName();
46+
47+
if (!in_array($functionName, ['array_sum', 'array_product'], true)) {
48+
return [];
49+
}
50+
51+
$origArgs = $node->getArgs();
52+
53+
if (count($origArgs) !== 1) {
54+
return [];
55+
}
56+
57+
$parametersAcceptor = ParametersAcceptorSelector::selectFromArgs(
58+
$scope,
59+
$origArgs,
60+
$functionReflection->getVariants(),
61+
$functionReflection->getNamedArgumentsVariants(),
62+
);
63+
64+
$errorMessage = 'Parameter %s of function %s expects an array of values castable to number, %s given.';
65+
$functionParameters = $parametersAcceptor->getParameters();
66+
$error = $this->parameterCastableToStringCheck->checkParameter(
67+
$origArgs[0],
68+
$scope,
69+
$errorMessage,
70+
static fn (Type $t) => $t->toNumber(),
71+
$functionName,
72+
$this->parameterCastableToStringCheck->getParameterName(
73+
$origArgs[0],
74+
0,
75+
$functionParameters[0] ?? null,
76+
),
77+
);
78+
79+
return $error !== null
80+
? [$error]
81+
: [];
82+
}
83+
84+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Functions;
4+
5+
use PHPStan\Rules\ParameterCastableToStringCheck;
6+
use PHPStan\Rules\Rule;
7+
use PHPStan\Rules\RuleLevelHelper;
8+
use PHPStan\Testing\RuleTestCase;
9+
use function array_map;
10+
use function str_replace;
11+
use const PHP_VERSION_ID;
12+
13+
/**
14+
* @extends RuleTestCase<ParameterCastableToNumberRule>
15+
*/
16+
class ParameterCastableToNumberRuleTest extends RuleTestCase
17+
{
18+
19+
protected function getRule(): Rule
20+
{
21+
$broker = $this->createReflectionProvider();
22+
return new ParameterCastableToNumberRule($broker, new ParameterCastableToStringCheck(new RuleLevelHelper($broker, true, false, true, false, false, false)));
23+
}
24+
25+
public function testRule(): void
26+
{
27+
$this->analyse([__DIR__ . '/data/param-castable-to-number-functions.php'], $this->hackPhp74ErrorMessages([
28+
[
29+
'Parameter #1 $array of function array_sum expects an array of values castable to number, array<int, array<int, int>> given.',
30+
20,
31+
],
32+
[
33+
'Parameter #1 $array of function array_sum expects an array of values castable to number, array<int, stdClass> given.',
34+
21,
35+
],
36+
[
37+
'Parameter #1 $array of function array_sum expects an array of values castable to number, array<int, string> given.',
38+
22,
39+
],
40+
[
41+
'Parameter #1 $array of function array_sum expects an array of values castable to number, array<int, resource|false> given.',
42+
23,
43+
],
44+
[
45+
'Parameter #1 $array of function array_sum expects an array of values castable to number, array<int, CurlHandle> given.',
46+
24,
47+
],
48+
[
49+
'Parameter #1 $array of function array_sum expects an array of values castable to number, array<int, ParamCastableToNumberFunctions\\ClassWithToString> given.',
50+
25,
51+
],
52+
[
53+
'Parameter #1 $array of function array_product expects an array of values castable to number, array<int, array<int, int>> given.',
54+
27,
55+
],
56+
[
57+
'Parameter #1 $array of function array_product expects an array of values castable to number, array<int, stdClass> given.',
58+
28,
59+
],
60+
[
61+
'Parameter #1 $array of function array_product expects an array of values castable to number, array<int, string> given.',
62+
29,
63+
],
64+
[
65+
'Parameter #1 $array of function array_product expects an array of values castable to number, array<int, resource|false> given.',
66+
30,
67+
],
68+
[
69+
'Parameter #1 $array of function array_product expects an array of values castable to number, array<int, CurlHandle> given.',
70+
31,
71+
],
72+
[
73+
'Parameter #1 $array of function array_product expects an array of values castable to number, array<int, ParamCastableToNumberFunctions\\ClassWithToString> given.',
74+
32,
75+
],
76+
]));
77+
}
78+
79+
public function testNamedArguments(): void
80+
{
81+
if (PHP_VERSION_ID < 80000) {
82+
$this->markTestSkipped('Test requires PHP 8.0.');
83+
}
84+
85+
$this->analyse([__DIR__ . '/data/param-castable-to-number-functions-named-args.php'], [
86+
[
87+
'Parameter $array of function array_sum expects an array of values castable to number, array<int, array<int, int>> given.',
88+
7,
89+
],
90+
[
91+
'Parameter $array of function array_product expects an array of values castable to number, array<int, array<int, int>> given.',
92+
8,
93+
],
94+
]);
95+
}
96+
97+
public function testEnum(): void
98+
{
99+
if (PHP_VERSION_ID < 80100) {
100+
$this->markTestSkipped('Test requires PHP 8.1.');
101+
}
102+
103+
$this->analyse([__DIR__ . '/data/param-castable-to-number-functions-enum.php'], [
104+
[
105+
'Parameter #1 $array of function array_sum expects an array of values castable to number, array<int, ParamCastableToNumberFunctionsEnum\\FooEnum::A> given.',
106+
12,
107+
],
108+
[
109+
'Parameter #1 $array of function array_product expects an array of values castable to number, array<int, ParamCastableToNumberFunctionsEnum\\FooEnum::A> given.',
110+
13,
111+
],
112+
]);
113+
}
114+
115+
public function testBug11883(): void
116+
{
117+
if (PHP_VERSION_ID < 80100) {
118+
$this->markTestSkipped('Test requires PHP 8.1.');
119+
}
120+
121+
$this->analyse([__DIR__ . '/data/bug-11883.php'], [
122+
[
123+
'Parameter #1 $array of function array_sum expects an array of values castable to number, array<int, Bug11883\\SomeEnum::A|Bug11883\\SomeEnum::B> given.',
124+
13,
125+
],
126+
[
127+
'Parameter #1 $array of function array_product expects an array of values castable to number, array<int, Bug11883\\SomeEnum::A|Bug11883\\SomeEnum::B> given.',
128+
14,
129+
],
130+
]);
131+
}
132+
133+
/**
134+
* @param list<array{0: string, 1: int, 2?: string|null}> $errors
135+
* @return list<array{0: string, 1: int, 2?: string|null}>
136+
*/
137+
private function hackPhp74ErrorMessages(array $errors): array
138+
{
139+
if (PHP_VERSION_ID >= 80000) {
140+
return $errors;
141+
}
142+
143+
return array_map(static function (array $error): array {
144+
$error[0] = str_replace(
145+
[
146+
'$array of function array_sum',
147+
'$array of function array_product',
148+
'array<int, CurlHandle>',
149+
],
150+
[
151+
'$input of function array_sum',
152+
'$input of function array_product',
153+
'array<int, resource>',
154+
],
155+
$error[0],
156+
);
157+
158+
return $error;
159+
}, $errors);
160+
}
161+
162+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php declare(strict_types = 1); // lint >= 8.1
2+
3+
namespace Bug11883;
4+
5+
enum SomeEnum: int
6+
{
7+
case A = 1;
8+
case B = 2;
9+
}
10+
11+
$enums1 = [SomeEnum::A, SomeEnum::B];
12+
13+
var_dump(array_sum($enums1));
14+
var_dump(array_product($enums1));
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php declare(strict_types = 1); // lint >= 8.1
2+
3+
namespace ParamCastableToNumberFunctionsEnum;
4+
5+
enum FooEnum
6+
{
7+
case A;
8+
}
9+
10+
function invalidUsages()
11+
{
12+
array_sum([FooEnum::A]);
13+
array_product([FooEnum::A]);
14+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php declare(strict_types = 1); // lint >= 8.0
2+
3+
namespace ParamCastableToNumberFunctionsNamedArgs;
4+
5+
function invalidUsages()
6+
{
7+
var_dump(array_sum(array: [[0]]));
8+
var_dump(array_product(array: [[0]]));
9+
}
10+
11+
function validUsages()
12+
{
13+
var_dump(array_sum(array: [1]));
14+
var_dump(array_product(array: [1]));
15+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ParamCastableToNumberFunctions;
4+
5+
class ClassWithoutToString {}
6+
class ClassWithToString
7+
{
8+
public function __toString(): string
9+
{
10+
return 'foo';
11+
}
12+
}
13+
14+
function invalidUsages(): void
15+
{
16+
$curlHandle = curl_init();
17+
// curl_init returns benevolent union and false is castable to number.
18+
assert($curlHandle !== false);
19+
20+
var_dump(array_sum([[0]]));
21+
var_dump(array_sum([new \stdClass()]));
22+
var_dump(array_sum(['ttt']));
23+
var_dump(array_sum([fopen('php://input', 'r')]));
24+
var_dump(array_sum([$curlHandle]));
25+
var_dump(array_sum([new ClassWithToString()]));
26+
27+
var_dump(array_product([[0]]));
28+
var_dump(array_product([new \stdClass()]));
29+
var_dump(array_product(['ttt']));
30+
var_dump(array_product([fopen('php://input', 'r')]));
31+
var_dump(array_product([$curlHandle]));
32+
var_dump(array_product([new ClassWithToString()]));
33+
}
34+
35+
function wrongNumberOfArguments(): void
36+
{
37+
array_sum();
38+
array_product();
39+
}
40+
41+
function validUsages(): void
42+
{
43+
var_dump(array_sum(['5.5', false, true, new \SimpleXMLElement('<a>7.7</a>'), 5, 5.5, null]));
44+
var_dump(array_product(['5.5', false, true, new \SimpleXMLElement('<a>7.7</a>'), 5, 5.5, null]));
45+
}

0 commit comments

Comments
 (0)