diff --git a/Consistence/Sniffs/Exceptions/ExceptionDeclarationSniff.php b/Consistence/Sniffs/Exceptions/ExceptionDeclarationSniff.php new file mode 100644 index 0000000..68bc087 --- /dev/null +++ b/Consistence/Sniffs/Exceptions/ExceptionDeclarationSniff.php @@ -0,0 +1,143 @@ +findExtendedClassName($classPointer); + if ($extendedClassName === false) { + return; //does not extend anything + } + + if (!StringHelper::endsWith($extendedClassName, 'Exception')) { + return; // does not extend Exception, is not an exception + } + + $this->checkExceptionName($phpcsFile, $classPointer); + + $this->checkExceptionDirectoryName($phpcsFile, $classPointer); + + $this->checkThatExceptionIsChainable($phpcsFile, $classPointer); + } + + private function checkExceptionName(PhpCsFile $phpcsFile, int $classPointer) + { + $className = ClassHelper::getName($phpcsFile, $classPointer); + if (!StringHelper::endsWith($className, 'Exception')) { + $phpcsFile->addError(sprintf( + 'Exception class name "%s" must end with "Exception".', + $className + ), $classPointer, self::CODE_NOT_ENDING_WITH_EXCEPTION); + } + } + + private function checkExceptionDirectoryName(PhpCsFile $phpcsFile, int $classPointer) + { + $filename = $phpcsFile->getFilename(); + + // normalize path for Windows (PHP_CodeSniffer detects it with backslashes on Windows) + $filename = str_replace('\\', '/', $filename); + + $pathInfo = pathinfo($filename); + $pathSegments = explode('/', $pathInfo['dirname']); + + $exceptionDirectoryName = array_pop($pathSegments); + + if ($exceptionDirectoryName !== $this->exceptionsDirectoryName) { + $phpcsFile->addError(sprintf( + 'Exception file "%s" must be placed in "%s" directory (is in "%s").', + $pathInfo['basename'], + $this->exceptionsDirectoryName, + $exceptionDirectoryName + ), $classPointer, self::CODE_INCORRECT_EXCEPTION_DIRECTORY); + } + } + + private function checkThatExceptionIsChainable(PhpCsFile $phpcsFile, int $classPointer) + { + $constructorPointer = $this->findConstructorMethodPointer($phpcsFile, $classPointer); + if ($constructorPointer === null) { + return; + } + + $typeHints = FunctionHelper::getParametersTypeHints($phpcsFile, $constructorPointer); + if (count($typeHints) === 0) { + $phpcsFile->addError( + 'Exception is not chainable. It must have optional \Throwable as last constructor argument.', + $constructorPointer, + self::CODE_NOT_CHAINABLE + ); + return; + } + $lastArgument = array_pop($typeHints); + + if ($lastArgument === null) { + $phpcsFile->addError( + 'Exception is not chainable. It must have optional \Throwable as last constructor argument and has none.', + $constructorPointer, + self::CODE_NOT_CHAINABLE + ); + return; + } + + if ($lastArgument->getTypeHint() !== '\Throwable') { + $phpcsFile->addError(sprintf( + 'Exception is not chainable. It must have optional \Throwable as last constructor argument and has "%s".', + $lastArgument->getTypeHint() + ), $constructorPointer, self::CODE_NOT_CHAINABLE); + return; + } + } + + /** + * @param \PHP_CodeSniffer\Files\File $phpcsFile + * @param int $classPointer + * @return int|null + */ + private function findConstructorMethodPointer(PhpCsFile $phpcsFile, int $classPointer) + { + $functionPointerToScan = $classPointer; + while (($functionPointer = TokenHelper::findNext($phpcsFile, T_FUNCTION, $functionPointerToScan)) !== null) { + $functionName = FunctionHelper::getName($phpcsFile, $functionPointer); + if ($functionName === '__construct') { + return $functionPointer; + } + $functionPointerToScan = $functionPointer + 1; + } + return null; + } + +} diff --git a/tests/Sniffs/Exceptions/ExceptionDeclarationSniffTest.php b/tests/Sniffs/Exceptions/ExceptionDeclarationSniffTest.php new file mode 100644 index 0000000..6bcc3d7 --- /dev/null +++ b/tests/Sniffs/Exceptions/ExceptionDeclarationSniffTest.php @@ -0,0 +1,225 @@ +checkFile(__DIR__ . '/data/InvalidExceptionName.php', [ + 'exceptionsDirectoryName' => 'data', + ]); + + $this->assertSniffError( + $resultFile, + 7, + ExceptionDeclarationSniff::CODE_NOT_ENDING_WITH_EXCEPTION, + 'Exception class name "InvalidExceptionName" must end with "Exception".' + ); + } + + public function testValidClassName() + { + $resultFile = $this->checkFile(__DIR__ . '/data/ValidNameException.php', [ + 'exceptionsDirectoryName' => 'data', + ]); + + $this->assertNoSniffError($resultFile, 7); + } + + public function testValidClassNameThatExtendsCustomException() + { + $resultFile = $this->checkFile(__DIR__ . '/data/ValidClassNameThatExtendsCustomException.php', [ + 'exceptionsDirectoryName' => 'data', + ]); + + $this->assertNoSniffError($resultFile, 7); + } + + public function testAbstractExceptionWithValidNameException() + { + $resultFile = $this->checkFile(__DIR__ . '/data/AbstractExceptionWithValidNameException.php', [ + 'exceptionsDirectoryName' => 'data', + ]); + + $this->assertNoSniffError($resultFile, 7); + } + + public function testAbstractClassWithInvalidExceptionName() + { + $resultFile = $this->checkFile(__DIR__ . '/data/AbstractExceptionWithInvalidName.php', [ + 'exceptionsDirectoryName' => 'data', + ]); + + $this->assertSniffError( + $resultFile, + 7, + ExceptionDeclarationSniff::CODE_NOT_ENDING_WITH_EXCEPTION, + 'Exception class name "AbstractExceptionWithInvalidName" must end with "Exception".' + ); + } + + public function testClassThatDoesNotExtendAnything() + { + $resultFile = $this->checkFile(__DIR__ . '/data/ClassThatDoesNotExtendAnything.php', [ + 'exceptionsDirectoryName' => 'data', + ]); + + $this->assertNoSniffError($resultFile, 7); + } + + public function testClassThatExtendsRegularClass() + { + $resultFile = $this->checkFile(__DIR__ . '/data/ClassThatDoesNotExtendException.php', [ + 'exceptionsDirectoryName' => 'data', + ]); + + $this->assertNoSniffError($resultFile, 7); + } + + public function testInterfaceThatDoesNotExtendAnything() + { + $resultFile = $this->checkFile(__DIR__ . '/data/InterfaceThatDoesNotExtendAnything.php', [ + 'exceptionsDirectoryName' => 'data', + ]); + + $this->assertNoSniffError($resultFile, 7); + } + + public function testInterfaceThatDoesNotExtendAnythingException() + { + $resultFile = $this->checkFile(__DIR__ . '/data/InterfaceThatDoesNotExtendAnythingException.php', [ + 'exceptionsDirectoryName' => 'data', + ]); + + $this->assertNoSniffError($resultFile, 7); + } + + public function testInterfaceThatExtendsException() + { + $resultFile = $this->checkFile(__DIR__ . '/data/InterfaceThatExtendsException.php', [ + 'exceptionsDirectoryName' => 'data', + ]); + + $this->assertNoSniffError($resultFile, 7); + } + + public function testInterfaceThatExtendsExceptionIncorrectName() + { + $resultFile = $this->checkFile(__DIR__ . '/data/InterfaceThatExtendsExceptionIncorrectName.php', [ + 'exceptionsDirectoryName' => 'data', + ]); + + $this->assertSniffError( + $resultFile, + 7, + ExceptionDeclarationSniff::CODE_NOT_ENDING_WITH_EXCEPTION, + 'Exception class name "InterfaceThatExtendsExceptionIncorrectName" must end with "Exception".' + ); + } + + public function testExceptionWithConstructorWithoutParametersIsNotChainable() + { + $resultFile = $this->checkFile(__DIR__ . '/data/ConstructWithoutParametersException.php', [ + 'exceptionsDirectoryName' => 'data', + ]); + + $this->assertSniffError( + $resultFile, + 10, + ExceptionDeclarationSniff::CODE_NOT_CHAINABLE, + 'Exception is not chainable. It must have optional \Throwable as last constructor argument.' + ); + } + + public function testExceptionWithChainableConstructorIsChainable() + { + $resultFile = $this->checkFile(__DIR__ . '/data/ChainableConstructorException.php', [ + 'exceptionsDirectoryName' => 'data', + ]); + + $this->assertNoSniffError($resultFile, 10); + } + + public function testExceptionWithNonchainableConstructorIsNotChainable() + { + $resultFile = $this->checkFile(__DIR__ . '/data/NonChainableConstructorException.php', [ + 'exceptionsDirectoryName' => 'data', + ]); + + $this->assertSniffError( + $resultFile, + 10, + ExceptionDeclarationSniff::CODE_NOT_CHAINABLE, + 'Exception is not chainable. It must have optional \Throwable as last constructor argument and has "string".' + ); + } + + public function testExceptionWithConstructorWithoutParameterTypeHintIsNotChainable() + { + $resultFile = $this->checkFile(__DIR__ . '/data/NonChainableConstructorWithoutParameterTypehintException.php', [ + 'exceptionsDirectoryName' => 'data', + ]); + + $this->assertSniffError( + $resultFile, + 10, + ExceptionDeclarationSniff::CODE_NOT_CHAINABLE, + 'Exception is not chainable. It must have optional \Throwable as last constructor argument and has none.' + ); + } + + public function testExceptionIsPlacedInCorrectDirectory() + { + $resultFile = $this->checkFile(__DIR__ . '/data/ValidNameException.php', [ + 'exceptionsDirectoryName' => 'data', + ]); + + $this->assertNoSniffError($resultFile, 7); + } + + /** + * @requires OS WIN + */ + public function testExceptionIsPlacedInCorrectDirectoryOnWindows() + { + // PHP_CodeSniffer detects the path with backslashes on Windows + $resultFile = $this->checkFile(__DIR__ . '\data\ValidNameException.php', [ + 'exceptionsDirectoryName' => 'data', + ]); + + $this->assertNoSniffError($resultFile, 7); + } + + public function testExceptionIsPlacedInIncorrectDirectory() + { + $resultFile = $this->checkFile(__DIR__ . '/data/ValidNameException.php', [ + 'exceptionsDirectoryName' => 'exceptions', + ]); + + $this->assertSniffError( + $resultFile, + 7, + ExceptionDeclarationSniff::CODE_INCORRECT_EXCEPTION_DIRECTORY, + 'Exception file "ValidNameException.php" must be placed in "exceptions" directory (is in "data").' + ); + } + + public function testExceptionIsPlacedInIncorrectDirectoryCaseSensitively() + { + $resultFile = $this->checkFile(__DIR__ . '/data/ValidNameException.php', [ + 'exceptionsDirectoryName' => 'Data', + ]); + + $this->assertSniffError( + $resultFile, + 7, + ExceptionDeclarationSniff::CODE_INCORRECT_EXCEPTION_DIRECTORY, + 'Exception file "ValidNameException.php" must be placed in "Data" directory (is in "data").' + ); + } + +} diff --git a/tests/Sniffs/Exceptions/data/AbstractExceptionWithInvalidName.php b/tests/Sniffs/Exceptions/data/AbstractExceptionWithInvalidName.php new file mode 100644 index 0000000..a76be7f --- /dev/null +++ b/tests/Sniffs/Exceptions/data/AbstractExceptionWithInvalidName.php @@ -0,0 +1,10 @@ +init(); + if (count($sniffProperties) > 0) { + $codeSniffer->ruleset->ruleset[$this->getSniffName()]['properties'] = $sniffProperties; + } + $codeSniffer->ruleset->sniffs = [$this->getSniffClassName() => $this->getSniffClassName()]; $codeSniffer->ruleset->populateTokenListeners();