Skip to content

Commit 706141f

Browse files
Support call_user_func_array
1 parent 6895e54 commit 706141f

9 files changed

Lines changed: 339 additions & 10 deletions

File tree

src/Analyser/ArgumentsNormalizer.php

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33
namespace PHPStan\Analyser;
44

55
use PhpParser\Node\Arg;
6+
use PhpParser\Node\Expr\Array_;
67
use PhpParser\Node\Expr\FuncCall;
78
use PhpParser\Node\Expr\MethodCall;
89
use PhpParser\Node\Expr\New_;
910
use PhpParser\Node\Expr\StaticCall;
11+
use PhpParser\Node\Identifier;
12+
use PhpParser\Node\Scalar\Int_;
13+
use PhpParser\Node\Scalar\String_;
1014
use PHPStan\Node\Expr\TypeExpr;
1115
use PHPStan\Reflection\ParametersAcceptor;
1216
use PHPStan\Reflection\ParametersAcceptorSelector;
@@ -89,6 +93,98 @@ public static function reorderCallUserFuncArguments(
8993
), $acceptsNamedArguments];
9094
}
9195

96+
/**
97+
* @return array{ParametersAcceptor, FuncCall, TrinaryLogic}|null
98+
*/
99+
public static function reorderCallUserFuncArrayArguments(
100+
FuncCall $callUserFuncArrayCall,
101+
Scope $scope,
102+
): ?array
103+
{
104+
$args = $callUserFuncArrayCall->getArgs();
105+
if (count($args) < 2) {
106+
return null;
107+
}
108+
109+
$callbackArg = null;
110+
$argsArrayArg = null;
111+
foreach ($args as $i => $arg) {
112+
if ($callbackArg === null) {
113+
if ($arg->name === null && $i === 0) {
114+
$callbackArg = $arg;
115+
continue;
116+
}
117+
if ($arg->name !== null && $arg->name->toString() === 'callback') {
118+
$callbackArg = $arg;
119+
continue;
120+
}
121+
}
122+
123+
if ($argsArrayArg === null) {
124+
if ($arg->name === null && $i === 1) {
125+
$argsArrayArg = $arg;
126+
continue;
127+
}
128+
if ($arg->name !== null && $arg->name->toString() === 'args') {
129+
$argsArrayArg = $arg;
130+
continue;
131+
}
132+
}
133+
}
134+
135+
if ($callbackArg === null || $argsArrayArg === null) {
136+
return null;
137+
}
138+
139+
if (!$argsArrayArg->value instanceof Array_) {
140+
return null;
141+
}
142+
143+
$calledOnType = $scope->getType($callbackArg->value);
144+
if (!$calledOnType->isCallable()->yes()) {
145+
return null;
146+
}
147+
148+
$passThruArgs = [];
149+
foreach ($argsArrayArg->value->items as $item) {
150+
$key = null;
151+
if ($item->key instanceof String_) {
152+
/** @var int|string $offsetValue */
153+
$key = key([$item->key->value => null]);
154+
} elseif ($item->key !== null && !$item->key instanceof Int_) {
155+
// Dynamic key, we cannot be sure.
156+
return null;
157+
}
158+
159+
$passThruArgs[] = new Arg(
160+
$item->value,
161+
$item->byRef,
162+
$item->unpack,
163+
$item->getAttributes(),
164+
\is_string($key) ? new Identifier($key) : null,
165+
);
166+
}
167+
168+
$callableParametersAcceptors = $calledOnType->getCallableParametersAcceptors($scope);
169+
$parametersAcceptor = ParametersAcceptorSelector::selectFromArgs(
170+
$scope,
171+
$passThruArgs,
172+
$callableParametersAcceptors,
173+
null,
174+
);
175+
176+
$acceptsNamedArguments = TrinaryLogic::createYes();
177+
foreach ($callableParametersAcceptors as $callableParametersAcceptor) {
178+
$acceptsNamedArguments = $acceptsNamedArguments->and($callableParametersAcceptor->acceptsNamedArguments());
179+
}
180+
181+
return [$parametersAcceptor, new FuncCall(
182+
$callbackArg->value,
183+
$passThruArgs,
184+
$callUserFuncArrayCall->getAttributes(),
185+
), $acceptsNamedArguments];
186+
}
187+
92188
public static function reorderFuncArguments(
93189
ParametersAcceptor $parametersAcceptor,
94190
FuncCall $functionCall,

src/Analyser/ExprHandler/FuncCallHandler.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,15 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type
770770
}
771771
}
772772

773+
if ($functionReflection->getName() === 'call_user_func_array') {
774+
$result = ArgumentsNormalizer::reorderCallUserFuncArrayArguments($expr, $scope);
775+
if ($result !== null) {
776+
[, $innerFuncCall] = $result;
777+
778+
return $scope->getType($innerFuncCall);
779+
}
780+
}
781+
773782
$parametersAcceptor = ParametersAcceptorSelector::selectFromArgs(
774783
$scope,
775784
$expr->getArgs(),

src/Rules/Functions/CallUserFuncRule.php

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use PHPStan\Rules\Rule;
1313
use function count;
1414
use function ucfirst;
15+
use function sprintf;
1516

1617
/**
1718
* @implements Rule<FuncCall>
@@ -47,20 +48,27 @@ public function processNode(Node $node, Scope $scope): array
4748
}
4849

4950
$functionReflection = $this->reflectionProvider->getFunction($node->name, $scope);
50-
if ($functionReflection->getName() !== 'call_user_func') {
51+
52+
$functionName = $functionReflection->getName();
53+
if ($functionName === 'call_user_func') {
54+
$result = ArgumentsNormalizer::reorderCallUserFuncArguments(
55+
$node,
56+
$scope,
57+
);
58+
} elseif ($functionName === 'call_user_func_array') {
59+
$result = ArgumentsNormalizer::reorderCallUserFuncArrayArguments(
60+
$node,
61+
$scope,
62+
);
63+
} else {
5164
return [];
5265
}
53-
54-
$result = ArgumentsNormalizer::reorderCallUserFuncArguments(
55-
$node,
56-
$scope,
57-
);
5866
if ($result === null) {
5967
return [];
6068
}
6169
[$parametersAcceptor, $funcCall, $acceptsNamedArguments] = $result;
6270

63-
$callableDescription = 'callable passed to call_user_func()';
71+
$callableDescription = sprintf('callable passed to %s()', $functionName);
6472

6573
return $this->check->check(
6674
$parametersAcceptor,
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php // lint >= 8.0
2+
3+
namespace CallUserFuncArrayPhp8;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/**
8+
* @template T
9+
* @param T $t
10+
* @return T
11+
*/
12+
function generic($t) {
13+
return $t;
14+
}
15+
16+
/**
17+
* @template T
18+
* @param T $t
19+
* @return T
20+
*/
21+
function generic3($t = '', int $b = 100, string $c = '') {
22+
return $t;
23+
}
24+
25+
26+
function fun3($a = '', $b = '', $c = ''): int {
27+
return 1;
28+
}
29+
30+
class Foo {
31+
function doNamed() {
32+
assertType('1', call_user_func_array('CallUserFuncPhp8\generic', ['t' => 1]));
33+
assertType('array{1, 2, 3}', call_user_func_array('CallUserFuncPhp8\generic', ['t' => [1, 2, 3]]));
34+
35+
assertType('array{1, 2, 3}', call_user_func_array('CallUserFuncPhp8\generic3', ['t' => [1, 2, 3]]));
36+
assertType('\'\'', call_user_func_array('CallUserFuncPhp8\generic3', ['b' => 150]));
37+
assertType('\'\'', call_user_func_array('CallUserFuncPhp8\generic3', ['c' => 'lala']));
38+
assertType('\'\'', call_user_func_array(args: ['c' => 'lala'], callback: 'CallUserFuncPhp8\generic3'));
39+
40+
assertType('int', call_user_func_array('CallUserFuncPhp8\fun3', ['a' => [1, 2, 3]]));
41+
assertType('int', call_user_func_array('CallUserFuncPhp8\fun3', ['b' => [1, 2, 3]]));
42+
assertType('int', call_user_func_array('CallUserFuncPhp8\fun3', ['c' => [1, 2, 3]]));
43+
assertType('int', call_user_func_array('CallUserFuncPhp8\fun3', ['a' => [1, 2, 3], 'c' => 'c']));
44+
}
45+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
namespace CallUserFuncArray;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/**
8+
* @template T
9+
* @param T $t
10+
* @return T
11+
*/
12+
function generic($t) {
13+
return $t;
14+
}
15+
16+
function fun(): int
17+
{
18+
return 3;
19+
}
20+
21+
function fun3($i, $x, $y): int
22+
{
23+
return 3;
24+
}
25+
26+
class c {
27+
static function m(): string
28+
{
29+
return 'hello';
30+
}
31+
}
32+
33+
class Foo {
34+
function proxy() {
35+
$params = [
36+
'CallUserFunc\generic',
37+
[123, 456],
38+
];
39+
40+
assertType('mixed', call_user_func_array(...$params));
41+
}
42+
43+
/**
44+
* @param string[] $strings
45+
*/
46+
function doFunc($strings) {
47+
assertType('true', call_user_func_array('CallUserFunc\generic', [true]));
48+
assertType('\'hello\'', call_user_func_array('CallUserFunc\generic', ['hello']));
49+
assertType('array<string>', call_user_func_array('CallUserFunc\generic', [$strings]));
50+
51+
assertType('int', call_user_func_array('CallUserFunc\fun', []));
52+
assertType('int', call_user_func_array('CallUserFunc\fun3', [1 ,2 ,3]));
53+
assertType('string', call_user_func_array(['CallUserFunc\c', 'm'], []));
54+
}
55+
}

tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1128,7 +1128,7 @@ public function testCallUserFuncArray(): void
11281128
],
11291129
];
11301130
}
1131-
$this->analyse([__DIR__ . '/data/call-user-func-array.php'], $errors);
1131+
$this->analyse([__DIR__ . '/data/call-user-func-array-named-args.php'], $errors);
11321132
}
11331133

11341134
public function testFirstClassCallables(): void

tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,64 @@ public function testRule(): void
7474
]);
7575
}
7676

77+
public function testRuleCallUserFuncArray(): void
78+
{
79+
$this->analyse([__DIR__ . '/data/call-user-func-array.php'], [
80+
[
81+
'Callable passed to call_user_func_array() invoked with 0 parameters, 1 required.',
82+
15,
83+
],
84+
[
85+
'Parameter #1 $i of callable passed to call_user_func_array() expects int, string given.',
86+
17,
87+
],
88+
[
89+
'Parameter $i of callable passed to call_user_func_array() expects int, string given.',
90+
18,
91+
],
92+
[
93+
'Parameter $i of callable passed to call_user_func_array() expects int, string given.',
94+
19,
95+
],
96+
[
97+
'Unknown parameter $j in call to callable passed to call_user_func_array().',
98+
22,
99+
],
100+
[
101+
'Missing parameter $i (int) in call to callable passed to call_user_func_array().',
102+
22,
103+
],
104+
[
105+
'Callable passed to call_user_func_array() invoked with 0 parameters, 2-4 required.',
106+
30,
107+
],
108+
[
109+
'Callable passed to call_user_func_array() invoked with 1 parameter, 2-4 required.',
110+
31,
111+
],
112+
[
113+
'Callable passed to call_user_func_array() invoked with 0 parameters, at least 2 required.',
114+
40,
115+
],
116+
[
117+
'Callable passed to call_user_func_array() invoked with 1 parameter, at least 2 required.',
118+
41,
119+
],
120+
[
121+
'Result of callable passed to call_user_func_array() (void) is used.',
122+
43,
123+
],
124+
[
125+
'Parameter #1 $i of callable passed to call_user_func_array() expects int|null, string given.',
126+
52,
127+
],
128+
[
129+
'Parameter #1 $i of callable passed to call_user_func_array() expects int|null, string given.',
130+
53,
131+
],
132+
]);
133+
}
134+
77135
public function testBug7057(): void
78136
{
79137
$this->analyse([__DIR__ . '/data/bug-7057.php'], []);
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<?php
2+
3+
call_user_func_array('array_merge', ['foo' => ['bar' => 2]]);

0 commit comments

Comments
 (0)