Skip to content

Commit

Permalink
new sniff Consistence.Exceptions.ExceptionDeclaration checks exceptio…
Browse files Browse the repository at this point in the history
…n rules from the standard
  • Loading branch information
VasekPurchart committed Sep 21, 2017
2 parents 2543915 + da42ccd commit ac8c913
Show file tree
Hide file tree
Showing 18 changed files with 559 additions and 1 deletion.
143 changes: 143 additions & 0 deletions Consistence/Sniffs/Exceptions/ExceptionDeclarationSniff.php
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 tests/Sniffs/Exceptions/ExceptionDeclarationSniffTest.php
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 tests/Sniffs/Exceptions/data/AbstractExceptionWithInvalidName.php
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
{

}
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
{

}
Loading

0 comments on commit ac8c913

Please sign in to comment.