diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 3f63434d03..6e9ee37c63 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -101,6 +101,7 @@ use PHPStan\Node\MethodCallableNode; use PHPStan\Node\MethodReturnStatementsNode; use PHPStan\Node\NoopExpressionNode; +use PHPStan\Node\NullsafeMethodCallOnFirstClassCallableNode; use PHPStan\Node\PropertyAssignNode; use PHPStan\Node\PropertyHookReturnStatementsNode; use PHPStan\Node\PropertyHookStatementNode; @@ -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 NullsafeMethodCallOnFirstClassCallableNode($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_) { diff --git a/src/Node/NullsafeMethodCallOnFirstClassCallableNode.php b/src/Node/NullsafeMethodCallOnFirstClassCallableNode.php new file mode 100644 index 0000000000..4b148aee6a --- /dev/null +++ b/src/Node/NullsafeMethodCallOnFirstClassCallableNode.php @@ -0,0 +1,61 @@ +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 NullsafeMethodCallOnFirstClassCallableNode 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 []; + } + +} diff --git a/src/Node/Printer/Printer.php b/src/Node/Printer/Printer.php index 7409140e8c..7c6c8dbcb8 100644 --- a/src/Node/Printer/Printer.php +++ b/src/Node/Printer/Printer.php @@ -29,6 +29,7 @@ use PHPStan\Node\InstantiationCallableNode; use PHPStan\Node\IssetExpr; use PHPStan\Node\MethodCallableNode; +use PHPStan\Node\NullsafeMethodCallOnFirstClassCallableNode; use PHPStan\Node\StaticMethodCallableNode; use PHPStan\Type\VerbosityLevel; use function sprintf; @@ -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(NullsafeMethodCallOnFirstClassCallableNode $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())); diff --git a/src/Rules/Methods/NullsafeMethodCallOnFirstClassCallableRule.php b/src/Rules/Methods/NullsafeMethodCallOnFirstClassCallableRule.php new file mode 100644 index 0000000000..e55f7bfd8f --- /dev/null +++ b/src/Rules/Methods/NullsafeMethodCallOnFirstClassCallableRule.php @@ -0,0 +1,34 @@ + + */ +#[RegisteredRule(level: 0)] +final class NullsafeMethodCallOnFirstClassCallableRule implements Rule +{ + + public function getNodeType(): string + { + return NullsafeMethodCallOnFirstClassCallableNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return [ + RuleErrorBuilder::message('Cannot combine nullsafe operator with Closure creation.') + ->nonIgnorable() + ->identifier('nullsafe.firstClassCallable') + ->build(), + ]; + } + +} diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index d6e9e7e38c..8a59d9f8d4 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -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 diff --git a/tests/PHPStan/Analyser/data/bug-9746.php b/tests/PHPStan/Analyser/data/bug-9746.php new file mode 100644 index 0000000000..febddd484d --- /dev/null +++ b/tests/PHPStan/Analyser/data/bug-9746.php @@ -0,0 +1,13 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug9746; + +class HelloWorld +{ + public function sayHello(?self $self): void + { + $self?->sayHello(...); + } +} diff --git a/tests/PHPStan/Rules/Methods/NullsafeMethodCallOnFirstClassCallableRuleTest.php b/tests/PHPStan/Rules/Methods/NullsafeMethodCallOnFirstClassCallableRuleTest.php new file mode 100644 index 0000000000..a92a58937c --- /dev/null +++ b/tests/PHPStan/Rules/Methods/NullsafeMethodCallOnFirstClassCallableRuleTest.php @@ -0,0 +1,39 @@ + + */ +class NullsafeMethodCallOnFirstClassCallableRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new NullsafeMethodCallOnFirstClassCallableRule(); + } + + #[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, + ], + [ + 'Cannot combine nullsafe operator with Closure creation.', + 26, + ], + [ + 'Cannot combine nullsafe operator with Closure creation.', + 34, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Methods/data/nullsafe-first-class-callable.php b/tests/PHPStan/Rules/Methods/data/nullsafe-first-class-callable.php new file mode 100644 index 0000000000..84438941cd --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/nullsafe-first-class-callable.php @@ -0,0 +1,36 @@ += 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(...); +} + +function testDynamic(?Foo $foo, string $method): void +{ + // dynamic method name - also a fatal error in PHP + $foo?->{$method}(...); +} + + +class HelloWorld +{ + public function sayHello(?self $self): void + { + $self?->sayHello(...); + } +}