diff --git a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php index 02e197a28e..589ae6e3d7 100644 --- a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php +++ b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php @@ -115,6 +115,27 @@ public function revertNonNullability(MutatingScope $scope, array $specifiedExpre return $scope; } + /** + * Walk a nullsafe chain (NullsafePropertyFetch/NullsafeMethodCall) and narrow + * each intermediate var to non-null. Used so that method call arguments can see + * the narrowed types without affecting the scope during var processing. + */ + public function narrowNullsafeVarChain(MutatingScope $scope, Expr $expr): EnsuredNonNullabilityResult + { + $specifiedExpressions = []; + $currentExpr = $expr; + while ($currentExpr instanceof Expr\NullsafePropertyFetch || $currentExpr instanceof Expr\NullsafeMethodCall) { + $result = $this->ensureShallowNonNullability($scope, $scope, $currentExpr->var); + $scope = $result->getScope(); + foreach ($result->getSpecifiedExpressions() as $specifiedExpression) { + $specifiedExpressions[] = $specifiedExpression; + } + $currentExpr = $currentExpr->var; + } + + return new EnsuredNonNullabilityResult($scope, $specifiedExpressions); + } + /** * @param Closure(MutatingScope, Expr): MutatingScope $callback */ diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 56af96b2ed..bc5cf5f0df 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -14,6 +14,7 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; +use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; @@ -59,6 +60,7 @@ final class MethodCallHandler implements ExprHandler public function __construct( private DynamicThrowTypeExtensionProvider $dynamicThrowTypeExtensionProvider, private MethodCallReturnTypeHelper $methodCallReturnTypeHelper, + private NonNullabilityHelper $nonNullabilityHelper, #[AutowiredParameter(ref: '%exceptions.implicitThrows%')] private bool $implicitThrows, #[AutowiredParameter] @@ -139,6 +141,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $isAlwaysTerminating || ($returnType instanceof NeverType && $returnType->isExplicit()); } + // For virtual nullsafe method calls, narrow the vars in the nullsafe + // chain so arguments see non-null types. E.g. in $a?->b?->method($a), + // $a must be non-null when method() is reached. + $nullsafeNarrowingResult = null; + if ($expr->getAttribute('virtualNullsafeMethodCall') === true) { + $nullsafeNarrowingResult = $this->nonNullabilityHelper->narrowNullsafeVarChain($scope, $expr->var); + $scope = $nullsafeNarrowingResult->getScope(); + } + $argsResult = $nodeScopeResolver->processArgs( $stmt, $methodReflection, @@ -152,6 +163,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); $scope = $argsResult->getScope(); + if ($nullsafeNarrowingResult !== null) { + $scope = $this->nonNullabilityHelper->revertNonNullability($scope, $nullsafeNarrowingResult->getSpecifiedExpressions()); + } + if ($methodReflection !== null) { $methodThrowPoint = $this->getMethodThrowPoint($methodReflection, $parametersAcceptor, $expr, $scope); if ($methodThrowPoint !== null) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-6934.php b/tests/PHPStan/Analyser/nsrt/bug-6934.php new file mode 100644 index 0000000000..bbf2e023cf --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-6934.php @@ -0,0 +1,32 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug6934; + +use DOMNode; +use function PHPStan\Testing\assertType; + +function removeFromParent(?DOMNode $node): void { + $node?->parentNode?->removeChild($node); + $node?->removeChild($node); + + assertType('DOMNode|null', $node); + assertType('DOMNode|null', $node?->parentNode); +} + +function testNarrowing(?DOMNode $node): void { + $node?->parentNode?->removeChild(assertType('DOMNode', $node)); + $node?->removeChild(assertType('DOMNode', $node)); +} + +class Foo { + public function doSomething($mixed): string { + return 'hello'; + } +} + +function testNullsafeChainArgs(?Foo $foo): void { + $foo?->doSomething(assertType('Bug6934\Foo', $foo)); + assertType('Bug6934\Foo|null', $foo); +} diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 26ffbe0fc9..8c4428341d 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -3945,6 +3945,15 @@ public function testBug7369(): void ]); } + #[RequiresPhp('>= 8.0')] + public function testBug6934(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-6934.php'], []); + } + public function testBug11463(): void { $this->checkThisOnly = false;