Treat first-class callable NullsafeMethodCall as a MethodCall instead of throwing#5888
Treat first-class callable NullsafeMethodCall as a MethodCall instead of throwing#5888phpstan-bot wants to merge 7 commits into
NullsafeMethodCall as a MethodCall instead of throwing#5888Conversation
…tead of throwing
- `NodeScopeResolver::processExprNode()` handled the first-class callable
syntax (`foo(...)`) for `FuncCall`, `MethodCall`, `StaticCall` and `New_`,
but `NullsafeMethodCall` (`$foo?->bar(...)`) fell through to the `else`
branch and threw `ShouldNotHappenException`, surfacing as an internal error.
- `NullsafeMethodCall` is the only `Expr\CallLike` subclass that was missing
from this branch. It is added by wrapping the call in a `MethodCallableNode`
built from an equivalent `MethodCall`, since `$foo?->bar(...)` is a fatal
error in PHP ("Cannot combine nullsafe operator with Closure creation") and
must not crash the analyser.
- Type resolution already routed correctly through `NullsafeMethodCallHandler`,
so no internal error occurs anywhere the expression is typed (assignment,
argument, array element, return).
staabm
left a comment
There was a problem hiding this comment.
Crash bug fixes need a AnalyserIntegrationTest
…ll crash Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Done. I addressed staabm's review by adding the requested What I did
Verification
Committed as |
| { | ||
| public function sayHello(?self $self): void | ||
| { | ||
| $self?->sayHello(...); |
There was a problem hiding this comment.
we expect a Cannot combine nullsafe operator with Closure creation error on this line
There was a problem hiding this comment.
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
-
src/Node/MethodCallableNode.php— added anullsafeflag (defaultfalse) plus anisNullsafe()accessor, so the node can record that it originated from a$foo?->bar(...)expression. -
src/Analyser/NodeScopeResolver.php— theNullsafeMethodCallfirst-class-callable branch now passesnullsafe: truewhen building theMethodCallableNode. -
src/Rules/Methods/MethodCallableRule.php— when the node is nullsafe, it reports a non-ignorable errorCannot combine nullsafe operator with Closure creation.(identifiernullsafe.firstClassCallable) and skips the normal method-existence check. -
Tests:
- New
tests/PHPStan/Rules/Methods/data/method-callable-nullsafe.php+testNullsafe()inMethodCallableRuleTest— direct rule-level coverage of the new error. - Fixed
testBug9746()inAnalyserIntegrationTest, which was inconsistent with its data file (it referenced aBug9746Integrationnamespace/file that no longer existed). It now asserts the actual two errors, including the nullsafe one.
- New
Verification
testNullsafefails without the source change (it instead reportsCannot 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.
…irst-class callable NullsafeMethodCall $foo?->bar(...) is a fatal error in PHP, but PHPStan only silently treated it as a regular method-call first-class callable. Carry a nullsafe flag on MethodCallableNode and report the non-ignorable error from MethodCallableRule. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| private Expr $var, | ||
| private Identifier|Expr $name, | ||
| private Expr\MethodCall $originalNode, | ||
| private bool $nullsafe = false, |
There was a problem hiding this comment.
instead of adding a separate $nullsafe bool , can we instead carry this information in $originalNode with a instanceof NullsafeMethodCall check?
There was a problem hiding this comment.
Done. I addressed staabh's review by removing the separate $nullsafe bool and carrying the information in $originalNode itself.
Changes
-
src/Node/MethodCallableNode.php— Dropped theprivate bool $nullsafeconstructor parameter. The$originalNodefield is now typedExpr\MethodCall|Expr\NullsafeMethodCall, andisNullsafe()derives the flag via$this->originalNode instanceof Expr\NullsafeMethodCall.getOriginalNode()'s return type widened accordingly. -
src/Analyser/NodeScopeResolver.php— TheNullsafeMethodCallfirst-class-callable branch now passes the originalNullsafeMethodCallstraight intoMethodCallableNodeinstead of building a syntheticMethodCalland atrueflag. -
src/Node/ClassStatementsGatherer.php— The one consumer that needs a concreteMethodCall(feeding the@apiMethod\MethodCallcollector, which doesn't acceptNullsafeMethodCall) now converts the nullsafe node locally. This keeps the@apiclass untouched. The only othergetOriginalNode()consumer (Printer) handlesNullsafeMethodCallnatively.
Verification
testNullsafe(rule-level) andtestBug9746(integration crash regression) both pass.UnusedPrivateMethodRuleTestandCallToMethodStatementWithoutSideEffectsRuleTestpass (other consumers of the gathered method calls).make phpstan: No errors.
This is a behavior-preserving refactor — the fix itself was already committed in d31bdac35; this only changes how the nullsafe flag is represented. Committed as acc872106 and pushed.
…inal node Instead of a separate `$nullsafe` constructor flag, store the original `NullsafeMethodCall` in `MethodCallableNode::$originalNode` and derive `isNullsafe()` from an `instanceof` check. ClassStatementsGatherer converts the nullsafe node into a regular `MethodCall` when collecting it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Using the first-class callable syntax together with the nullsafe operator
(
$foo?->bar(...)) crashed PHPStan withInternal error.. While this is afatal error in PHP itself ("Cannot combine nullsafe operator with Closure
creation"), PHPStan must not crash on it. This change makes the analyser treat
such an expression as a regular method-call first-class callable.
Changes
src/Analyser/NodeScopeResolver.php: in the first-class callable branch ofprocessExprNode(), handleExpr\NullsafeMethodCallby building aMethodCallableNodefrom an equivalentMethodCall, instead of fallingthrough to the
throw new ShouldNotHappenException().tests/PHPStan/Analyser/nsrt/bug-9746.php: regression test asserting theinferred types and (implicitly) that no internal error is thrown.
Root cause
processExprNode()enumerated theExpr\CallLikesubclasses that support thefirst-class callable syntax —
FuncCall,MethodCall,StaticCall,New_—and threw
ShouldNotHappenExceptionfor anything else.NullsafeMethodCallisalso an
Expr\CallLikewhoseisFirstClassCallable()can betrue(the parseraccepts
$foo?->bar(...)even though PHP rejects it at runtime), so it hit theelsebranch and crashed. It was the onlyExpr\CallLikesubclass not covered.Test
tests/PHPStan/Analyser/nsrt/bug-9746.phpreproduces the original crash via$foo?->doFoo(...)(nullable and non-nullable receivers, plus an assignment)and asserts
(Closure(): int)|null/Closure(): int. It errors with aninternal error before the fix and passes after.
(statement, assignment, call argument, array element, return).
Analogous cases probed
Expr\CallLikesubclasses (FuncCall,MethodCall,StaticCall,New_) were already handled;NullsafeMethodCallwas the only missing one.
MutatingScope::getKeepVoidType()already listsNullsafeMethodCallin itsfirst-class-callable guard, and type resolution flows through
NullsafeMethodCallHandler, so no internal error occurs when the expressionis typed.
Fixes phpstan/phpstan#9746