-
Notifications
You must be signed in to change notification settings - Fork 20
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
new sniff Consistence.Exceptions.ExceptionDeclaration checks exceptio…
…n rules from the standard
- Loading branch information
Showing
18 changed files
with
559 additions
and
1 deletion.
There are no files selected for viewing
143 changes: 143 additions & 0 deletions
143
Consistence/Sniffs/Exceptions/ExceptionDeclarationSniff.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
<?php | ||
|
||
declare(strict_types = 1); | ||
|
||
namespace Consistence\Sniffs\Exceptions; | ||
|
||
use PHP_CodeSniffer\Files\File as PhpCsFile; | ||
use SlevomatCodingStandard\Helpers\ClassHelper; | ||
use SlevomatCodingStandard\Helpers\FunctionHelper; | ||
use SlevomatCodingStandard\Helpers\StringHelper; | ||
use SlevomatCodingStandard\Helpers\TokenHelper; | ||
|
||
class ExceptionDeclarationSniff implements \PHP_CodeSniffer\Sniffs\Sniff | ||
{ | ||
|
||
const CODE_NOT_ENDING_WITH_EXCEPTION = 'NotEndingWithException'; | ||
const CODE_NOT_CHAINABLE = 'NotChainable'; | ||
const CODE_INCORRECT_EXCEPTION_DIRECTORY = 'IncorrectExceptionDirectory'; | ||
|
||
/** @var string */ | ||
public $exceptionsDirectoryName = 'exceptions'; | ||
|
||
/** | ||
* @return int[] | ||
*/ | ||
public function register(): array | ||
{ | ||
return [ | ||
T_CLASS, | ||
T_INTERFACE, | ||
]; | ||
} | ||
|
||
/** | ||
* @param \PHP_CodeSniffer\Files\File $phpcsFile | ||
* @param int $classPointer | ||
*/ | ||
public function process(PhpCsFile $phpcsFile, $classPointer) | ||
{ | ||
$extendedClassName = $phpcsFile->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; | ||
} | ||
|
||
} |
225 changes: 225 additions & 0 deletions
225
tests/Sniffs/Exceptions/ExceptionDeclarationSniffTest.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,225 @@ | ||
<?php | ||
|
||
declare(strict_types = 1); | ||
|
||
namespace Consistence\Sniffs\Exceptions; | ||
|
||
class ExceptionDeclarationSniffTest extends \Consistence\Sniffs\TestCase | ||
{ | ||
|
||
public function testInvalidExceptionName() | ||
{ | ||
$resultFile = $this->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").' | ||
); | ||
} | ||
|
||
} |
10 changes: 10 additions & 0 deletions
10
tests/Sniffs/Exceptions/data/AbstractExceptionWithInvalidName.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
<?php | ||
|
||
declare(strict_types = 1); | ||
|
||
namespace Consistence\Sniffs\Exceptions; | ||
|
||
abstract class AbstractExceptionWithInvalidName extends \Exception | ||
{ | ||
|
||
} |
10 changes: 10 additions & 0 deletions
10
tests/Sniffs/Exceptions/data/AbstractExceptionWithValidNameException.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
<?php | ||
|
||
declare(strict_types = 1); | ||
|
||
namespace Consistence\Sniffs\Exceptions; | ||
|
||
abstract class AbstractExceptionWithValidNameException extends \Exception | ||
{ | ||
|
||
} |
Oops, something went wrong.