Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
24 changes: 23 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,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<Array_>
Expand All @@ -38,7 +44,23 @@

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()) {

Check warning on line 58 in src/Analyser/ExprHandler/ArrayHandler.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ new FullyQualified('is_callable'), [new Arg($expr->items[0]->value), new Arg($expr->items[1]->value)], ); - if ($scope->hasExpressionType($isCallableCall)->yes()) { + if (!$scope->hasExpressionType($isCallableCall)->no()) { $type = TypeCombinator::intersect($type, new CallableType()); } }

Check warning on line 58 in src/Analyser/ExprHandler/ArrayHandler.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ new FullyQualified('is_callable'), [new Arg($expr->items[0]->value), new Arg($expr->items[1]->value)], ); - if ($scope->hasExpressionType($isCallableCall)->yes()) { + if (!$scope->hasExpressionType($isCallableCall)->no()) { $type = TypeCombinator::intersect($type, new CallableType()); } }
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.

Why would getType returning "NeverType" ? It would make sens that it returns True.

Just checking hasExpressionType is no enough cause the day we'll save is_callable it will produce an error.

We should add a test with

$instance = new HelloWorld();
	if (is_callable([$instance, $method])) {
		return;
	}

	assertType('list{Bug4510\HelloWorld, string}', [$instance, $method]);
    assertType('false', is_callable([$instance, $method]));
	[$instance, $method](); // error reported

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.

All changes are pushed. Here's a summary of what was done:

Root cause of getType() returning NeverType: The synthetic is_callable FuncCall used 2 separate arguments ($instance, $method). The is_callable stub has @return ($value is callable ? true : false) conditional return type, which evaluated $value = $instance (first arg) as non-callable → returned false. Then addTypeToExpression intersected true ∩ false = NeverType.

Fix: Changed both IsCallableFunctionTypeSpecifyingExtension and ArrayHandler to pass the whole array as a single argument to the synthetic is_callable call. Now the conditional return type evaluates [$obj, $method] whose isCallable() is maybe → returns bool. So intersect(true, bool) = true, making getType()->isTrue()->yes() work correctly.

Changes:

  1. ArrayHandler.php: Synthetic call now uses [new Arg($expr)] instead of splitting into 2 args. Added getType()->isTrue()->yes() check alongside hasExpressionType.
  2. IsCallableFunctionTypeSpecifyingExtension.php: Marker stored with [new Arg($value)] (whole array) instead of 2 separate element args.
  3. bug-4510.php: Added testIsCallableFalseBranch test verifying the array is NOT treated as callable when is_callable returned false.
  4. CallCallablesRuleTest.php: Added expected error for the false-branch test case.

All 11,935 tests pass and static analysis reports no errors.

$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])) {
Comment thread
staabm marked this conversation as resolved.
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