diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 13c0577f859..6dc0ddad48b 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -60,6 +60,9 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $scopeBeforeNullsafe = $scope; + $varType = $scope->getType($expr->var); + $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($scope, $scope, $expr->var); $attributes = array_merge($expr->getAttributes(), ['virtualNullsafeMethodCall' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); @@ -78,6 +81,16 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); $scope = $this->nonNullabilityHelper->revertNonNullability($exprResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); + $varIsNull = $varType->isNull(); + if ($varIsNull->yes()) { + // Arguments are never evaluated when the var is always null. + $scope = $scopeBeforeNullsafe; + } elseif ($varIsNull->maybe()) { + // Arguments might not be evaluated (short-circuit). + // Merge with the original scope so variables assigned in arguments become "maybe defined". + $scope = $scope->mergeWith($scopeBeforeNullsafe); + } + return new ExpressionResult( $scope, hasYield: $exprResult->hasYield(), diff --git a/tests/PHPStan/Analyser/nsrt/bug-10729.php b/tests/PHPStan/Analyser/nsrt/bug-10729.php new file mode 100644 index 00000000000..66be14727e2 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10729.php @@ -0,0 +1,44 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug10729Types; + +use function PHPStan\Testing\assertType; + +class Foo +{ + public function bar(string $a, string $b): string + { + return $a . $b; + } +} + +function nullable(?Foo $foo): void +{ + $foo?->bar($a = 'hello', $b = 'world'); + assertType("'hello'|null", $a ?? null); + assertType("'world'|null", $b ?? null); +} + +function nonNullable(Foo $foo): void +{ + $foo->bar($a = 'hello', $b = 'world'); + assertType("'hello'", $a); + assertType("'world'", $b); +} + +function alwaysNull(): void +{ + $foo = null; + $foo?->bar($a = 'hello', $b = 'world'); + assertType('null', $a ?? null); // $a is never assigned when $foo is always null +} + +function chainedNullsafe(?Foo $foo): void +{ + $result = $foo?->bar($x = 'a', $y = 'b'); + assertType('string|null', $result); + assertType("'a'|null", $x ?? null); + assertType("'b'|null", $y ?? null); +} diff --git a/tests/PHPStan/Analyser/nsrt/throw-points/php8/null-safe-method-call.php b/tests/PHPStan/Analyser/nsrt/throw-points/php8/null-safe-method-call.php index 7be88b90903..666bbfcef90 100644 --- a/tests/PHPStan/Analyser/nsrt/throw-points/php8/null-safe-method-call.php +++ b/tests/PHPStan/Analyser/nsrt/throw-points/php8/null-safe-method-call.php @@ -41,6 +41,21 @@ function () { function () { try { doesntThrow()?->{$foo = 1}($bar = 2); + } finally { + // doesntThrow() returns mixed which can be null, so ?-> may short-circuit + assertVariableCertainty(TrinaryLogic::createMaybe(), $foo); + assertVariableCertainty(TrinaryLogic::createMaybe(), $bar); + } +}; + +function () { + $result = doesntThrow(); + if ($result === null) { + return; + } + // $result is mixed~null, so ?-> never short-circuits + try { + $result?->{$foo = 1}($bar = 2); } finally { assertVariableCertainty(TrinaryLogic::createYes(), $foo); assertVariableCertainty(TrinaryLogic::createYes(), $bar); diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index 207070c7627..db45cf0dbb1 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1517,4 +1517,35 @@ public function testBug11218(): void $this->analyse([__DIR__ . '/data/bug-11218.php'], []); } + #[RequiresPhp('>= 8.0')] + public function testBug10729(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = false; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + $this->analyse([__DIR__ . '/data/bug-10729.php'], [ + [ + 'Variable $format might not be defined.', + 12, + ], + [ + 'Undefined variable: $format', + 25, + ], + [ + 'Variable $format might not be defined.', + 31, + ], + [ + 'Variable $value might not be defined.', + 32, + ], + [ + 'Variable $format might not be defined.', + 38, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/bug-10729.php b/tests/PHPStan/Rules/Variables/data/bug-10729.php new file mode 100644 index 00000000000..e570ac3694a --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-10729.php @@ -0,0 +1,47 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug10729; + +class HelloWorld +{ + public function sayHello(?\DateTimeImmutable $date): void + { + var_dump($date?->format($format = "Y-m-d")); + var_dump($format); // might not be defined if $date is null + } + + public function nonNullable(\DateTimeImmutable $date): void + { + var_dump($date->format($format = "Y-m-d")); + var_dump($format); // always defined, $date can't be null + } + + public function nullOnly(): void + { + $date = null; + var_dump($date?->format($format = "Y-m-d")); + var_dump($format); // undefined, $date is always null + } + + public function multipleArgs(?\DateTimeImmutable $date): void + { + $date?->createFromFormat($format = 'Y-m-d', $value = '2024-01-01'); + var_dump($format); // might not be defined + var_dump($value); // might not be defined + } + + public function nestedAssignment(?\DateTimeImmutable $date): void + { + $result = $date?->format($format = "Y-m-d"); + var_dump($format); // might not be defined + } + + public function existingVarStillDefined(?\DateTimeImmutable $date): void + { + $existing = 'before'; + $date?->format($format = "Y-m-d"); + var_dump($existing); // always defined, not affected by nullsafe + } +}