Skip to content

Commit ceaabb9

Browse files
phpstan-botclaude
andcommitted
Resolve call_user_func_array callback to use proper parameter types for by-ref variables
Instead of blindly invalidating by-ref variables in array arguments to mixed, resolve the callback passed to call_user_func_array and use the callback's parameter types. This makes call_user_func_array([$this, 'method'], [&$var, ...]) equivalent to $this->method($var, ...) for type inference. - Add ArgumentsNormalizer::reorderCallUserFuncArrayArguments() to unpack array literal arguments into synthetic function call args - Add return type resolution for call_user_func_array in FuncCallHandler - Use callback parameter types for by-ref variables in processArgs - Fall back to mixed invalidation when callback cannot be resolved Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6f4fcd1 commit ceaabb9

4 files changed

Lines changed: 193 additions & 5 deletions

File tree

src/Analyser/ArgumentsNormalizer.php

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
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_;
@@ -89,6 +90,88 @@ public static function reorderCallUserFuncArguments(
8990
), $acceptsNamedArguments];
9091
}
9192

93+
/**
94+
* @return array{ParametersAcceptor, FuncCall, TrinaryLogic}|null
95+
*/
96+
public static function reorderCallUserFuncArrayArguments(
97+
FuncCall $callUserFuncArrayCall,
98+
Scope $scope,
99+
): ?array
100+
{
101+
$args = $callUserFuncArrayCall->getArgs();
102+
if (count($args) < 2) {
103+
return null;
104+
}
105+
106+
$callbackArg = null;
107+
$argsArrayArg = null;
108+
foreach ($args as $i => $arg) {
109+
if ($callbackArg === null) {
110+
if ($arg->name === null && $i === 0) {
111+
$callbackArg = $arg;
112+
continue;
113+
}
114+
if ($arg->name !== null && ($arg->name->toString() === 'callback' || $arg->name->toString() === 'function')) {
115+
$callbackArg = $arg;
116+
continue;
117+
}
118+
}
119+
120+
if ($argsArrayArg === null) {
121+
if ($arg->name === null && $i === 1) {
122+
$argsArrayArg = $arg;
123+
continue;
124+
}
125+
if ($arg->name !== null && ($arg->name->toString() === 'args' || $arg->name->toString() === 'parameters')) {
126+
$argsArrayArg = $arg;
127+
continue;
128+
}
129+
}
130+
}
131+
132+
if ($callbackArg === null || $argsArrayArg === null) {
133+
return null;
134+
}
135+
136+
if (!$argsArrayArg->value instanceof Array_) {
137+
return null;
138+
}
139+
140+
$calledOnType = $scope->getType($callbackArg->value);
141+
if (!$calledOnType->isCallable()->yes()) {
142+
return null;
143+
}
144+
145+
$passThruArgs = [];
146+
foreach ($argsArrayArg->value->items as $item) {
147+
$passThruArgs[] = new Arg(
148+
$item->value,
149+
$item->byRef,
150+
$item->unpack,
151+
$item->getAttributes(),
152+
);
153+
}
154+
155+
$callableParametersAcceptors = $calledOnType->getCallableParametersAcceptors($scope);
156+
$parametersAcceptor = ParametersAcceptorSelector::selectFromArgs(
157+
$scope,
158+
$passThruArgs,
159+
$callableParametersAcceptors,
160+
null,
161+
);
162+
163+
$acceptsNamedArguments = TrinaryLogic::createYes();
164+
foreach ($callableParametersAcceptors as $callableParametersAcceptor) {
165+
$acceptsNamedArguments = $acceptsNamedArguments->and($callableParametersAcceptor->acceptsNamedArguments());
166+
}
167+
168+
return [$parametersAcceptor, new FuncCall(
169+
$callbackArg->value,
170+
$passThruArgs,
171+
$callUserFuncArrayCall->getAttributes(),
172+
), $acceptsNamedArguments];
173+
}
174+
92175
public static function reorderFuncArguments(
93176
ParametersAcceptor $parametersAcceptor,
94177
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/Analyser/NodeScopeResolver.php

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3535,10 +3535,76 @@ public function processArgs(
35353535
}
35363536
}
35373537

3538-
// Invalidate variables passed by reference inside array arguments
3539-
// e.g. call_user_func_array($callback, [&$var, ...]) - $var might be modified
3540-
foreach ($args as $arg) {
3541-
$scope = $this->invalidateByRefVariablesInArrayArg($scope, $arg->value);
3538+
// For call_user_func_array with a resolvable callback, use the callback's
3539+
// parameter types for by-reference variables instead of blindly using mixed
3540+
if (
3541+
$calleeReflection instanceof FunctionReflection
3542+
&& $calleeReflection->getName() === 'call_user_func_array'
3543+
&& $callLike instanceof FuncCall
3544+
) {
3545+
$rewriteResult = ArgumentsNormalizer::reorderCallUserFuncArrayArguments($callLike, $scope);
3546+
if ($rewriteResult !== null) {
3547+
[$callbackParametersAcceptor] = $rewriteResult;
3548+
$callbackParameters = $callbackParametersAcceptor->getParameters();
3549+
$callArgs = $callLike->getArgs();
3550+
if (isset($callArgs[1]) && $callArgs[1]->value instanceof Array_) {
3551+
foreach ($callArgs[1]->value->items as $i => $arrayItem) {
3552+
// Handle nested by-ref items in sub-arrays
3553+
if ($arrayItem->value instanceof Array_) {
3554+
$scope = $this->invalidateByRefVariablesInArrayArg($scope, $arrayItem->value);
3555+
}
3556+
3557+
if (!$arrayItem->byRef) {
3558+
continue;
3559+
}
3560+
3561+
if (!$arrayItem->value instanceof Variable || !is_string($arrayItem->value->name)) {
3562+
continue;
3563+
}
3564+
3565+
$matchedParam = null;
3566+
if (isset($callbackParameters[$i])) {
3567+
$matchedParam = $callbackParameters[$i];
3568+
} elseif (count($callbackParameters) > 0 && $callbackParametersAcceptor->isVariadic()) {
3569+
$matchedParam = $callbackParameters[count($callbackParameters) - 1];
3570+
}
3571+
3572+
if ($matchedParam !== null && $matchedParam->passedByReference()->createsNewVariable()) {
3573+
$byRefType = $matchedParam->getType();
3574+
if (
3575+
$matchedParam instanceof ExtendedParameterReflection
3576+
&& $matchedParam->getOutType() !== null
3577+
) {
3578+
$byRefType = $matchedParam->getOutType();
3579+
}
3580+
3581+
$scope = $this->processVirtualAssign(
3582+
$scope,
3583+
$storage,
3584+
$stmt,
3585+
$arrayItem->value,
3586+
new TypeExpr($byRefType),
3587+
$nodeCallback,
3588+
)->getScope();
3589+
} else {
3590+
// Callback parameter is not by-ref or unknown - invalidate to mixed
3591+
// since we can't determine what happens with the reference
3592+
$scope = $scope->assignVariable($arrayItem->value->name, new MixedType(), new MixedType(), TrinaryLogic::createYes());
3593+
}
3594+
}
3595+
}
3596+
} else {
3597+
// Could not resolve the callback - fall back to generic invalidation
3598+
foreach ($args as $arg) {
3599+
$scope = $this->invalidateByRefVariablesInArrayArg($scope, $arg->value);
3600+
}
3601+
}
3602+
} else {
3603+
// Invalidate variables passed by reference inside array arguments
3604+
// e.g. some_function([&$var, ...]) - $var might be modified
3605+
foreach ($args as $arg) {
3606+
$scope = $this->invalidateByRefVariablesInArrayArg($scope, $arg->value);
3607+
}
35423608
}
35433609

35443610
// not storing this, it's scope after processing all args

tests/PHPStan/Analyser/nsrt/bug-6799.php

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ protected function test(array $filterValues, array $filters): void
2828
foreach ($filterValues as $type => $value) {
2929
call_user_func_array(array($this, 'addFilter'), array(&$whereFilter, $filters[$type], $value));
3030
}
31-
assertType('mixed', $whereFilter);
31+
assertType('array<string>', $whereFilter);
3232
}
3333
}
3434
}
@@ -40,6 +40,36 @@ function testSimple(): void
4040
assertType('mixed', $arr);
4141
}
4242

43+
function testDirectFunction(): void
44+
{
45+
$result = [];
46+
call_user_func_array('Bug6799\modify_by_ref', [&$result, 'value']);
47+
assertType('array<string>', $result);
48+
}
49+
50+
/** @param callable $callback */
51+
function testUnresolvableCallback($callback): void
52+
{
53+
$arr = [];
54+
call_user_func_array($callback, [&$arr, 'foo']);
55+
assertType('mixed', $arr);
56+
}
57+
58+
function testCallbackNotByRef(): void
59+
{
60+
$arr = [];
61+
call_user_func_array('Bug6799\some_function', [1, [&$arr, 'foo']]);
62+
assertType('mixed', $arr);
63+
}
64+
65+
/**
66+
* @param string[] $arr
67+
*/
68+
function modify_by_ref(array &$arr, string $value): void
69+
{
70+
$arr[] = $value;
71+
}
72+
4373
/**
4474
* @param mixed $x
4575
*/

0 commit comments

Comments
 (0)