Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ parameters:
count: 1
path: src/Analyser/AnalyserResultFinalizer.php

-
rawMessage: PHPDoc tag @var with type int|string is not subtype of type string.
identifier: varTag.type
count: 1
path: src/Analyser/ArgumentsNormalizer.php

-
rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.'
identifier: phpstanApi.instanceofType
Expand Down
102 changes: 102 additions & 0 deletions src/Analyser/ArgumentsNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
namespace PHPStan\Analyser;

use PhpParser\Node\Arg;
use PhpParser\Node\Expr\Array_;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\New_;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Identifier;
use PhpParser\Node\Scalar\Int_;
use PhpParser\Node\Scalar\String_;
use PHPStan\Node\Expr\TypeExpr;
use PHPStan\Reflection\ParametersAcceptor;
use PHPStan\Reflection\ParametersAcceptorSelector;
Expand All @@ -18,6 +22,8 @@
use function array_keys;
use function array_values;
use function count;
use function is_string;
use function key;
use function ksort;
use function max;
use function sprintf;
Expand Down Expand Up @@ -89,6 +95,102 @@
), $acceptsNamedArguments];
}

/**
* @return array{ParametersAcceptor, FuncCall, TrinaryLogic}|null
*/
public static function reorderCallUserFuncArrayArguments(
FuncCall $callUserFuncArrayCall,
Scope $scope,
): ?array
{
$args = $callUserFuncArrayCall->getArgs();
if (count($args) < 2) {
return null;
}

$callbackArg = null;
$argsArrayArg = null;
foreach ($args as $i => $arg) {
if ($callbackArg === null) {
if ($arg->name === null && $i === 0) {
$callbackArg = $arg;
continue;
}
if ($arg->name !== null && $arg->name->toString() === 'callback') {
$callbackArg = $arg;
continue;
}
}

if ($argsArrayArg !== null) {
continue;
}
if ($arg->name === null && $i === 1) {
$argsArrayArg = $arg;
continue;
}
if ($arg->name === null || $arg->name->toString() !== 'args') {
continue;
}
$argsArrayArg = $arg;
}

if ($callbackArg === null || $argsArrayArg === null) {
return null;
}

if (!$argsArrayArg->value instanceof Array_) {
return null;
}

$passThruArgs = [];
foreach ($argsArrayArg->value->items as $item) {
$key = null;
if ($item->key instanceof String_) {
/** @var int|string $key */
$key = key([$item->key->value => null]);
if ($key === '') {
return null;
}
} elseif ($item->key !== null && !$item->key instanceof Int_) {
// Dynamic key, we cannot be sure.
return null;
}

$passThruArgs[] = new Arg(
$item->value,
$item->byRef,
$item->unpack,
$item->getAttributes(),
is_string($key) ? new Identifier($key) : null,
);
}

$calledOnType = $scope->getType($callbackArg->value);
if (!$calledOnType->isCallable()->yes()) {

Check warning on line 170 in src/Analyser/ArgumentsNormalizer.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } $calledOnType = $scope->getType($callbackArg->value); - if (!$calledOnType->isCallable()->yes()) { + if ($calledOnType->isCallable()->no()) { return null; }

Check warning on line 170 in src/Analyser/ArgumentsNormalizer.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } $calledOnType = $scope->getType($callbackArg->value); - if (!$calledOnType->isCallable()->yes()) { + if ($calledOnType->isCallable()->no()) { return null; }
return null;
}

$callableParametersAcceptors = $calledOnType->getCallableParametersAcceptors($scope);
$parametersAcceptor = ParametersAcceptorSelector::selectFromArgs(
$scope,
$passThruArgs,
$callableParametersAcceptors,
null,
);

$acceptsNamedArguments = TrinaryLogic::createYes();
foreach ($callableParametersAcceptors as $callableParametersAcceptor) {
$acceptsNamedArguments = $acceptsNamedArguments->and($callableParametersAcceptor->acceptsNamedArguments());
}

return [$parametersAcceptor, new FuncCall(
$callbackArg->value,
$passThruArgs,
$callUserFuncArrayCall->getAttributes(),
), $acceptsNamedArguments];
}

public static function reorderFuncArguments(
ParametersAcceptor $parametersAcceptor,
FuncCall $functionCall,
Expand Down
9 changes: 9 additions & 0 deletions src/Analyser/ExprHandler/FuncCallHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,15 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type
}
}

if ($functionReflection->getName() === 'call_user_func_array') {
$result = ArgumentsNormalizer::reorderCallUserFuncArrayArguments($expr, $scope);
if ($result !== null) {
[, $innerFuncCall] = $result;

return $scope->getType($innerFuncCall);
}
}

$parametersAcceptor = ParametersAcceptorSelector::selectFromArgs(
$scope,
$expr->getArgs(),
Expand Down
22 changes: 15 additions & 7 deletions src/Rules/Functions/CallUserFuncRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use PHPStan\Rules\FunctionCallParametersCheck;
use PHPStan\Rules\Rule;
use function count;
use function sprintf;
use function ucfirst;

/**
Expand Down Expand Up @@ -47,20 +48,27 @@ public function processNode(Node $node, Scope $scope): array
}

$functionReflection = $this->reflectionProvider->getFunction($node->name, $scope);
if ($functionReflection->getName() !== 'call_user_func') {

$functionName = $functionReflection->getName();
if ($functionName === 'call_user_func') {
$result = ArgumentsNormalizer::reorderCallUserFuncArguments(
$node,
$scope,
);
} elseif ($functionName === 'call_user_func_array') {
$result = ArgumentsNormalizer::reorderCallUserFuncArrayArguments(
$node,
$scope,
);
} else {
return [];
}

$result = ArgumentsNormalizer::reorderCallUserFuncArguments(
$node,
$scope,
);
if ($result === null) {
return [];
}
[$parametersAcceptor, $funcCall, $acceptsNamedArguments] = $result;

$callableDescription = 'callable passed to call_user_func()';
$callableDescription = sprintf('callable passed to %s()', $functionName);

return $this->check->check(
$parametersAcceptor,
Expand Down
45 changes: 45 additions & 0 deletions tests/PHPStan/Analyser/nsrt/call-user-func-array-php8.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php // lint >= 8.0

namespace CallUserFuncArrayPhp8;

use function PHPStan\Testing\assertType;

/**
* @template T
* @param T $t
* @return T
*/
function generic($t) {
return $t;
}

/**
* @template T
* @param T $t
* @return T
*/
function generic3($t = '', int $b = 100, string $c = '') {
return $t;
}


function fun3($a = '', $b = '', $c = ''): int {
return 1;
}

class Foo {
function doNamed() {
assertType('1', call_user_func_array('CallUserFuncPhp8\generic', ['t' => 1]));
assertType('array{1, 2, 3}', call_user_func_array('CallUserFuncPhp8\generic', ['t' => [1, 2, 3]]));

assertType('array{1, 2, 3}', call_user_func_array('CallUserFuncPhp8\generic3', ['t' => [1, 2, 3]]));
assertType('\'\'', call_user_func_array('CallUserFuncPhp8\generic3', ['b' => 150]));
assertType('\'\'', call_user_func_array('CallUserFuncPhp8\generic3', ['c' => 'lala']));
assertType('\'\'', call_user_func_array(args: ['c' => 'lala'], callback: 'CallUserFuncPhp8\generic3'));

assertType('int', call_user_func_array('CallUserFuncPhp8\fun3', ['a' => [1, 2, 3]]));
assertType('int', call_user_func_array('CallUserFuncPhp8\fun3', ['b' => [1, 2, 3]]));
assertType('int', call_user_func_array('CallUserFuncPhp8\fun3', ['c' => [1, 2, 3]]));
assertType('int', call_user_func_array('CallUserFuncPhp8\fun3', ['a' => [1, 2, 3], 'c' => 'c']));
}
}
55 changes: 55 additions & 0 deletions tests/PHPStan/Analyser/nsrt/call-user-func_array.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace CallUserFuncArray;

use function PHPStan\Testing\assertType;

/**
* @template T
* @param T $t
* @return T
*/
function generic($t) {
return $t;
}

function fun(): int
{
return 3;
}

function fun3($i, $x, $y): int
{
return 3;
}

class c {
static function m(): string
{
return 'hello';
}
}

class Foo {
function proxy() {
$params = [
'CallUserFunc\generic',
[123, 456],
];

assertType('mixed', call_user_func_array(...$params));
}

/**
* @param string[] $strings
*/
function doFunc($strings) {
assertType('true', call_user_func_array('CallUserFunc\generic', [true]));
assertType('\'hello\'', call_user_func_array('CallUserFunc\generic', ['hello']));
assertType('array<string>', call_user_func_array('CallUserFunc\generic', [$strings]));

assertType('int', call_user_func_array('CallUserFunc\fun', []));
assertType('int', call_user_func_array('CallUserFunc\fun3', [1 ,2 ,3]));
assertType('string', call_user_func_array(['CallUserFunc\c', 'm'], []));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1128,7 +1128,7 @@ public function testCallUserFuncArray(): void
],
];
}
$this->analyse([__DIR__ . '/data/call-user-func-array.php'], $errors);
$this->analyse([__DIR__ . '/data/call-user-func-array-named-args.php'], $errors);
}

public function testFirstClassCallables(): void
Expand Down
59 changes: 59 additions & 0 deletions tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,65 @@ public function testRule(): void
]);
}

#[RequiresPhp('>= 8.0')]
public function testRuleCallUserFuncArray(): void
{
$this->analyse([__DIR__ . '/data/call-user-func-array.php'], [
[
'Callable passed to call_user_func_array() invoked with 0 parameters, 1 required.',
15,
],
[
'Parameter #1 $i of callable passed to call_user_func_array() expects int, string given.',
17,
],
[
'Parameter $i of callable passed to call_user_func_array() expects int, string given.',
18,
],
[
'Parameter $i of callable passed to call_user_func_array() expects int, string given.',
19,
],
[
'Unknown parameter $j in call to callable passed to call_user_func_array().',
22,
],
[
'Missing parameter $i (int) in call to callable passed to call_user_func_array().',
22,
],
[
'Callable passed to call_user_func_array() invoked with 0 parameters, 2-4 required.',
30,
],
[
'Callable passed to call_user_func_array() invoked with 1 parameter, 2-4 required.',
31,
],
[
'Callable passed to call_user_func_array() invoked with 0 parameters, at least 2 required.',
40,
],
[
'Callable passed to call_user_func_array() invoked with 1 parameter, at least 2 required.',
41,
],
[
'Result of callable passed to call_user_func_array() (void) is used.',
43,
],
[
'Parameter #1 $i of callable passed to call_user_func_array() expects int|null, string given.',
52,
],
[
'Parameter #1 $i of callable passed to call_user_func_array() expects int|null, string given.',
53,
],
]);
}

public function testBug7057(): void
{
$this->analyse([__DIR__ . '/data/bug-7057.php'], []);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<?php

call_user_func_array('array_merge', ['foo' => ['bar' => 2]]);
Loading
Loading