From 6e42bfe62d7b72b526007f4ec5b13c637327398e Mon Sep 17 00:00:00 2001 From: Richard van Velzen Date: Wed, 24 Jun 2026 09:48:21 +0200 Subject: [PATCH] Support treatPhpDocTypesAsCertain for null-safe methods/properties --- src/Rules/Methods/NullsafeMethodCallRule.php | 41 +++++++++++++++---- .../Properties/NullsafePropertyFetchRule.php | 39 ++++++++++++++---- .../Methods/NullsafeMethodCallRuleTest.php | 29 ++++++++++++- .../NullsafePropertyFetchRuleTest.php | 33 ++++++++++++++- 4 files changed, 125 insertions(+), 17 deletions(-) diff --git a/src/Rules/Methods/NullsafeMethodCallRule.php b/src/Rules/Methods/NullsafeMethodCallRule.php index 12ac117ce0d..a3d3b872f73 100644 --- a/src/Rules/Methods/NullsafeMethodCallRule.php +++ b/src/Rules/Methods/NullsafeMethodCallRule.php @@ -4,6 +4,7 @@ use PhpParser\Node; use PHPStan\Analyser\Scope; +use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; @@ -17,6 +18,15 @@ final class NullsafeMethodCallRule implements Rule { + public function __construct( + #[AutowiredParameter] + private bool $treatPhpDocTypesAsCertain, + #[AutowiredParameter(ref: '%tips.treatPhpDocTypesAsCertain%')] + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + public function getNodeType(): string { return Node\Expr\NullsafeMethodCall::class; @@ -24,17 +34,34 @@ public function getNodeType(): string public function processNode(Node $node, Scope $scope): array { - $calledOnType = $scope->getScopeType($node->var); + $calledOnType = $this->treatPhpDocTypesAsCertain ? $scope->getScopeType($node->var) : $scope->getScopeNativeType($node->var); if (!$calledOnType->isNull()->no()) { return []; } - return [ - RuleErrorBuilder::message(sprintf('Using nullsafe method call on non-nullable type %s. Use -> instead.', $calledOnType->describe(VerbosityLevel::typeOnly()))) - ->line($node->name->getStartLine()) - ->identifier('nullsafe.neverNull') - ->build(), - ]; + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain || !$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + $calledOnNativeType = $scope->getScopeNativeType($node->var); + if ($calledOnNativeType->isNull()->no()) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + $ruleErrorBuilder = $addTip( + RuleErrorBuilder::message(sprintf( + 'Using nullsafe method call on non-nullable type %s. Use -> instead.', + $calledOnType->describe(VerbosityLevel::typeOnly()), + )), + ) + ->line($node->name->getStartLine()) + ->identifier('nullsafe.neverNull'); + + return [$ruleErrorBuilder->build()]; } } diff --git a/src/Rules/Properties/NullsafePropertyFetchRule.php b/src/Rules/Properties/NullsafePropertyFetchRule.php index c1d22337364..1e11e03f8a8 100644 --- a/src/Rules/Properties/NullsafePropertyFetchRule.php +++ b/src/Rules/Properties/NullsafePropertyFetchRule.php @@ -4,6 +4,7 @@ use PhpParser\Node; use PHPStan\Analyser\Scope; +use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; @@ -17,7 +18,12 @@ final class NullsafePropertyFetchRule implements Rule { - public function __construct() + public function __construct( + #[AutowiredParameter] + private bool $treatPhpDocTypesAsCertain, + #[AutowiredParameter(ref: '%tips.treatPhpDocTypesAsCertain%')] + private bool $treatPhpDocTypesAsCertainTip, + ) { } @@ -28,7 +34,7 @@ public function getNodeType(): string public function processNode(Node $node, Scope $scope): array { - $calledOnType = $scope->getScopeType($node->var); + $calledOnType = $this->treatPhpDocTypesAsCertain ? $scope->getScopeType($node->var) : $scope->getScopeNativeType($node->var); if (!$calledOnType->isNull()->no()) { return []; } @@ -37,12 +43,29 @@ public function processNode(Node $node, Scope $scope): array return []; } - return [ - RuleErrorBuilder::message(sprintf('Using nullsafe property access on non-nullable type %s. Use -> instead.', $calledOnType->describe(VerbosityLevel::typeOnly()))) - ->line($node->name->getStartLine()) - ->identifier('nullsafe.neverNull') - ->build(), - ]; + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { + if (!$this->treatPhpDocTypesAsCertain || !$this->treatPhpDocTypesAsCertainTip) { + return $ruleErrorBuilder; + } + + $calledOnNativeType = $scope->getScopeNativeType($node->var); + if ($calledOnNativeType->isNull()->no()) { + return $ruleErrorBuilder; + } + + return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + }; + + $ruleErrorBuilder = $addTip( + RuleErrorBuilder::message(sprintf( + 'Using nullsafe property access on non-nullable type %s. Use -> instead.', + $calledOnType->describe(VerbosityLevel::typeOnly()), + )), + ) + ->line($node->name->getStartLine()) + ->identifier('nullsafe.neverNull'); + + return [$ruleErrorBuilder->build()]; } } diff --git a/tests/PHPStan/Rules/Methods/NullsafeMethodCallRuleTest.php b/tests/PHPStan/Rules/Methods/NullsafeMethodCallRuleTest.php index 0d1bc3e1b6e..32b49882fe2 100644 --- a/tests/PHPStan/Rules/Methods/NullsafeMethodCallRuleTest.php +++ b/tests/PHPStan/Rules/Methods/NullsafeMethodCallRuleTest.php @@ -12,13 +12,24 @@ class NullsafeMethodCallRuleTest extends RuleTestCase { + private bool $treatPhpDocTypesAsCertain; + protected function getRule(): Rule { - return new NullsafeMethodCallRule(); + return new NullsafeMethodCallRule( + $this->treatPhpDocTypesAsCertain, + true, + ); + } + + protected function shouldTreatPhpDocTypesAsCertain(): bool + { + return $this->treatPhpDocTypesAsCertain; } public function testRule(): void { + $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/nullsafe-method-call-rule.php'], [ [ 'Using nullsafe method call on non-nullable type Exception. Use -> instead.', @@ -29,54 +40,70 @@ public function testRule(): void public function testNullsafeVsScalar(): void { + $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/../../Analyser/nsrt/nullsafe-vs-scalar.php'], []); } public function testBug8664(): void { + $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/../../Analyser/data/bug-8664.php'], []); } public function testBug14150(): void { + $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/bug-14150-nullsafe.php'], [ [ 'Using nullsafe method call on non-nullable type $this(Bug14150NullsafeMethod\HelloWorld). Use -> instead.', 21, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', ], ]); } + public function testBug14150WithoutCertainPhpDocTypes(): void + { + $this->treatPhpDocTypesAsCertain = false; + $this->analyse([__DIR__ . '/data/bug-14150-nullsafe.php'], []); + } + #[RequiresPhp('>= 8.0.0')] public function testBug9293(): void { + $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-9293.php'], []); } #[RequiresPhp('>= 8.0.0')] public function testBug6922b(): void { + $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/bug-6922b.php'], []); } public function testBug8523(): void { + $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/bug-8523.php'], []); } public function testBug8523b(): void { + $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/bug-8523b.php'], []); } public function testBug8523c(): void { + $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/bug-8523c.php'], []); } #[RequiresPhp('>= 8.1.0')] public function testBug12222(): void { + $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/bug-12222.php'], []); } diff --git a/tests/PHPStan/Rules/Properties/NullsafePropertyFetchRuleTest.php b/tests/PHPStan/Rules/Properties/NullsafePropertyFetchRuleTest.php index e4c37b08ca0..4d0fa6e3f2e 100644 --- a/tests/PHPStan/Rules/Properties/NullsafePropertyFetchRuleTest.php +++ b/tests/PHPStan/Rules/Properties/NullsafePropertyFetchRuleTest.php @@ -12,13 +12,24 @@ class NullsafePropertyFetchRuleTest extends RuleTestCase { + private bool $treatPhpDocTypesAsCertain; + protected function getRule(): Rule { - return new NullsafePropertyFetchRule(); + return new NullsafePropertyFetchRule( + $this->treatPhpDocTypesAsCertain, + true, + ); + } + + protected function shouldTreatPhpDocTypesAsCertain(): bool + { + return $this->treatPhpDocTypesAsCertain; } public function testRule(): void { + $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/nullsafe-property-fetch-rule.php'], [ [ 'Using nullsafe property access on non-nullable type Exception. Use -> instead.', @@ -29,34 +40,40 @@ public function testRule(): void public function testBug6020(): void { + $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/bug-6020.php'], []); } #[RequiresPhp('>= 8.0.0')] public function testBug7109(): void { + $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/bug-7109.php'], []); } #[RequiresPhp('>= 8.0.0')] public function testBug5172(): void { + $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-5172.php'], []); } #[RequiresPhp('>= 8.1.0')] public function testBug7980(): void { + $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/../../Analyser/data/bug-7980.php'], []); } public function testBug8517(): void { + $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-8517.php'], []); } public function testBug14150(): void { + $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/bug-14150-nullsafe.php'], [ [ 'Using nullsafe property access on non-nullable type $this(Bug14150NullsafeProperty\HelloWorld). Use -> instead.', @@ -65,6 +82,18 @@ public function testBug14150(): void [ 'Using nullsafe property access on non-nullable type $this(Bug14150NullsafeProperty\HelloWorld). Use -> instead.', 27, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', + ], + ]); + } + + public function testBug14150WithoutCertainPhpDocTypes(): void + { + $this->treatPhpDocTypesAsCertain = false; + $this->analyse([__DIR__ . '/data/bug-14150-nullsafe.php'], [ + [ + 'Using nullsafe property access on non-nullable type $this(Bug14150NullsafeProperty\HelloWorld). Use -> instead.', + 20, ], ]); } @@ -72,12 +101,14 @@ public function testBug14150(): void #[RequiresPhp('>= 8.0.0')] public function testBug9105(): void { + $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-9105.php'], []); } #[RequiresPhp('>= 8.1.0')] public function testBug6922(): void { + $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/bug-6922.php'], []); }