Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion src/Analyser/ExprHandler/ArrayHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,8 +18,12 @@
use PHPStan\Node\LiteralArrayItem;
use PHPStan\Node\LiteralArrayNode;
use PHPStan\Reflection\InitializerExprTypeResolver;
use PHPStan\Type\CallableType;
use PHPStan\Type\Constant\ConstantBooleanType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use function array_merge;
use function count;

/**
* @implements ExprHandler<Array_>
Expand All @@ -38,7 +45,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])
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we limit this to 2 item arrays only?
that way we would not need to check any literal array we find, whether its callable

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Added count($expr->items) === 2 as an early guard before the isCallable()->maybe() check, so we only evaluate callable-related logic on arrays that could actually be [$obj, $method] pairs. Tests and static analysis both pass.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isset($expr->items[0], $expr->items[1]) is faster than $type->isCallable()->maybe() so you can swap the condition.

Also, how are indexed the item ?
Cause array{0: string, 1: string} might be callable but array{1: string, 2: string} won't.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed. Here's a summary of the changes addressing VincentLanglet's review:

  1. Reordered conditions in ArrayHandler: Moved isset($expr->items[0], $expr->items[1]) and the new key null checks before $type->isCallable()->maybe() since they're cheaper to evaluate.

  2. Added key null checks: Added $expr->items[0]->key === null && $expr->items[1]->key === null to ensure we only consider auto-indexed arrays ([0: ..., 1: ...]), not explicitly-keyed arrays like [1: ..., 2: ...] which wouldn't be callable.

  3. Inlined isCallableMarker variable in IsCallableFunctionTypeSpecifyingExtension as requested in the previous review.

All tests (11,927) and static analysis pass.

&& $type->isCallable()->maybe()
) {
$isCallableCall = new FuncCall(
new FullyQualified('is_callable'),
[new Arg($expr->items[0]->value), new Arg($expr->items[1]->value)],
);
if (
$scope->hasExpressionType($isCallableCall)->yes()
&& (new ConstantBooleanType(true))->isSuperTypeOf($scope->getType($isCallableCall))->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
Expand Down
14 changes: 13 additions & 1 deletion src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -57,7 +59,17 @@ 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->items[0]->value),
new Arg($value->items[1]->value),
]),
new ConstantBooleanType(true),
$context,
$scope,
));
}

return $this->typeSpecifier->create($value, new CallableType(), $context, $scope);
Expand Down
124 changes: 124 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-4510.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php declare(strict_types = 1);

namespace Bug4510;

use function PHPStan\Testing\assertType;

class HelloWorld
{
public function existingMethod(): void {}

public function doSomething(string $method): void {
if (!method_exists($this, $method)) {
return;
}

[$this, $method](); // error - method_exists doesn't imply callable
}
}

function testMethodExists(string $method): void {
$instance = new HelloWorld();
if (!method_exists($instance, $method)) {
return;
}

assertType('array{Bug4510\HelloWorld, string}', [$instance, $method]);
[$instance, $method](); // error - method_exists doesn't imply callable
}

function testIsCallableInlineArray(string $method): void {
$instance = new HelloWorld();
if (!is_callable([$instance, $method])) {
return;
}

assertType('list{Bug4510\HelloWorld, string}&callable(): mixed', [$instance, $method]);
[$instance, $method](); // ok - is_callable verifies callability
}

function testMethodExistsWithClassString(string $method): void {
if (!method_exists(HelloWorld::class, $method)) {
return;
}

assertType("array{'Bug4510\\\\HelloWorld', string}", [HelloWorld::class, $method]);
[HelloWorld::class, $method](); // error - method_exists doesn't imply callable
}

function testIsCallableWithClassString(string $method): void {
if (!is_callable([HelloWorld::class, $method])) {
return;
}

assertType("list{'Bug4510\\\\HelloWorld', string}&callable(): mixed", [HelloWorld::class, $method]);
[HelloWorld::class, $method](); // ok - is_callable verifies callability
}

function testIsCallableExplicitKeys(string $method): void {
$instance = new HelloWorld();
if (!is_callable([0 => $instance, 1 => $method])) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seeing this makes me think we should also test with named arguments

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]);
}
26 changes: 26 additions & 0 deletions tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,32 @@ 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,
],
]);
}

public function testMaybeNotCallable(): void
{
$errors = [];
Expand Down
Loading