Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions conf/config.level5.neon
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ parameters:
checkFunctionArgumentTypes: true
checkArgumentsPassedByReference: true

conditionalTags:
PHPStan\Rules\Functions\ParameterCastableToNumberRule:
phpstan.rules.rule: %featureToggles.bleedingEdge%

rules:
- PHPStan\Rules\DateTimeInstantiationRule
- PHPStan\Rules\Functions\CallUserFuncRule
Expand Down Expand Up @@ -36,3 +40,5 @@ services:
treatPhpDocTypesAsCertainTip: %tips.treatPhpDocTypesAsCertain%
tags:
- phpstan.rules.rule
-
class: PHPStan\Rules\Functions\ParameterCastableToNumberRule
84 changes: 84 additions & 0 deletions src/Rules/Functions/ParameterCastableToNumberRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Functions;

use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\ParameterCastableToStringCheck;
use PHPStan\Rules\Rule;
use PHPStan\Type\Type;
use function count;
use function in_array;

/**
* @implements Rule<Node\Expr\FuncCall>
*/
final class ParameterCastableToNumberRule implements Rule
{

public function __construct(
private ReflectionProvider $reflectionProvider,
private ParameterCastableToStringCheck $parameterCastableToStringCheck,
)
{
}

public function getNodeType(): string
{
return FuncCall::class;
}

public function processNode(Node $node, Scope $scope): array
{
if (!($node->name instanceof Node\Name)) {
return [];
}

if (!$this->reflectionProvider->hasFunction($node->name, $scope)) {
return [];
}

$functionReflection = $this->reflectionProvider->getFunction($node->name, $scope);
$functionName = $functionReflection->getName();

if (!in_array($functionName, ['array_sum', 'array_product'], true)) {
return [];
}

$origArgs = $node->getArgs();

if (count($origArgs) !== 1) {
return [];
}

$parametersAcceptor = ParametersAcceptorSelector::selectFromArgs(
$scope,
$origArgs,
$functionReflection->getVariants(),
$functionReflection->getNamedArgumentsVariants(),
);

$errorMessage = 'Parameter %s of function %s expects an array of values castable to number, %s given.';
$functionParameters = $parametersAcceptor->getParameters();
$error = $this->parameterCastableToStringCheck->checkParameter(
$origArgs[0],
$scope,
$errorMessage,
static fn (Type $t) => $t->toNumber(),
$functionName,
$this->parameterCastableToStringCheck->getParameterName(
$origArgs[0],
0,
$functionParameters[0] ?? null,
),
);

return $error !== null
? [$error]
: [];
}

}
162 changes: 162 additions & 0 deletions tests/PHPStan/Rules/Functions/ParameterCastableToNumberRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Functions;

use PHPStan\Rules\ParameterCastableToStringCheck;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleLevelHelper;
use PHPStan\Testing\RuleTestCase;
use function array_map;
use function str_replace;
use const PHP_VERSION_ID;

/**
* @extends RuleTestCase<ParameterCastableToNumberRule>
*/
class ParameterCastableToNumberRuleTest extends RuleTestCase
{

protected function getRule(): Rule
{
$broker = $this->createReflectionProvider();
return new ParameterCastableToNumberRule($broker, new ParameterCastableToStringCheck(new RuleLevelHelper($broker, true, false, true, false, false, false)));
}

public function testRule(): void
{
$this->analyse([__DIR__ . '/data/param-castable-to-number-functions.php'], $this->hackPhp74ErrorMessages([
[
'Parameter #1 $array of function array_sum expects an array of values castable to number, array<int, array<int, int>> given.',
20,
],
[
'Parameter #1 $array of function array_sum expects an array of values castable to number, array<int, stdClass> given.',
21,
],
[
'Parameter #1 $array of function array_sum expects an array of values castable to number, array<int, string> given.',
22,
],
[
'Parameter #1 $array of function array_sum expects an array of values castable to number, array<int, resource|false> given.',
23,
],
[
'Parameter #1 $array of function array_sum expects an array of values castable to number, array<int, CurlHandle> given.',
24,
],
[
'Parameter #1 $array of function array_sum expects an array of values castable to number, array<int, ParamCastableToNumberFunctions\\ClassWithToString> given.',
25,
],
[
'Parameter #1 $array of function array_product expects an array of values castable to number, array<int, array<int, int>> given.',
27,
],
[
'Parameter #1 $array of function array_product expects an array of values castable to number, array<int, stdClass> given.',
28,
],
[
'Parameter #1 $array of function array_product expects an array of values castable to number, array<int, string> given.',
29,
],
[
'Parameter #1 $array of function array_product expects an array of values castable to number, array<int, resource|false> given.',
30,
],
[
'Parameter #1 $array of function array_product expects an array of values castable to number, array<int, CurlHandle> given.',
31,
],
[
'Parameter #1 $array of function array_product expects an array of values castable to number, array<int, ParamCastableToNumberFunctions\\ClassWithToString> given.',
32,
],
]));
}

public function testNamedArguments(): void
{
if (PHP_VERSION_ID < 80000) {
$this->markTestSkipped('Test requires PHP 8.0.');
}

$this->analyse([__DIR__ . '/data/param-castable-to-number-functions-named-args.php'], [
[
'Parameter $array of function array_sum expects an array of values castable to number, array<int, array<int, int>> given.',
7,
],
[
'Parameter $array of function array_product expects an array of values castable to number, array<int, array<int, int>> given.',
8,
],
]);
}

public function testEnum(): void
{
if (PHP_VERSION_ID < 80100) {
$this->markTestSkipped('Test requires PHP 8.1.');
}

$this->analyse([__DIR__ . '/data/param-castable-to-number-functions-enum.php'], [
[
'Parameter #1 $array of function array_sum expects an array of values castable to number, array<int, ParamCastableToNumberFunctionsEnum\\FooEnum::A> given.',
12,
],
[
'Parameter #1 $array of function array_product expects an array of values castable to number, array<int, ParamCastableToNumberFunctionsEnum\\FooEnum::A> given.',
13,
],
]);
}

public function testBug11883(): void
{
if (PHP_VERSION_ID < 80100) {
$this->markTestSkipped('Test requires PHP 8.1.');
}

$this->analyse([__DIR__ . '/data/bug-11883.php'], [
[
'Parameter #1 $array of function array_sum expects an array of values castable to number, array<int, Bug11883\\SomeEnum::A|Bug11883\\SomeEnum::B> given.',
13,
],
[
'Parameter #1 $array of function array_product expects an array of values castable to number, array<int, Bug11883\\SomeEnum::A|Bug11883\\SomeEnum::B> given.',
14,
],
]);
}

/**
* @param list<array{0: string, 1: int, 2?: string|null}> $errors
* @return list<array{0: string, 1: int, 2?: string|null}>
*/
private function hackPhp74ErrorMessages(array $errors): array
{
if (PHP_VERSION_ID >= 80000) {
return $errors;
}

return array_map(static function (array $error): array {
$error[0] = str_replace(
[
'$array of function array_sum',
'$array of function array_product',
'array<int, CurlHandle>',
],
[
'$input of function array_sum',
'$input of function array_product',
'array<int, resource>',
],
$error[0],
);

return $error;
}, $errors);
}

}
14 changes: 14 additions & 0 deletions tests/PHPStan/Rules/Functions/data/bug-11883.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php declare(strict_types = 1); // lint >= 8.1

namespace Bug11883;

enum SomeEnum: int
{
case A = 1;
case B = 2;
}

$enums1 = [SomeEnum::A, SomeEnum::B];

var_dump(array_sum($enums1));
var_dump(array_product($enums1));
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php declare(strict_types = 1); // lint >= 8.1

namespace ParamCastableToNumberFunctionsEnum;

enum FooEnum
{
case A;
}

function invalidUsages()
{
array_sum([FooEnum::A]);
array_product([FooEnum::A]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php declare(strict_types = 1); // lint >= 8.0

namespace ParamCastableToNumberFunctionsNamedArgs;

function invalidUsages()
{
var_dump(array_sum(array: [[0]]));
var_dump(array_product(array: [[0]]));
}

function validUsages()
{
var_dump(array_sum(array: [1]));
var_dump(array_product(array: [1]));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php declare(strict_types = 1);

namespace ParamCastableToNumberFunctions;

class ClassWithoutToString {}
class ClassWithToString
{
public function __toString(): string
{
return 'foo';
}
}

function invalidUsages(): void
{
$curlHandle = curl_init();
// curl_init returns benevolent union and false is castable to number.
assert($curlHandle !== false);

var_dump(array_sum([[0]]));
var_dump(array_sum([new \stdClass()]));
var_dump(array_sum(['ttt']));
var_dump(array_sum([fopen('php://input', 'r')]));
var_dump(array_sum([$curlHandle]));
var_dump(array_sum([new ClassWithToString()]));

var_dump(array_product([[0]]));
var_dump(array_product([new \stdClass()]));
var_dump(array_product(['ttt']));
var_dump(array_product([fopen('php://input', 'r')]));
var_dump(array_product([$curlHandle]));
var_dump(array_product([new ClassWithToString()]));
}

function wrongNumberOfArguments(): void
{
array_sum();
array_product();
}

function validUsages(): void
{
var_dump(array_sum(['5.5', false, true, new \SimpleXMLElement('<a>7.7</a>'), 5, 5.5, null]));
var_dump(array_product(['5.5', false, true, new \SimpleXMLElement('<a>7.7</a>'), 5, 5.5, null]));
}
Loading