diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index a4d150b0e2f..ba1ec13c191 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -2,8 +2,11 @@ namespace PHPStan\Analyser\ExprHandler; +use PhpParser\Node\Arg; use PhpParser\Node\Expr; use PhpParser\Node\Expr\Array_; +use PhpParser\Node\Expr\FuncCall; +use PhpParser\Node\Name\FullyQualified; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; @@ -15,8 +18,11 @@ use PHPStan\Node\LiteralArrayItem; use PHPStan\Node\LiteralArrayNode; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\Type\CallableType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use function array_merge; +use function count; /** * @implements ExprHandler @@ -38,7 +44,26 @@ public function supports(Expr $expr): bool public function resolveType(MutatingScope $scope, Expr $expr): Type { - return $this->initializerExprTypeResolver->getArrayType($expr, static fn (Expr $expr): Type => $scope->getType($expr)); + $type = $this->initializerExprTypeResolver->getArrayType($expr, static fn (Expr $expr): Type => $scope->getType($expr)); + + if ( + count($expr->items) === 2 + && isset($expr->items[0], $expr->items[1]) + && $type->isCallable()->maybe() + ) { + $isCallableCall = new FuncCall( + new FullyQualified('is_callable'), + [new Arg($expr)], + ); + if ( + $scope->hasExpressionType($isCallableCall)->yes() + && $scope->getType($isCallableCall)->isTrue()->yes() + ) { + $type = TypeCombinator::intersect($type, new CallableType()); + } + } + + return $type; } public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult diff --git a/src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php b/src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php index a0cde666e5b..c9fe9f343ec 100644 --- a/src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php @@ -6,6 +6,7 @@ use PhpParser\Node\Expr\Array_; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Name; +use PhpParser\Node\Name\FullyQualified; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; @@ -15,6 +16,7 @@ use PHPStan\Reflection\FunctionReflection; use PHPStan\ShouldNotHappenException; use PHPStan\Type\CallableType; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\FunctionTypeSpecifyingExtension; use function count; use function strtolower; @@ -57,7 +59,16 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n new Arg($value->items[0]->value), new Arg($value->items[1]->value), ]); - return $this->methodExistsExtension->specifyTypes($functionReflection, $functionCall, $scope, $context); + $methodExistsTypes = $this->methodExistsExtension->specifyTypes($functionReflection, $functionCall, $scope, $context); + + return $methodExistsTypes->unionWith($this->typeSpecifier->create( + new FuncCall(new FullyQualified('is_callable'), [ + new Arg($value), + ]), + new ConstantBooleanType(true), + $context, + $scope, + )); } return $this->typeSpecifier->create($value, new CallableType(), $context, $scope); diff --git a/tests/PHPStan/Analyser/nsrt/bug-4510.php b/tests/PHPStan/Analyser/nsrt/bug-4510.php new file mode 100644 index 00000000000..3c5b2c52f70 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-4510.php @@ -0,0 +1,134 @@ + $instance, 1 => $method])) { + return; + } + + assertType('list{Bug4510\HelloWorld, string}&callable(): mixed', [0 => $instance, 1 => $method]); + [0 => $instance, 1 => $method](); // ok - is_callable verifies callability +} + +function testIsCallableExplicitKeysWithClassString(string $method): void { + if (!is_callable([0 => HelloWorld::class, 1 => $method])) { + return; + } + + assertType("list{'Bug4510\\\\HelloWorld', string}&callable(): mixed", [0 => HelloWorld::class, 1 => $method]); + [0 => HelloWorld::class, 1 => $method](); // ok - is_callable verifies callability +} + +function testWithDynamicMethodExistsAndVariable(string $method): void { + $instance = new HelloWorld(); + $callable = [$instance, $method]; + if (!is_callable($callable)) { + return; + } + + $callable(); // ok - is_callable on variable already worked +} + +function testMethodExistsInElseBranch(string $method): void { + $instance = new HelloWorld(); + if (method_exists($instance, $method)) { + [$instance, $method](); // error - method_exists doesn't imply callable + } +} + +function testIsCallableInElseBranch(string $method): void { + $instance = new HelloWorld(); + if (is_callable([$instance, $method])) { + [$instance, $method](); // ok - is_callable verifies callability + } +} + +function testIsCallableNamedArg(string $method): void { + $instance = new HelloWorld(); + if (!is_callable(value: [$instance, $method])) { + return; + } + + assertType('list{Bug4510\HelloWorld, string}&callable(): mixed', [$instance, $method]); + [$instance, $method](); // ok - is_callable verifies callability +} + +function testMethodExistsNamedArgs(string $method): void { + $instance = new HelloWorld(); + if (!method_exists(object_or_class: $instance, method: $method)) { + return; + } + + assertType('array{Bug4510\HelloWorld, string}', [$instance, $method]); + [$instance, $method](); // error - method_exists doesn't imply callable +} + +function testNoMethodExists(string $method): void { + $instance = new HelloWorld(); + assertType('array{Bug4510\HelloWorld, string}', [$instance, $method]); +} + +function testIsCallableFalseBranch(string $method): void { + $instance = new HelloWorld(); + if (is_callable([$instance, $method])) { + return; + } + + assertType('array{Bug4510\HelloWorld, string}', [$instance, $method]); + [$instance, $method](); // error - is_callable was false +} diff --git a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php index 44cec29c54f..11ec549385f 100644 --- a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php @@ -374,6 +374,36 @@ public function testBug4608(): void ]); } + public function testBug4510(): void + { + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-4510.php'], [ + [ + 'Trying to invoke array{$this(Bug4510\HelloWorld), string} but it might not be a callable.', + 16, + ], + [ + 'Trying to invoke array{Bug4510\HelloWorld, string} but it might not be a callable.', + 27, + ], + [ + "Trying to invoke array{'Bug4510\\\HelloWorld', string} but it might not be a callable.", + 46, + ], + [ + 'Trying to invoke array{Bug4510\HelloWorld, string} but it might not be a callable.', + 90, + ], + [ + 'Trying to invoke array{Bug4510\HelloWorld, string} but it might not be a callable.', + 118, + ], + [ + 'Trying to invoke array{Bug4510\HelloWorld, string} but it might not be a callable.', + 133, + ], + ]); + } + public function testMaybeNotCallable(): void { $errors = [];