Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser\ExprHandler\Virtual;

use PhpParser\Node\Expr;
use PhpParser\Node\Stmt;
use PHPStan\Analyser\ExpressionContext;
use PHPStan\Analyser\ExpressionResult;
use PHPStan\Analyser\ExpressionResultStorage;
use PHPStan\Analyser\ExprHandler;
use PHPStan\Analyser\MutatingScope;
use PHPStan\Analyser\NodeScopeResolver;
use PHPStan\Analyser\Scope;
use PHPStan\Analyser\SpecifiedTypes;
use PHPStan\Analyser\TypeSpecifier;
use PHPStan\Analyser\TypeSpecifierContext;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Node\NullsafeFirstClassCallableNode;
use PHPStan\Type\MixedType;
use PHPStan\Type\Type;
use function array_merge;

/**
* @implements ExprHandler<NullsafeFirstClassCallableNode>
*/
#[AutowiredService]
final class NullsafeFirstClassCallableNodeHandler implements ExprHandler
{

public function supports(Expr $expr): bool
{
return $expr instanceof NullsafeFirstClassCallableNode;
}

public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult
{
$varResult = $nodeScopeResolver->processExprNode($stmt, $expr->getVar(), $scope, $storage, $nodeCallback, ExpressionContext::createDeep());
$scope = $varResult->getScope();
$hasYield = $varResult->hasYield();
$throwPoints = $varResult->getThrowPoints();
$impurePoints = $varResult->getImpurePoints();
$isAlwaysTerminating = false;
if ($expr->getName() instanceof Expr) {
$nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->getName(), $scope, $storage, $nodeCallback, ExpressionContext::createDeep());
$scope = $nameResult->getScope();
$hasYield = $hasYield || $nameResult->hasYield();
$throwPoints = array_merge($throwPoints, $nameResult->getThrowPoints());
$impurePoints = array_merge($impurePoints, $nameResult->getImpurePoints());
$isAlwaysTerminating = $nameResult->isAlwaysTerminating();
}

return new ExpressionResult(
$scope,
hasYield: $hasYield,
isAlwaysTerminating: $isAlwaysTerminating,
throwPoints: $throwPoints,
impurePoints: $impurePoints,
);
}

public function resolveType(MutatingScope $scope, Expr $expr): Type
{
// in practice the type of the first-class callable is resolved
// by NullsafeMethodCallHandler / FirstClassCallableMethodCallHandler
return new MixedType();
}

public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes
{
return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context);
}

}
6 changes: 6 additions & 0 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
use PHPStan\Node\MethodCallableNode;
use PHPStan\Node\MethodReturnStatementsNode;
use PHPStan\Node\NoopExpressionNode;
use PHPStan\Node\NullsafeFirstClassCallableNode;
use PHPStan\Node\PropertyAssignNode;
use PHPStan\Node\PropertyHookReturnStatementsNode;
use PHPStan\Node\PropertyHookStatementNode;
Expand Down Expand Up @@ -2755,6 +2756,11 @@ public function processExprNode(
$newExpr = new FunctionCallableNode($expr->name, $expr);
} elseif ($expr instanceof MethodCall) {
$newExpr = new MethodCallableNode($expr->var, $expr->name, $expr);
} elseif ($expr instanceof Expr\NullsafeMethodCall) {
// $foo?->bar(...) is a fatal error in PHP ("Cannot combine nullsafe
// operator with Closure creation"), but it must not crash the analyser.
// The error is reported by NullsafeFirstClassCallableRule.
$newExpr = new NullsafeFirstClassCallableNode($expr->var, $expr->name, $expr);
} elseif ($expr instanceof StaticCall) {
$newExpr = new StaticMethodCallableNode($expr->class, $expr->name, $expr);
} elseif ($expr instanceof New_ && !$expr->class instanceof Class_) {
Expand Down
5 changes: 5 additions & 0 deletions src/Dependency/DependencyResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use PHPStan\Node\InPropertyHookNode;
use PHPStan\Node\InstantiationCallableNode;
use PHPStan\Node\MethodCallableNode;
use PHPStan\Node\NullsafeFirstClassCallableNode;
use PHPStan\Node\StaticMethodCallableNode;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\ExtendedParameterReflection;
Expand Down Expand Up @@ -486,6 +487,10 @@ public function resolveDependencies(Node $node, Scope $scope): NodeDependencies
foreach ($this->resolveDependencies(new Node\Expr\MethodCall($node->getVar(), $node->getName()), $scope)->getReflections() as $dependency) {
$dependenciesReflections[] = $dependency;
}
} elseif ($node instanceof NullsafeFirstClassCallableNode) {
foreach ($this->resolveDependencies(new Node\Expr\NullsafeMethodCall($node->getVar(), $node->getName()), $scope)->getReflections() as $dependency) {
$dependenciesReflections[] = $dependency;
}
} elseif ($node instanceof FunctionCallableNode) {
foreach ($this->resolveDependencies(new Node\Expr\FuncCall($node->getName()), $scope)->getReflections() as $dependency) {
$dependenciesReflections[] = $dependency;
Expand Down
61 changes: 61 additions & 0 deletions src/Node/NullsafeFirstClassCallableNode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php declare(strict_types = 1);

namespace PHPStan\Node;

use Override;
use PhpParser\Node\Expr;
use PhpParser\Node\Identifier;

/**
* Represents `$foo?->bar(...)` - combining the nullsafe operator with the
* first-class callable syntax. This is a fatal error in PHP ("Cannot combine
* nullsafe operator with Closure creation"), reported by NullsafeFirstClassCallableRule.
*
* @api
*/
final class NullsafeFirstClassCallableNode extends Expr implements VirtualNode
{

public function __construct(
private Expr $var,
private Identifier|Expr $name,
private Expr\NullsafeMethodCall $originalNode,
)
{
parent::__construct($originalNode->getAttributes());
}

public function getVar(): Expr
{
return $this->var;
}

/**
* @return Expr|Identifier
*/
public function getName()
{
return $this->name;
}

public function getOriginalNode(): Expr\NullsafeMethodCall
{
return $this->originalNode;
}

#[Override]
public function getType(): string
{
return 'PHPStan_Node_NullsafeFirstClassCallableNode';
}

/**
* @return string[]
*/
#[Override]
public function getSubNodeNames(): array
{
return [];
}

}
6 changes: 6 additions & 0 deletions src/Node/Printer/Printer.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
use PHPStan\Node\InstantiationCallableNode;
use PHPStan\Node\IssetExpr;
use PHPStan\Node\MethodCallableNode;
use PHPStan\Node\NullsafeFirstClassCallableNode;
use PHPStan\Node\StaticMethodCallableNode;
use PHPStan\Type\VerbosityLevel;
use function sprintf;
Expand Down Expand Up @@ -160,6 +161,11 @@ protected function pPHPStan_Node_MethodCallableNode(MethodCallableNode $expr): s
return sprintf('__phpstanMethodCallable(%s)', $this->p($expr->getOriginalNode()));
}

protected function pPHPStan_Node_NullsafeFirstClassCallableNode(NullsafeFirstClassCallableNode $expr): string // phpcs:ignore
{
return sprintf('__phpstanNullsafeFirstClassCallable(%s)', $this->p($expr->getOriginalNode()));
}

protected function pPHPStan_Node_StaticMethodCallableNode(StaticMethodCallableNode $expr): string // phpcs:ignore
{
return sprintf('__phpstanStaticMethodCallable(%s)', $this->p($expr->getOriginalNode()));
Expand Down
34 changes: 34 additions & 0 deletions src/Rules/Methods/NullsafeFirstClassCallableRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Methods;

use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\DependencyInjection\RegisteredRule;
use PHPStan\Node\NullsafeFirstClassCallableNode;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
* @implements Rule<NullsafeFirstClassCallableNode>
*/
#[RegisteredRule(level: 0)]
final class NullsafeFirstClassCallableRule implements Rule
{

public function getNodeType(): string
{
return NullsafeFirstClassCallableNode::class;
}

public function processNode(Node $node, Scope $scope): array
{
return [
RuleErrorBuilder::message('Cannot combine nullsafe operator with Closure creation.')
->nonIgnorable()
->identifier('nullsafe.firstClassCallable')
->build(),
];
}

}
11 changes: 11 additions & 0 deletions tests/PHPStan/Analyser/AnalyserIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1599,6 +1599,17 @@ public function testBug14707(): void
$this->assertNoErrors($errors);
}

public function testBug9746(): void
{
// first-class callable NullsafeMethodCall used to crash with an internal error
$errors = $this->runAnalyse(__DIR__ . '/data/bug-9746.php');
$this->assertCount(2, $errors);
$this->assertSame('Call to method Bug9746\HelloWorld::sayHello() on a separate line has no effect.', $errors[0]->getMessage());
$this->assertSame(11, $errors[0]->getLine());
$this->assertSame('Cannot combine nullsafe operator with Closure creation.', $errors[1]->getMessage());
$this->assertSame(11, $errors[1]->getLine());
}

/**
* @param string[]|null $allAnalysedFiles
* @return list<Error>
Expand Down
13 changes: 13 additions & 0 deletions tests/PHPStan/Analyser/data/bug-9746.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php // lint >= 8.1

declare(strict_types = 1);

namespace Bug9746;

class HelloWorld
{
public function sayHello(?self $self): void
{
$self?->sayHello(...);

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.

we expect a Cannot combine nullsafe operator with Closure creation error on this line

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.

I've addressed staabm's review. Here's what I did:

The review

staabm pointed out that for $self?->sayHello(...) PHPStan should report a Cannot combine nullsafe operator with Closure creation error (matching PHP's fatal error), not just silently treat it as a valid method-call first-class callable.

Changes

  1. src/Node/MethodCallableNode.php — added a nullsafe flag (default false) plus an isNullsafe() accessor, so the node can record that it originated from a $foo?->bar(...) expression.

  2. src/Analyser/NodeScopeResolver.php — the NullsafeMethodCall first-class-callable branch now passes nullsafe: true when building the MethodCallableNode.

  3. src/Rules/Methods/MethodCallableRule.php — when the node is nullsafe, it reports a non-ignorable error Cannot combine nullsafe operator with Closure creation. (identifier nullsafe.firstClassCallable) and skips the normal method-existence check.

  4. Tests:

    • New tests/PHPStan/Rules/Methods/data/method-callable-nullsafe.php + testNullsafe() in MethodCallableRuleTest — direct rule-level coverage of the new error.
    • Fixed testBug9746() in AnalyserIntegrationTest, which was inconsistent with its data file (it referenced a Bug9746Integration namespace/file that no longer existed). It now asserts the actual two errors, including the nullsafe one.

Verification

  • testNullsafe fails without the source change (it instead reports Cannot call method doFoo() on Foo|null), confirming the test catches the regression; passes with the fix.
  • All affected test classes pass (197 tests).
  • make phpstan: No errors.

Committed as d31bdac35 and pushed.

}
}
31 changes: 31 additions & 0 deletions tests/PHPStan/Rules/Methods/NullsafeFirstClassCallableRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Methods;

use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;
use PHPUnit\Framework\Attributes\RequiresPhp;

/**
* @extends RuleTestCase<NullsafeFirstClassCallableRule>
*/
class NullsafeFirstClassCallableRuleTest extends RuleTestCase
{

protected function getRule(): Rule
{
return new NullsafeFirstClassCallableRule();
}

#[RequiresPhp('>= 8.1.0')]
public function testRule(): void
{
$this->analyse([__DIR__ . '/data/nullsafe-first-class-callable.php'], [
[
'Cannot combine nullsafe operator with Closure creation.',
20,
],
]);
}

}
21 changes: 21 additions & 0 deletions tests/PHPStan/Rules/Methods/data/nullsafe-first-class-callable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php // lint >= 8.1

declare(strict_types = 1);

namespace MethodCallableNullsafe;

class Foo
{

public function doFoo(): int
{
return 1;
}

}

function test(?Foo $foo): void
{
// fatal error in PHP: "Cannot combine nullsafe operator with Closure creation"
$foo?->doFoo(...);
}
Loading