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
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\NullsafeMethodCallOnFirstClassCallableNode;
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 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_) {
Expand Down
61 changes: 61 additions & 0 deletions src/Node/NullsafeMethodCallOnFirstClassCallableNode.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 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 [];
}

}
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\NullsafeMethodCallOnFirstClassCallableNode;
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(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()));
Expand Down
34 changes: 34 additions & 0 deletions src/Rules/Methods/NullsafeMethodCallOnFirstClassCallableRule.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\NullsafeMethodCallOnFirstClassCallableNode;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
* @implements Rule<NullsafeMethodCallOnFirstClassCallableNode>
*/
#[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(),
];
}

}
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(...);
Comment thread
staabm marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Methods;

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

/**
* @extends RuleTestCase<NullsafeMethodCallOnFirstClassCallableRule>
*/
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,
],
]);
}

}
36 changes: 36 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,36 @@
<?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(...);
}

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(...);
Comment thread
staabm marked this conversation as resolved.
}
}
Loading