Skip to content

Commit d2288b9

Browse files
phpstan-botclaude
andcommitted
Introduce NullsafeFirstClassCallableNode for $foo?->bar(...)
Represent the nullsafe first-class callable in its own virtual node and report "Cannot combine nullsafe operator with Closure creation." from a dedicated NullsafeFirstClassCallableRule, instead of overloading MethodCallableNode/MethodCallableRule with a nullsafe branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b3ee78e commit d2288b9

10 files changed

Lines changed: 185 additions & 21 deletions
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Analyser\ExprHandler\Virtual;
4+
5+
use PhpParser\Node\Expr;
6+
use PhpParser\Node\Stmt;
7+
use PHPStan\Analyser\ExpressionContext;
8+
use PHPStan\Analyser\ExpressionResult;
9+
use PHPStan\Analyser\ExpressionResultStorage;
10+
use PHPStan\Analyser\ExprHandler;
11+
use PHPStan\Analyser\MutatingScope;
12+
use PHPStan\Analyser\NodeScopeResolver;
13+
use PHPStan\Analyser\Scope;
14+
use PHPStan\Analyser\SpecifiedTypes;
15+
use PHPStan\Analyser\TypeSpecifier;
16+
use PHPStan\Analyser\TypeSpecifierContext;
17+
use PHPStan\DependencyInjection\AutowiredService;
18+
use PHPStan\Node\NullsafeFirstClassCallableNode;
19+
use PHPStan\Type\MixedType;
20+
use PHPStan\Type\Type;
21+
use function array_merge;
22+
23+
/**
24+
* @implements ExprHandler<NullsafeFirstClassCallableNode>
25+
*/
26+
#[AutowiredService]
27+
final class NullsafeFirstClassCallableNodeHandler implements ExprHandler
28+
{
29+
30+
public function supports(Expr $expr): bool
31+
{
32+
return $expr instanceof NullsafeFirstClassCallableNode;
33+
}
34+
35+
public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult
36+
{
37+
$varResult = $nodeScopeResolver->processExprNode($stmt, $expr->getVar(), $scope, $storage, $nodeCallback, ExpressionContext::createDeep());
38+
$scope = $varResult->getScope();
39+
$hasYield = $varResult->hasYield();
40+
$throwPoints = $varResult->getThrowPoints();
41+
$impurePoints = $varResult->getImpurePoints();
42+
$isAlwaysTerminating = false;
43+
if ($expr->getName() instanceof Expr) {
44+
$nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->getName(), $scope, $storage, $nodeCallback, ExpressionContext::createDeep());
45+
$scope = $nameResult->getScope();
46+
$hasYield = $hasYield || $nameResult->hasYield();
47+
$throwPoints = array_merge($throwPoints, $nameResult->getThrowPoints());
48+
$impurePoints = array_merge($impurePoints, $nameResult->getImpurePoints());
49+
$isAlwaysTerminating = $nameResult->isAlwaysTerminating();
50+
}
51+
52+
return new ExpressionResult(
53+
$scope,
54+
hasYield: $hasYield,
55+
isAlwaysTerminating: $isAlwaysTerminating,
56+
throwPoints: $throwPoints,
57+
impurePoints: $impurePoints,
58+
);
59+
}
60+
61+
public function resolveType(MutatingScope $scope, Expr $expr): Type
62+
{
63+
// in practice the type of the first-class callable is resolved
64+
// by NullsafeMethodCallHandler / FirstClassCallableMethodCallHandler
65+
return new MixedType();
66+
}
67+
68+
public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes
69+
{
70+
return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context);
71+
}
72+
73+
}

src/Analyser/NodeScopeResolver.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
use PHPStan\Node\MethodCallableNode;
102102
use PHPStan\Node\MethodReturnStatementsNode;
103103
use PHPStan\Node\NoopExpressionNode;
104+
use PHPStan\Node\NullsafeFirstClassCallableNode;
104105
use PHPStan\Node\PropertyAssignNode;
105106
use PHPStan\Node\PropertyHookReturnStatementsNode;
106107
use PHPStan\Node\PropertyHookStatementNode;
@@ -2758,9 +2759,8 @@ public function processExprNode(
27582759
} elseif ($expr instanceof Expr\NullsafeMethodCall) {
27592760
// $foo?->bar(...) is a fatal error in PHP ("Cannot combine nullsafe
27602761
// operator with Closure creation"), but it must not crash the analyser.
2761-
// It is treated as a regular method-call first-class callable and the
2762-
// error is reported by MethodCallableRule via MethodCallableNode::isNullsafe().
2763-
$newExpr = new MethodCallableNode($expr->var, $expr->name, $expr);
2762+
// The error is reported by NullsafeFirstClassCallableRule.
2763+
$newExpr = new NullsafeFirstClassCallableNode($expr->var, $expr->name, $expr);
27642764
} elseif ($expr instanceof StaticCall) {
27652765
$newExpr = new StaticMethodCallableNode($expr->class, $expr->name, $expr);
27662766
} elseif ($expr instanceof New_ && !$expr->class instanceof Class_) {

src/Dependency/DependencyResolver.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use PHPStan\Node\InPropertyHookNode;
2222
use PHPStan\Node\InstantiationCallableNode;
2323
use PHPStan\Node\MethodCallableNode;
24+
use PHPStan\Node\NullsafeFirstClassCallableNode;
2425
use PHPStan\Node\StaticMethodCallableNode;
2526
use PHPStan\Reflection\ClassReflection;
2627
use PHPStan\Reflection\ExtendedParameterReflection;
@@ -486,6 +487,10 @@ public function resolveDependencies(Node $node, Scope $scope): NodeDependencies
486487
foreach ($this->resolveDependencies(new Node\Expr\MethodCall($node->getVar(), $node->getName()), $scope)->getReflections() as $dependency) {
487488
$dependenciesReflections[] = $dependency;
488489
}
490+
} elseif ($node instanceof NullsafeFirstClassCallableNode) {
491+
foreach ($this->resolveDependencies(new Node\Expr\NullsafeMethodCall($node->getVar(), $node->getName()), $scope)->getReflections() as $dependency) {
492+
$dependenciesReflections[] = $dependency;
493+
}
489494
} elseif ($node instanceof FunctionCallableNode) {
490495
foreach ($this->resolveDependencies(new Node\Expr\FuncCall($node->getName()), $scope)->getReflections() as $dependency) {
491496
$dependenciesReflections[] = $dependency;

src/Node/ClassStatementsGatherer.php

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -180,13 +180,7 @@ private function gatherNodes(Node $node, Scope $scope): void
180180
return;
181181
}
182182
if ($node instanceof MethodCallableNode || $node instanceof StaticMethodCallableNode) {
183-
$originalNode = $node->getOriginalNode();
184-
if ($originalNode instanceof Expr\NullsafeMethodCall) {
185-
// $foo?->bar(...) is a fatal error in PHP, but it must not crash the
186-
// analyser; collect it as a regular method call.
187-
$originalNode = new MethodCall($originalNode->var, $originalNode->name, $originalNode->args, $originalNode->getAttributes());
188-
}
189-
$this->methodCalls[] = new \PHPStan\Node\Method\MethodCall($originalNode, $scope);
183+
$this->methodCalls[] = new \PHPStan\Node\Method\MethodCall($node->getOriginalNode(), $scope);
190184
return;
191185
}
192186
if ($node instanceof MethodReturnStatementsNode) {

src/Node/MethodCallableNode.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ final class MethodCallableNode extends Expr implements VirtualNode
1515
public function __construct(
1616
private Expr $var,
1717
private Identifier|Expr $name,
18-
private Expr\MethodCall|Expr\NullsafeMethodCall $originalNode,
18+
private Expr\MethodCall $originalNode,
1919
)
2020
{
2121
parent::__construct($originalNode->getAttributes());
@@ -34,7 +34,7 @@ public function getName()
3434
return $this->name;
3535
}
3636

37-
public function getOriginalNode(): Expr\MethodCall|Expr\NullsafeMethodCall
37+
public function getOriginalNode(): Expr\MethodCall
3838
{
3939
return $this->originalNode;
4040
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Node;
4+
5+
use Override;
6+
use PhpParser\Node\Expr;
7+
use PhpParser\Node\Identifier;
8+
9+
/**
10+
* Represents `$foo?->bar(...)` - combining the nullsafe operator with the
11+
* first-class callable syntax. This is a fatal error in PHP ("Cannot combine
12+
* nullsafe operator with Closure creation"), reported by NullsafeFirstClassCallableRule.
13+
*
14+
* @api
15+
*/
16+
final class NullsafeFirstClassCallableNode extends Expr implements VirtualNode
17+
{
18+
19+
public function __construct(
20+
private Expr $var,
21+
private Identifier|Expr $name,
22+
private Expr\NullsafeMethodCall $originalNode,
23+
)
24+
{
25+
parent::__construct($originalNode->getAttributes());
26+
}
27+
28+
public function getVar(): Expr
29+
{
30+
return $this->var;
31+
}
32+
33+
/**
34+
* @return Expr|Identifier
35+
*/
36+
public function getName()
37+
{
38+
return $this->name;
39+
}
40+
41+
public function getOriginalNode(): Expr\NullsafeMethodCall
42+
{
43+
return $this->originalNode;
44+
}
45+
46+
#[Override]
47+
public function getType(): string
48+
{
49+
return 'PHPStan_Node_NullsafeFirstClassCallableNode';
50+
}
51+
52+
/**
53+
* @return string[]
54+
*/
55+
#[Override]
56+
public function getSubNodeNames(): array
57+
{
58+
return [];
59+
}
60+
61+
}

src/Node/Printer/Printer.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
use PHPStan\Node\InstantiationCallableNode;
3030
use PHPStan\Node\IssetExpr;
3131
use PHPStan\Node\MethodCallableNode;
32+
use PHPStan\Node\NullsafeFirstClassCallableNode;
3233
use PHPStan\Node\StaticMethodCallableNode;
3334
use PHPStan\Type\VerbosityLevel;
3435
use function sprintf;
@@ -160,6 +161,11 @@ protected function pPHPStan_Node_MethodCallableNode(MethodCallableNode $expr): s
160161
return sprintf('__phpstanMethodCallable(%s)', $this->p($expr->getOriginalNode()));
161162
}
162163

164+
protected function pPHPStan_Node_NullsafeFirstClassCallableNode(NullsafeFirstClassCallableNode $expr): string // phpcs:ignore
165+
{
166+
return sprintf('__phpstanNullsafeFirstClassCallable(%s)', $this->p($expr->getOriginalNode()));
167+
}
168+
163169
protected function pPHPStan_Node_StaticMethodCallableNode(StaticMethodCallableNode $expr): string // phpcs:ignore
164170
{
165171
return sprintf('__phpstanStaticMethodCallable(%s)', $this->p($expr->getOriginalNode()));

src/Rules/Methods/MethodCallableRule.php

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,6 @@ public function processNode(Node $node, Scope $scope): array
3737
];
3838
}
3939

40-
if ($node->getOriginalNode() instanceof Node\Expr\NullsafeMethodCall) {
41-
return [
42-
RuleErrorBuilder::message('Cannot combine nullsafe operator with Closure creation.')
43-
->nonIgnorable()
44-
->identifier('nullsafe.firstClassCallable')
45-
->build(),
46-
];
47-
}
48-
4940
$methodName = $node->getName();
5041
if (!$methodName instanceof Node\Identifier) {
5142
return [];
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Methods;
4+
5+
use PhpParser\Node;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\DependencyInjection\RegisteredRule;
8+
use PHPStan\Node\NullsafeFirstClassCallableNode;
9+
use PHPStan\Rules\Rule;
10+
use PHPStan\Rules\RuleErrorBuilder;
11+
12+
/**
13+
* @implements Rule<NullsafeFirstClassCallableNode>
14+
*/
15+
#[RegisteredRule(level: 0)]
16+
final class NullsafeFirstClassCallableRule implements Rule
17+
{
18+
19+
public function getNodeType(): string
20+
{
21+
return NullsafeFirstClassCallableNode::class;
22+
}
23+
24+
public function processNode(Node $node, Scope $scope): array
25+
{
26+
return [
27+
RuleErrorBuilder::message('Cannot combine nullsafe operator with Closure creation.')
28+
->nonIgnorable()
29+
->identifier('nullsafe.firstClassCallable')
30+
->build(),
31+
];
32+
}
33+
34+
}

tests/PHPStan/Rules/Methods/data/method-callable-nullsafe.php renamed to tests/PHPStan/Rules/Methods/data/nullsafe-first-class-callable.php

File renamed without changes.

0 commit comments

Comments
 (0)