diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 78d6e328a54..6906e22da9d 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -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 diff --git a/src/Analyser/ArgumentsNormalizer.php b/src/Analyser/ArgumentsNormalizer.php index 5b3e35e3e96..6c254df7632 100644 --- a/src/Analyser/ArgumentsNormalizer.php +++ b/src/Analyser/ArgumentsNormalizer.php @@ -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; @@ -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; @@ -89,6 +95,102 @@ public static function reorderCallUserFuncArguments( ), $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()) { + 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, diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 96cf7fa0f14..e345e823659 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -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(), diff --git a/src/Rules/Functions/CallUserFuncRule.php b/src/Rules/Functions/CallUserFuncRule.php index 3dae092b529..3757f826fcd 100644 --- a/src/Rules/Functions/CallUserFuncRule.php +++ b/src/Rules/Functions/CallUserFuncRule.php @@ -11,6 +11,7 @@ use PHPStan\Rules\FunctionCallParametersCheck; use PHPStan\Rules\Rule; use function count; +use function sprintf; use function ucfirst; /** @@ -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, diff --git a/tests/PHPStan/Analyser/nsrt/call-user-func-array-php8.php b/tests/PHPStan/Analyser/nsrt/call-user-func-array-php8.php new file mode 100644 index 00000000000..f8db8e5c22e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/call-user-func-array-php8.php @@ -0,0 +1,45 @@ += 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'])); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/call-user-func_array.php b/tests/PHPStan/Analyser/nsrt/call-user-func_array.php new file mode 100644 index 00000000000..8d8f29a44b4 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/call-user-func_array.php @@ -0,0 +1,55 @@ +', 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'], [])); + } +} diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index f78f5918f77..58faaefd2e6 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -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 diff --git a/tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php b/tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php index dd4b3259a83..96f70cabe07 100644 --- a/tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php @@ -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'], []); diff --git a/tests/PHPStan/Rules/Functions/data/call-user-func-array-named-args.php b/tests/PHPStan/Rules/Functions/data/call-user-func-array-named-args.php new file mode 100644 index 00000000000..dd9f6155172 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/call-user-func-array-named-args.php @@ -0,0 +1,3 @@ + ['bar' => 2]]); diff --git a/tests/PHPStan/Rules/Functions/data/call-user-func-array.php b/tests/PHPStan/Rules/Functions/data/call-user-func-array.php index dd9f6155172..95810e700ba 100644 --- a/tests/PHPStan/Rules/Functions/data/call-user-func-array.php +++ b/tests/PHPStan/Rules/Functions/data/call-user-func-array.php @@ -1,3 +1,60 @@ -= 8.0 -call_user_func_array('array_merge', ['foo' => ['bar' => 2]]); +namespace CallUserFuncRuleArray; + +use function call_user_func_array; + +class Foo +{ + + public function doFoo(): void + { + $f = function (int $i): void { + + }; + call_user_func_array($f, []); + call_user_func_array($f, [1]); + call_user_func_array($f, ['foo']); + call_user_func_array($f, ['i' => 'foo']); + call_user_func_array(args: ['i' => 'foo'], callback: $f); + call_user_func_array($f, ['i' => 1]); + call_user_func_array(args: ['i' => 1], callback: $f); + call_user_func_array($f, ['j' => 1]); + } + + public function doBar(): void + { + $f = function (int $i, $j, $g = 2, $h = 3): void { + }; + + call_user_func_array($f, []); + call_user_func_array($f, [1]); + call_user_func_array($f, [2, 'foo']); + } + + public function doVariadic(): void + { + $f = function ($i, $j, ...$params): void { + }; + + call_user_func_array($f, []); + call_user_func_array($f, [1]); + call_user_func_array($f, [2, 'foo']); + $result = call_user_func_array($f, [2, 'foo']); + } + + public function doMore(): void + { + $f = function (?int $i = null, ?string $j = null): void { + }; + + call_user_func_array($f, []); + call_user_func_array($f, [1 => 'foo']); // Same as call_user_func_array($f, ['foo']) + call_user_func_array($f, ['1' => 'foo']); // Same as call_user_func_array($f, ['foo']) + call_user_func_array($f, [1 => 42]); // Same as call_user_func_array($f, [42]) + call_user_func_array($f, ['1' => 42]); // Same as call_user_func_array($f, [42]) + + call_user_func_array($f, ['' => 42]); // Could be reported but should at least not crash PHPStan + } + +}