diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index bfdd57db17..245fb055b1 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -27,5 +27,6 @@ parameters: classConstants: true privateStaticCall: true overridingProperty: true + throwsVoid: true stubFiles: - ../stubs/arrayFunctions.stub diff --git a/conf/config.level3.neon b/conf/config.level3.neon index 45c6d129a0..f30bea1c29 100644 --- a/conf/config.level3.neon +++ b/conf/config.level3.neon @@ -20,6 +20,10 @@ rules: conditionalTags: PHPStan\Rules\Arrays\ArrayDestructuringRule: phpstan.rules.rule: %featureToggles.arrayDestructuring% + PHPStan\Rules\Exceptions\ThrowsVoidFunctionWithExplicitThrowPointRule: + phpstan.rules.rule: %featureToggles.throwsVoid% + PHPStan\Rules\Exceptions\ThrowsVoidMethodWithExplicitThrowPointRule: + phpstan.rules.rule: %featureToggles.throwsVoid% parameters: checkPhpDocMethodSignatures: true @@ -56,6 +60,18 @@ services: tags: - phpstan.rules.rule + - + class: PHPStan\Rules\Exceptions\ThrowsVoidFunctionWithExplicitThrowPointRule + arguments: + exceptionTypeResolver: @exceptionTypeResolver + missingCheckedExceptionInThrows: %exceptions.check.missingCheckedExceptionInThrows% + + - + class: PHPStan\Rules\Exceptions\ThrowsVoidMethodWithExplicitThrowPointRule + arguments: + exceptionTypeResolver: @exceptionTypeResolver + missingCheckedExceptionInThrows: %exceptions.check.missingCheckedExceptionInThrows% + - class: PHPStan\Rules\Functions\ReturnTypeRule arguments: diff --git a/conf/config.neon b/conf/config.neon index 46849b6b79..0fec097710 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -52,6 +52,7 @@ parameters: classConstants: false privateStaticCall: false overridingProperty: false + throwsVoid: false fileExtensions: - php checkAdvancedIsset: false @@ -237,7 +238,8 @@ parametersSchema: finalByPhpDocTag: bool(), classConstants: bool(), privateStaticCall: bool(), - overridingProperty: bool() + overridingProperty: bool(), + throwsVoid: bool() ]) fileExtensions: listOf(string()) checkAdvancedIsset: bool() diff --git a/src/Rules/Exceptions/ThrowsVoidFunctionWithExplicitThrowPointRule.php b/src/Rules/Exceptions/ThrowsVoidFunctionWithExplicitThrowPointRule.php new file mode 100644 index 0000000000..932f85555c --- /dev/null +++ b/src/Rules/Exceptions/ThrowsVoidFunctionWithExplicitThrowPointRule.php @@ -0,0 +1,81 @@ + + */ +class ThrowsVoidFunctionWithExplicitThrowPointRule implements Rule +{ + + private ExceptionTypeResolver $exceptionTypeResolver; + + private bool $missingCheckedExceptionInThrows; + + public function __construct( + ExceptionTypeResolver $exceptionTypeResolver, + bool $missingCheckedExceptionInThrows + ) + { + $this->exceptionTypeResolver = $exceptionTypeResolver; + $this->missingCheckedExceptionInThrows = $missingCheckedExceptionInThrows; + } + + public function getNodeType(): string + { + return FunctionReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($this->missingCheckedExceptionInThrows) { + return []; + } + + $statementResult = $node->getStatementResult(); + $functionReflection = $scope->getFunction(); + if (!$functionReflection instanceof FunctionReflection) { + throw new \PHPStan\ShouldNotHappenException(); + } + + if (!$functionReflection->getThrowType() instanceof VoidType) { + return []; + } + + $errors = []; + foreach ($statementResult->getThrowPoints() as $throwPoint) { + if (!$throwPoint->isExplicit()) { + continue; + } + + foreach (TypeUtils::flattenTypes($throwPoint->getType()) as $throwPointType) { + if ( + $throwPointType instanceof TypeWithClassName + && $this->exceptionTypeResolver->isCheckedException($throwPointType->getClassName(), $throwPoint->getScope()) + ) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Function %s() throws exception %s but the PHPDoc contains @throws void.', + $functionReflection->getName(), + $throwPointType->describe(VerbosityLevel::typeOnly()) + ))->line($throwPoint->getNode()->getLine())->build(); + } + } + + return $errors; + } + +} diff --git a/src/Rules/Exceptions/ThrowsVoidMethodWithExplicitThrowPointRule.php b/src/Rules/Exceptions/ThrowsVoidMethodWithExplicitThrowPointRule.php new file mode 100644 index 0000000000..6354f7c529 --- /dev/null +++ b/src/Rules/Exceptions/ThrowsVoidMethodWithExplicitThrowPointRule.php @@ -0,0 +1,82 @@ + + */ +class ThrowsVoidMethodWithExplicitThrowPointRule implements Rule +{ + + private ExceptionTypeResolver $exceptionTypeResolver; + + private bool $missingCheckedExceptionInThrows; + + public function __construct( + ExceptionTypeResolver $exceptionTypeResolver, + bool $missingCheckedExceptionInThrows + ) + { + $this->exceptionTypeResolver = $exceptionTypeResolver; + $this->missingCheckedExceptionInThrows = $missingCheckedExceptionInThrows; + } + + public function getNodeType(): string + { + return MethodReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($this->missingCheckedExceptionInThrows) { + return []; + } + + $statementResult = $node->getStatementResult(); + $methodReflection = $scope->getFunction(); + if (!$methodReflection instanceof MethodReflection) { + throw new \PHPStan\ShouldNotHappenException(); + } + + if (!$methodReflection->getThrowType() instanceof VoidType) { + return []; + } + + $errors = []; + foreach ($statementResult->getThrowPoints() as $throwPoint) { + if (!$throwPoint->isExplicit()) { + continue; + } + + foreach (TypeUtils::flattenTypes($throwPoint->getType()) as $throwPointType) { + if ( + $throwPointType instanceof TypeWithClassName + && $this->exceptionTypeResolver->isCheckedException($throwPointType->getClassName(), $throwPoint->getScope()) + ) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() throws exception %s but the PHPDoc contains @throws void.', + $methodReflection->getDeclaringClass()->getDisplayName(), + $methodReflection->getName(), + $throwPointType->describe(VerbosityLevel::typeOnly()) + ))->line($throwPoint->getNode()->getLine())->build(); + } + } + + return $errors; + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/ThrowsVoidFunctionWithExplicitThrowPointRuleTest.php b/tests/PHPStan/Rules/Exceptions/ThrowsVoidFunctionWithExplicitThrowPointRuleTest.php new file mode 100644 index 0000000000..61f1f3de96 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/ThrowsVoidFunctionWithExplicitThrowPointRuleTest.php @@ -0,0 +1,70 @@ + + */ +class ThrowsVoidFunctionWithExplicitThrowPointRuleTest extends RuleTestCase +{ + + /** @var bool */ + private $missingCheckedExceptionInThrows; + + /** @var string[] */ + private $checkedExceptionClasses; + + protected function getRule(): Rule + { + return new ThrowsVoidFunctionWithExplicitThrowPointRule(new DefaultExceptionTypeResolver( + $this->createReflectionProvider(), + [], + [], + [], + $this->checkedExceptionClasses + ), $this->missingCheckedExceptionInThrows); + } + + public function dataRule(): array + { + return [ + [ + true, + [], + [], + ], + [ + false, + ['DifferentException'], + [ + [ + 'Function ThrowsVoidFunction\foo() throws exception ThrowsVoidFunction\MyException but the PHPDoc contains @throws void.', + 15, + ], + ], + ], + [ + false, + [\ThrowsVoidFunction\MyException::class], + [], + ], + ]; + } + + /** + * @dataProvider dataRule + * @param bool $missingCheckedExceptionInThrows + * @param string[] $checkedExceptionClasses + * @param mixed[] $errors + */ + public function testRule(bool $missingCheckedExceptionInThrows, array $checkedExceptionClasses, array $errors): void + { + $this->missingCheckedExceptionInThrows = $missingCheckedExceptionInThrows; + $this->checkedExceptionClasses = $checkedExceptionClasses; + $this->analyse([__DIR__ . '/data/throws-void-function.php'], $errors); + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/ThrowsVoidMethodWithExplicitThrowPointRuleTest.php b/tests/PHPStan/Rules/Exceptions/ThrowsVoidMethodWithExplicitThrowPointRuleTest.php new file mode 100644 index 0000000000..df99360201 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/ThrowsVoidMethodWithExplicitThrowPointRuleTest.php @@ -0,0 +1,70 @@ + + */ +class ThrowsVoidMethodWithExplicitThrowPointRuleTest extends RuleTestCase +{ + + /** @var bool */ + private $missingCheckedExceptionInThrows; + + /** @var string[] */ + private $checkedExceptionClasses; + + protected function getRule(): Rule + { + return new ThrowsVoidMethodWithExplicitThrowPointRule(new DefaultExceptionTypeResolver( + $this->createReflectionProvider(), + [], + [], + [], + $this->checkedExceptionClasses + ), $this->missingCheckedExceptionInThrows); + } + + public function dataRule(): array + { + return [ + [ + true, + [], + [], + ], + [ + false, + ['DifferentException'], + [ + [ + 'Method ThrowsVoidMethod\Foo::doFoo() throws exception ThrowsVoidMethod\MyException but the PHPDoc contains @throws void.', + 18, + ], + ], + ], + [ + false, + [\ThrowsVoidMethod\MyException::class], + [], + ], + ]; + } + + /** + * @dataProvider dataRule + * @param bool $missingCheckedExceptionInThrows + * @param string[] $checkedExceptionClasses + * @param mixed[] $errors + */ + public function testRule(bool $missingCheckedExceptionInThrows, array $checkedExceptionClasses, array $errors): void + { + $this->missingCheckedExceptionInThrows = $missingCheckedExceptionInThrows; + $this->checkedExceptionClasses = $checkedExceptionClasses; + $this->analyse([__DIR__ . '/data/throws-void-method.php'], $errors); + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/data/throws-void-function.php b/tests/PHPStan/Rules/Exceptions/data/throws-void-function.php new file mode 100644 index 0000000000..d5a407ec13 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/throws-void-function.php @@ -0,0 +1,16 @@ +