diff --git a/conf/config.level3.neon b/conf/config.level3.neon index d777a8b60f..286f456be6 100644 --- a/conf/config.level3.neon +++ b/conf/config.level3.neon @@ -16,6 +16,7 @@ rules: - PHPStan\Rules\Arrays\OffsetAccessAssignOpRule - PHPStan\Rules\Arrays\OffsetAccessValueAssignmentRule - PHPStan\Rules\Arrays\UnpackIterableInArrayRule + - PHPStan\Rules\Exceptions\ThrowExprTypeRule - PHPStan\Rules\Functions\ArrowFunctionReturnTypeRule - PHPStan\Rules\Functions\ClosureReturnTypeRule - PHPStan\Rules\Functions\ReturnTypeRule diff --git a/src/Rules/Exceptions/ThrowExprTypeRule.php b/src/Rules/Exceptions/ThrowExprTypeRule.php new file mode 100644 index 0000000000..8d38531321 --- /dev/null +++ b/src/Rules/Exceptions/ThrowExprTypeRule.php @@ -0,0 +1,62 @@ + + */ +class ThrowExprTypeRule implements Rule +{ + + public function __construct( + private RuleLevelHelper $ruleLevelHelper, + ) + { + } + + public function getNodeType(): string + { + return Node\Expr\Throw_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $throwableType = new ObjectType(Throwable::class); + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $node->expr, + 'Throwing object of an unknown class %s.', + static fn (Type $type): bool => $throwableType->isSuperTypeOf($type)->yes(), + ); + + $foundType = $typeResult->getType(); + if ($foundType instanceof ErrorType) { + return $typeResult->getUnknownClassErrors(); + } + + $isSuperType = $throwableType->isSuperTypeOf($foundType); + if ($isSuperType->yes()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Invalid type %s to throw.', + $foundType->describe(VerbosityLevel::typeOnly()), + ))->build(), + ]; + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/ThrowExprTypeRuleTest.php b/tests/PHPStan/Rules/Exceptions/ThrowExprTypeRuleTest.php new file mode 100644 index 0000000000..8ef6277fe1 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/ThrowExprTypeRuleTest.php @@ -0,0 +1,74 @@ + + */ +class ThrowExprTypeRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new ThrowExprTypeRule(new RuleLevelHelper($this->createReflectionProvider(), true, false, true, false, false, true, false)); + } + + public function testRule(): void + { + $this->analyse( + [__DIR__ . '/data/throw-values.php'], + [ + /*[ + 'Invalid type int to throw.', + 29, + ], + [ + 'Invalid type ThrowValues\InvalidException to throw.', + 32, + ], + [ + 'Invalid type ThrowValues\InvalidInterfaceException to throw.', + 35, + ], + [ + 'Invalid type Exception|null to throw.', + 38, + ], + [ + 'Throwing object of an unknown class ThrowValues\NonexistentClass.', + 44, + 'Learn more at https://phpstan.org/user-guide/discovering-symbols', + ],*/ + [ + 'Invalid type int to throw.', + 65, + ], + ], + ); + } + + public function testClassExists(): void + { + $this->analyse([__DIR__ . '/data/throw-class-exists.php'], []); + } + + public function testRuleWithNullsafeVariant(): void + { + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped('Test requires PHP 8.0.'); + } + + $this->analyse([__DIR__ . '/data/throw-values-nullsafe.php'], [ + /*[ + 'Invalid type Exception|null to throw.', + 17, + ],*/ + ]); + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/data/throw-class-exists.php b/tests/PHPStan/Rules/Exceptions/data/throw-class-exists.php new file mode 100644 index 0000000000..39c9dd13dc --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/throw-class-exists.php @@ -0,0 +1,19 @@ += 8.0 + +namespace ThrowExprValuesNullsafe; + +class Bar +{ + + function doException(): \Exception + { + return new \Exception(); + } + +} + +function doFoo(?Bar $bar) +{ + throw $bar?->doException(); +} diff --git a/tests/PHPStan/Rules/Exceptions/data/throw-values.php b/tests/PHPStan/Rules/Exceptions/data/throw-values.php new file mode 100644 index 0000000000..f8fc008d68 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/throw-values.php @@ -0,0 +1,66 @@ + $genericExceptionClassName + * @param T $genericException + */ +function test($genericExceptionClassName, $genericException) { + /** @var ValidInterfaceException $validInterface */ + $validInterface = new \Exception(); + /** @var InvalidInterfaceException $invalidInterface */ + $invalidInterface = new \Exception(); + /** @var \Exception|null $nullableException */ + $nullableException = new \Exception(); + + if (rand(0, 1)) { + throw new \Exception(); + } + if (rand(0, 1)) { + throw $validInterface; + } + if (rand(0, 1)) { + throw 123; + } + if (rand(0, 1)) { + throw new InvalidException(); + } + if (rand(0, 1)) { + throw $invalidInterface; + } + if (rand(0, 1)) { + throw $nullableException; + } + if (rand(0, 1)) { + throw foo(); + } + if (rand(0, 1)) { + throw new NonexistentClass(); + } + if (rand(0, 1)) { + throw new $genericExceptionClassName; + } + if (rand(0, 1)) { + throw $genericException; + } +} + +function (\stdClass $foo) { + /** @var \Exception $foo */ + throw $foo; +}; + +function (\stdClass $foo) { + /** @var \Exception */ + throw $foo; +}; + +function (?\stdClass $foo) { + echo $foo ?? throw 1; +};