Merge scope after ?-> method call to account for argument short-circuiting#5456
Merge scope after ?-> method call to account for argument short-circuiting#5456phpstan-bot wants to merge 3 commits intophpstan:2.1.xfrom
?-> method call to account for argument short-circuiting#5456Conversation
…cuiting - In NullsafeMethodCallHandler::processExpr(), merge the post-call scope with the pre-call scope when the var type is nullable, so variables assigned in arguments become "maybe defined" rather than "definitely defined" - When the var type is always null, use the pre-call scope directly since arguments are never evaluated - Add DefinedVariableRule regression tests for nullable, always-null, and multi-argument cases - Add NSRT type inference tests verifying correct types after nullsafe calls
| if ($varType->isNull()->yes()) { | ||
| // Arguments are never evaluated when the var is always null. | ||
| $scope = $scopeBeforeNullsafe; | ||
| } elseif (TypeCombinator::containsNull($varType)) { |
There was a problem hiding this comment.
You could use $varType->isNull()->maybe() instead.
And use a variable for $varType->isNull() to avoid computing it 2 times.
There was a problem hiding this comment.
Done. Here's what I changed to address VincentLanglet's review:
-
Cached
$varType->isNull()in a$varIsNullvariable to avoid computing it twice. -
Replaced
TypeCombinator::containsNull($varType)with$varIsNull->maybe()— a cleaner check that uses the already-computed result. -
Updated a throw-points test that was affected by the change:
doesntThrow()returnsmixed(which can be null), so?->may short-circuit. The variable certainty expectations for$fooand$barwere updated fromYestoMaybe, which is actually the correct behavior.
…ll() Address review feedback: cache $varType->isNull() in a variable to avoid computing it twice, and use ->maybe() instead of TypeCombinator::containsNull() for the nullable check. Update throw-points test: doesntThrow() returns mixed which can be null, so ?-> may short-circuit and variables assigned in the method name/arguments should be "maybe defined", not "yes defined". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| function () { | ||
| try { | ||
| doesntThrow()?->{$foo = 1}($bar = 2); | ||
| } finally { | ||
| assertVariableCertainty(TrinaryLogic::createYes(), $foo); | ||
| assertVariableCertainty(TrinaryLogic::createYes(), $bar); | ||
| // doesntThrow() returns mixed which can be null, so ?-> may short-circuit | ||
| assertVariableCertainty(TrinaryLogic::createMaybe(), $foo); | ||
| assertVariableCertainty(TrinaryLogic::createMaybe(), $bar); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Add the same test with a doesntThrow2 method which returns something mixed~null
There was a problem hiding this comment.
Done. I addressed VincentLanglet's review by adding a new test case in tests/PHPStan/Analyser/nsrt/throw-points/php8/null-safe-method-call.php that tests the mixed~null scenario:
- Calls
doesntThrow()(which returnsmixed), then narrows viaif ($result === null) returnto getmixed~null - Uses
$result?->{$foo = 1}($bar = 2)— since$resultismixed~null, the?->can never short-circuit - Asserts that both
$fooand$barhaveTrinaryLogic::createYes()certainty (always defined)
All tests pass (11856 tests, 79364 assertions) and make phpstan reports no errors.
Add a companion test case where the left operand of ?-> is mixed~null (obtained via null-narrowing). Since the value can never be null, ?-> never short-circuits and variables assigned in arguments have Yes certainty. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| }; | ||
|
|
||
| function () { | ||
| $result = doesntThrow(); |
There was a problem hiding this comment.
The call need to be chaine inside the try catch
try {
doesntThrow2()?->{$foo = 1}($bar = 2);
} finally {
assertVariableCertainty(TrinaryLogic::createYes(), $foo);
assertVariableCertainty(TrinaryLogic::createYes(), $bar);
}
If mixed~null is complex to indicate in phpdoc, then maybe use object as return type of doesntThrow2
Summary
PHP's nullsafe operator
?->short-circuits evaluation when the left operand is null, meaning method arguments are not evaluated. PHPStan was not accounting for this: variables assigned within nullsafe method call arguments were treated as "definitely defined" even though they might not be evaluated at all.Changes
src/Analyser/ExprHandler/NullsafeMethodCallHandler.php:Root cause
NullsafeMethodCallHandler::processExpr()converts$date?->format($format = "Y-m-d")into a regularMethodCalland processes it unconditionally. This means$formatgets added to the scope as "definitely defined". But in PHP, when$dateis null, the entire method call including argument evaluation is short-circuited —$formatis never assigned.The fix mirrors how
CoalesceHandlerandBooleanAndHandleralready handle conditional evaluation: by merging the scope from the "executed" path with the scope from the "not executed" path, making any newly-introduced variables "maybe defined".Analogous cases probed:
NullsafePropertyFetchHandler: Property names are almost alwaysIdentifiernodes (not expressions), so there are no side effects to short-circuit. The rare case of$obj?->$namewhere$namehas side effects is theoretical — skipped.$a?->b()?->c($x = 1)): Each?->is a separateNullsafeMethodCallnode, so the fix applies independently at each level — works correctly.Test
tests/PHPStan/Rules/Variables/data/bug-10729.php— DefinedVariableRule test covering:$formatreported as "might not be defined" (the reported bug)$formatreported as "Undefined variable" (arguments never evaluated)$formatand$valuereported as "might not be defined"$result = $date?->format($format = ...)with$formatcorrectly "might not be defined"tests/PHPStan/Analyser/nsrt/bug-10729.php— Type inference test verifying correct types for variables assigned in nullsafe argumentsFixes phpstan/phpstan#10729