Skip to content

Commit 1101adb

Browse files
VincentLangletphpstan-bot
authored andcommitted
Recognize [$obj, $method] as callable when method_exists($obj, $method) is known true in scope
- In `ArrayHandler::resolveType()`, after computing the array type, check if the 2-element array's `isCallable()` returns `maybe` and whether `method_exists(item0, item1)` is known to be `true` in the current scope - When both conditions are met, intersect the array type with `CallableType`, making `isCallable()` return `yes` instead of `maybe` - This fixes false positives from `CallCallablesRule` for patterns like: `method_exists($obj, $method)` followed by `[$obj, $method]()` - Also fixes the same pattern via `is_callable([$obj, $method])` for inline arrays, since `IsCallableFunctionTypeSpecifyingExtension` delegates to `MethodExistsTypeSpecifyingExtension` which stores the `method_exists` result in scope - Tested with: `$this`, class-string first element, `is_callable` inline array, and if/else branching patterns
1 parent 7604335 commit 1101adb

4 files changed

Lines changed: 129 additions & 1 deletion

File tree

src/Analyser/ExprHandler/ArrayHandler.php

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
namespace PHPStan\Analyser\ExprHandler;
44

5+
use PhpParser\Node\Arg;
56
use PhpParser\Node\Expr;
67
use PhpParser\Node\Expr\Array_;
8+
use PhpParser\Node\Expr\FuncCall;
9+
use PhpParser\Node\Name\FullyQualified;
710
use PhpParser\Node\Stmt;
811
use PHPStan\Analyser\ExpressionContext;
912
use PHPStan\Analyser\ExpressionResult;
@@ -15,8 +18,12 @@
1518
use PHPStan\Node\LiteralArrayItem;
1619
use PHPStan\Node\LiteralArrayNode;
1720
use PHPStan\Reflection\InitializerExprTypeResolver;
21+
use PHPStan\Type\CallableType;
22+
use PHPStan\Type\Constant\ConstantBooleanType;
1823
use PHPStan\Type\Type;
24+
use PHPStan\Type\TypeCombinator;
1925
use function array_merge;
26+
use function count;
2027

2128
/**
2229
* @implements ExprHandler<Array_>
@@ -38,7 +45,25 @@ public function supports(Expr $expr): bool
3845

3946
public function resolveType(MutatingScope $scope, Expr $expr): Type
4047
{
41-
return $this->initializerExprTypeResolver->getArrayType($expr, static fn (Expr $expr): Type => $scope->getType($expr));
48+
$type = $this->initializerExprTypeResolver->getArrayType($expr, static fn (Expr $expr): Type => $scope->getType($expr));
49+
50+
if (
51+
count($expr->items) === 2
52+
&& $expr->items[0]->key === null
53+
&& $expr->items[1]->key === null
54+
&& $type->isCallable()->maybe()
55+
) {
56+
$methodExistsCall = new FuncCall(
57+
new FullyQualified('method_exists'),
58+
[new Arg($expr->items[0]->value), new Arg($expr->items[1]->value)],
59+
);
60+
$methodExistsType = $scope->getType($methodExistsCall);
61+
if ((new ConstantBooleanType(true))->isSuperTypeOf($methodExistsType)->yes()) {
62+
$type = TypeCombinator::intersect($type, new CallableType());
63+
}
64+
}
65+
66+
return $type;
4267
}
4368

4469
public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug4510Nsrt;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class Foo
8+
{
9+
public function existingMethod(): void {}
10+
}
11+
12+
function testMethodExists(string $method): void {
13+
$instance = new Foo();
14+
if (!method_exists($instance, $method)) {
15+
return;
16+
}
17+
18+
assertType('list{Bug4510Nsrt\Foo, string}&callable(): mixed', [$instance, $method]);
19+
}
20+
21+
function testIsCallableInlineArray(string $method): void {
22+
$instance = new Foo();
23+
if (!is_callable([$instance, $method])) {
24+
return;
25+
}
26+
27+
assertType('list{Bug4510Nsrt\Foo, string}&callable(): mixed', [$instance, $method]);
28+
}
29+
30+
function testMethodExistsWithClassString(string $method): void {
31+
if (!method_exists(Foo::class, $method)) {
32+
return;
33+
}
34+
35+
assertType("list{'Bug4510Nsrt\\\\Foo', string}&callable(): mixed", [Foo::class, $method]);
36+
}
37+
38+
function testNoMethodExists(string $method): void {
39+
$instance = new Foo();
40+
assertType('array{Bug4510Nsrt\Foo, string}', [$instance, $method]);
41+
}

tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,11 @@ public function testBug4608(): void
374374
]);
375375
}
376376

377+
public function testBug4510(): void
378+
{
379+
$this->analyse([__DIR__ . '/data/bug-4510.php'], []);
380+
}
381+
377382
public function testMaybeNotCallable(): void
378383
{
379384
$errors = [];
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug4510;
4+
5+
class HelloWorld
6+
{
7+
public function doSomething(string $method): void {
8+
if (!method_exists($this, $method)) {
9+
return;
10+
}
11+
12+
[$this, $method]();
13+
}
14+
}
15+
16+
function bar(string $method): void {
17+
$instance = new HelloWorld();
18+
if (!method_exists($instance, $method)) {
19+
return;
20+
}
21+
22+
[$instance, $method]();
23+
}
24+
25+
function baz(string $method): void {
26+
$instance = new HelloWorld();
27+
if (!is_callable([$instance, $method])) {
28+
return;
29+
}
30+
31+
[$instance, $method]();
32+
}
33+
34+
function withClassString(string $method): void {
35+
if (!method_exists(HelloWorld::class, $method)) {
36+
return;
37+
}
38+
39+
[HelloWorld::class, $method]();
40+
}
41+
42+
function withDynamicMethodExistsAndVariable(string $method): void {
43+
$instance = new HelloWorld();
44+
$callable = [$instance, $method];
45+
if (!is_callable($callable)) {
46+
return;
47+
}
48+
49+
$callable();
50+
}
51+
52+
function methodExistsInElseBranch(string $method): void {
53+
$instance = new HelloWorld();
54+
if (method_exists($instance, $method)) {
55+
[$instance, $method]();
56+
}
57+
}

0 commit comments

Comments
 (0)