From 8ae7edeaa2b24d1de0db9423f63b8c59d22c0660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Kut=C3=A1=C4=8D?= Date: Thu, 9 Apr 2020 11:05:54 +0200 Subject: [PATCH] Add Forbidden Classes sniff --- README.md | 24 ++ .../Sniffs/PHP/ForbiddenClassesSniff.php | 277 ++++++++++++++++++ SlevomatCodingStandard/Sniffs/TestCase.php | 2 +- .../Sniffs/PHP/ForbiddenClassesSniffTest.php | 84 ++++++ .../PHP/data/forbiddenClasses.fixed.php | 68 +++++ tests/Sniffs/PHP/data/forbiddenClasses.php | 68 +++++ 6 files changed, 522 insertions(+), 1 deletion(-) create mode 100644 SlevomatCodingStandard/Sniffs/PHP/ForbiddenClassesSniff.php create mode 100644 tests/Sniffs/PHP/ForbiddenClassesSniffTest.php create mode 100644 tests/Sniffs/PHP/data/forbiddenClasses.fixed.php create mode 100644 tests/Sniffs/PHP/data/forbiddenClasses.php diff --git a/README.md b/README.md index 4d7a49d50..41324e052 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,30 @@ Looks for `use` alias that is same as unqualified name. Disallows references. +#### SlevomatCodingStandard.PHP.ForbiddenClasses 🔧 + +Sniff checks forbidden classes, interfaces, parent classes and traits. And provide the following settings: + +* `forbiddenClasses`: Creating instances with `new` keyword or accessing with `::` operator +* `forbiddenExtends`: Extending with `extends` keyword +* `forbiddenInterfaces`: Used in `implements` section +* `forbiddenTraits`: Imported with `use` keyword + +Optionally can be passed as an alternative for auto fixes. See `phpcs.xml` file example: + +```xml + + + + + + + + + + +``` + #### SlevomatCodingStandard.PHP.RequireExplicitAssertion 🔧 Requires assertion via `assert` instead of inline documentation comments. diff --git a/SlevomatCodingStandard/Sniffs/PHP/ForbiddenClassesSniff.php b/SlevomatCodingStandard/Sniffs/PHP/ForbiddenClassesSniff.php new file mode 100644 index 000000000..5756d28ca --- /dev/null +++ b/SlevomatCodingStandard/Sniffs/PHP/ForbiddenClassesSniff.php @@ -0,0 +1,277 @@ + */ + public $forbiddenClasses = []; + + /** @var array */ + public $forbiddenExtends = []; + + /** @var array */ + public $forbiddenInterfaces = []; + + /** @var array */ + public $forbiddenTraits = []; + + /** @var array */ + private static $keywordReferences = ['self', 'parent', 'static']; + + /** + * @return array + */ + public function register(): array + { + $searchTokens = []; + + if (count($this->forbiddenClasses) > 0) { + $this->forbiddenClasses = self::normalizeInputOption($this->forbiddenClasses); + $searchTokens[] = T_NEW; + $searchTokens[] = T_DOUBLE_COLON; + } + + if (count($this->forbiddenExtends) > 0) { + $this->forbiddenExtends = self::normalizeInputOption($this->forbiddenExtends); + $searchTokens[] = T_EXTENDS; + } + + if (count($this->forbiddenInterfaces) > 0) { + $this->forbiddenInterfaces = self::normalizeInputOption($this->forbiddenInterfaces); + $searchTokens[] = T_IMPLEMENTS; + } + + if (count($this->forbiddenTraits) > 0) { + $this->forbiddenTraits = self::normalizeInputOption($this->forbiddenTraits); + $searchTokens[] = T_USE; + } + + return $searchTokens; + } + + /** + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint + * @param File $phpcsFile + * @param int $tokenPointer + */ + public function process(File $phpcsFile, $tokenPointer): void + { + $tokens = $phpcsFile->getTokens(); + $token = $tokens[$tokenPointer]; + $nameTokens = array_merge(TokenHelper::$nameTokenCodes, TokenHelper::$ineffectiveTokenCodes); + + if ( + $token['code'] === T_IMPLEMENTS + || ($token['code'] === T_USE && UseStatementHelper::isTraitUse($phpcsFile, $tokenPointer)) + ) { + $endTokenPointer = TokenHelper::findNext( + $phpcsFile, + [T_SEMICOLON, T_OPEN_CURLY_BRACKET], + $tokenPointer + ); + $references = $this->getAllReferences($phpcsFile, $tokenPointer, $endTokenPointer); + + if ($token['code'] === T_IMPLEMENTS) { + $this->checkReferences($phpcsFile, $tokenPointer, $references, $this->forbiddenInterfaces); + } else { + // Fixer does not work when traits contains aliases + $this->checkReferences( + $phpcsFile, + $tokenPointer, + $references, + $this->forbiddenTraits, + $tokens[$endTokenPointer]['code'] !== T_OPEN_CURLY_BRACKET + ); + } + } elseif (in_array($token['code'], [T_NEW, T_EXTENDS], true)) { + $endTokenPointer = TokenHelper::findNextExcluding($phpcsFile, $nameTokens, $tokenPointer + 1); + $references = $this->getAllReferences($phpcsFile, $tokenPointer, $endTokenPointer); + + $this->checkReferences( + $phpcsFile, + $tokenPointer, + $references, + $token['code'] === T_NEW ? $this->forbiddenClasses : $this->forbiddenExtends + ); + } elseif ($token['code'] === T_DOUBLE_COLON && !$this->isTraitsConflictResolutionToken($token)) { + $startTokenPointer = TokenHelper::findPreviousExcluding($phpcsFile, $nameTokens, $tokenPointer - 1); + $references = $this->getAllReferences($phpcsFile, $startTokenPointer, $tokenPointer); + + $this->checkReferences($phpcsFile, $tokenPointer, $references, $this->forbiddenClasses); + } + } + + /** + * @param File $phpcsFile + * @param int $tokenPointer + * @param array{fullyQualifiedName: string, startPointer: int|null, endPointer: int|null}[] $references + * @param array $forbiddenNames + * @param bool $isFixable + */ + private function checkReferences( + File $phpcsFile, + int $tokenPointer, + array $references, + array $forbiddenNames, + bool $isFixable = true + ): void + { + $token = $phpcsFile->getTokens()[$tokenPointer]; + $details = [ + T_NEW => ['class', self::CODE_FORBIDDEN_CLASS], + T_DOUBLE_COLON => ['class', self::CODE_FORBIDDEN_CLASS], + T_EXTENDS => ['as a parent class', self::CODE_FORBIDDEN_PARENT_CLASS], + T_IMPLEMENTS => ['interface', self::CODE_FORBIDDEN_INTERFACE], + T_USE => ['trait', self::CODE_FORBIDDEN_TRAIT], + ]; + + foreach ($references as $reference) { + if (!array_key_exists($reference['fullyQualifiedName'], $forbiddenNames)) { + continue; + } + + $alternative = $forbiddenNames[$reference['fullyQualifiedName']]; + [$nameType, $code] = $details[$token['code']]; + + if ($alternative === null) { + $phpcsFile->addError( + sprintf('Usage of %s %s is forbidden.', $reference['fullyQualifiedName'], $nameType), + $reference['startPointer'], + $code + ); + } elseif (!$isFixable) { + $phpcsFile->addError( + sprintf( + 'Usage of %s %s is forbidden, use %s instead.', + $reference['fullyQualifiedName'], + $nameType, + $alternative + ), + $reference['startPointer'], + $code + ); + } else { + $fix = $phpcsFile->addFixableError( + sprintf( + 'Usage of %s %s is forbidden, use %s instead.', + $reference['fullyQualifiedName'], + $nameType, + $alternative + ), + $reference['startPointer'], + $code + ); + if (!$fix) { + continue; + } + + $phpcsFile->fixer->beginChangeset(); + $phpcsFile->fixer->replaceToken($reference['startPointer'], $alternative); + for ($i = $reference['startPointer'] + 1; $i <= $reference['endPointer']; $i++) { + $phpcsFile->fixer->replaceToken($i, ''); + } + $phpcsFile->fixer->endChangeset(); + } + } + } + + /** + * @param array|int|string> $token + * @return bool + */ + private function isTraitsConflictResolutionToken(array $token): bool + { + return is_array($token['conditions']) && array_pop($token['conditions']) === T_USE; + } + + /** + * @param File $phpcsFile + * @param int $startPointer + * @param int $endPointer + * @return array{fullyQualifiedName: string, startPointer: int|null, endPointer: int|null}[] + */ + private function getAllReferences(File $phpcsFile, int $startPointer, int $endPointer): array + { + // Always ignore first token + $startPointer++; + $references = []; + + while ($startPointer < $endPointer) { + $nextComma = TokenHelper::findNext($phpcsFile, [T_COMMA], $startPointer + 1); + $nextSeparator = min($endPointer, $nextComma ?? PHP_INT_MAX); + $reference = ReferencedNameHelper::getReferenceName($phpcsFile, $startPointer, $nextSeparator - 1); + + if ( + strlen($reference) !== 0 + && !in_array(strtolower($reference), self::$keywordReferences, true) + ) { + $references[] = [ + 'fullyQualifiedName' => NamespaceHelper::resolveClassName($phpcsFile, $reference, $startPointer), + 'startPointer' => TokenHelper::findNextEffective($phpcsFile, $startPointer, $endPointer), + 'endPointer' => TokenHelper::findPreviousEffective($phpcsFile, $nextSeparator - 1, $startPointer), + ]; + } + + $startPointer = $nextSeparator + 1; + } + + return $references; + } + + /** + * @param array $option + * @return array + */ + private static function normalizeInputOption(array $option): array + { + $forbiddenClasses = []; + foreach ($option as $forbiddenClass => $alternative) { + $forbiddenClasses[self::normalizeClassName($forbiddenClass)] = self::normalizeClassName($alternative); + } + + return $forbiddenClasses; + } + + private static function normalizeClassName(?string $typeName): ?string + { + if ($typeName === null || strlen($typeName) === 0 || strtolower($typeName) === 'null') { + return null; + } + + return NamespaceHelper::getFullyQualifiedTypeName($typeName); + } + +} diff --git a/SlevomatCodingStandard/Sniffs/TestCase.php b/SlevomatCodingStandard/Sniffs/TestCase.php index 0cbfbe342..8195730df 100644 --- a/SlevomatCodingStandard/Sniffs/TestCase.php +++ b/SlevomatCodingStandard/Sniffs/TestCase.php @@ -26,7 +26,7 @@ abstract class TestCase extends \PHPUnit\Framework\TestCase /** * @param string $filePath - * @param (string|int|bool|(string|int|bool)[])[] $sniffProperties + * @param (string|int|bool|array)[] $sniffProperties * @param string[] $codesToCheck * @return File */ diff --git a/tests/Sniffs/PHP/ForbiddenClassesSniffTest.php b/tests/Sniffs/PHP/ForbiddenClassesSniffTest.php new file mode 100644 index 000000000..9862ac80e --- /dev/null +++ b/tests/Sniffs/PHP/ForbiddenClassesSniffTest.php @@ -0,0 +1,84 @@ + [ + '\\UserModel' => '\\MyApp\\Models\\User', + '\\DB' => '\\TypeORM\\DB', + '\\SomeClass' => 'NULL', + '\\FullyQualified\\Nested\\SomeClass' => '\\FullyQualified\\SomeClass', + ], + 'forbiddenExtends' => [ + '\\FooNamespace\\ForbiddenExtendedClass' => '\\BarNamespace\\BaseClass', + ], + 'forbiddenInterfaces' => [ + '\\FooNamespace\\SecondImplementedInterface' => '\\BarNamespace\\SecondImplementedInterface', + '\\SecondImplementedInterface' => null, + '\\ThirdImplementedInterface' => '', + ], + 'forbiddenTraits' => [ + '\\SomeTraitB' => '\\TotallyDifferentTrait', + '\\FullyQualified\\SometTotallyDifferentTrait' => '\\DatabaseTrait', + ], + ]); + + self::assertSame(19, $report->getErrorCount()); + $allErrors = $report->getErrors(); + + self::assertSniffError( + $report, + 9, + ForbiddenClassesSniff::CODE_FORBIDDEN_INTERFACE, + 'Usage of \FooNamespace\SecondImplementedInterface interface is forbidden, use \BarNamespace\SecondImplementedInterface instead' + ); + self::assertSniffError( + $report, + 9, + ForbiddenClassesSniff::CODE_FORBIDDEN_INTERFACE, + 'Usage of \ThirdImplementedInterface interface is forbidden.' + ); + self::assertSniffError($report, 13, ForbiddenClassesSniff::CODE_FORBIDDEN_TRAIT); + self::assertSniffError($report, 14, ForbiddenClassesSniff::CODE_FORBIDDEN_TRAIT); + self::assertSniffError($report, 25, ForbiddenClassesSniff::CODE_FORBIDDEN_CLASS); + self::assertSniffError($report, 29, ForbiddenClassesSniff::CODE_FORBIDDEN_PARENT_CLASS); + self::assertSniffError($report, 29, ForbiddenClassesSniff::CODE_FORBIDDEN_INTERFACE); + self::assertSniffError($report, 31, ForbiddenClassesSniff::CODE_FORBIDDEN_TRAIT); + self::assertSniffError($report, 38, ForbiddenClassesSniff::CODE_FORBIDDEN_CLASS); + self::assertSniffError($report, 40, ForbiddenClassesSniff::CODE_FORBIDDEN_CLASS); + self::assertSniffError($report, 43, ForbiddenClassesSniff::CODE_FORBIDDEN_CLASS); + self::assertSniffError($report, 47, ForbiddenClassesSniff::CODE_FORBIDDEN_CLASS); + self::assertSniffError($report, 48, ForbiddenClassesSniff::CODE_FORBIDDEN_CLASS); + self::assertCount(2, $allErrors[48]); + self::assertSniffError( + $report, + 49, + ForbiddenClassesSniff::CODE_FORBIDDEN_CLASS, + 'Usage of \DB class is forbidden, use \TypeORM\DB instead' + ); + self::assertSniffError( + $report, + 49, + ForbiddenClassesSniff::CODE_FORBIDDEN_CLASS, + 'Usage of \UserModel class is forbidden, use \MyApp\Models\User instead' + ); + self::assertSniffError($report, 52, ForbiddenClassesSniff::CODE_FORBIDDEN_CLASS); + self::assertSniffError($report, 58, ForbiddenClassesSniff::CODE_FORBIDDEN_CLASS); + self::assertSniffError($report, 66, ForbiddenClassesSniff::CODE_FORBIDDEN_TRAIT); + + self::assertAllFixedInFile($report); + } + +} diff --git a/tests/Sniffs/PHP/data/forbiddenClasses.fixed.php b/tests/Sniffs/PHP/data/forbiddenClasses.fixed.php new file mode 100644 index 000000000..aaf0440df --- /dev/null +++ b/tests/Sniffs/PHP/data/forbiddenClasses.fixed.php @@ -0,0 +1,68 @@ +someMethod(); + + $testNewSelf = new self("foo"); + + $anonClass = new class () extends \BarNamespace\BaseClass implements \ImplementedInterface, \SecondImplementedInterface + { + use \DatabaseTrait; + }; + } + + public function doubleCollonCases($testClass) + { + $constant = $testClass::ACCESS_CONSTANT; + $staticVar = \TypeORM\DB::$variable; + + $user = \MyApp\Models\User::getQuery(); + + $user->where(function (DB $query) { + return \MyApp\Models\User::addNotBlockedToQuery($query); + }); + + $queryParts = [ + \TypeORM\DB::select('id'), + \TypeORM\DB::from(\TypeORM\DB::raw('users')), + \TypeORM\DB::where(\MyApp\Models\User::class, 'active', true), + ]; + + \TypeORM\DB::query('INSERT INTO ...'); + + $this->getModel()::query(); + + parent::doubleCollonCases(); + + return \TypeORM\DB::exec($queryParts); + } + } +} + +namespace BarNamespace { + trait FooTrait + { + use \TotallyDifferentTrait; + } +} diff --git a/tests/Sniffs/PHP/data/forbiddenClasses.php b/tests/Sniffs/PHP/data/forbiddenClasses.php new file mode 100644 index 000000000..65cf91e04 --- /dev/null +++ b/tests/Sniffs/PHP/data/forbiddenClasses.php @@ -0,0 +1,68 @@ +someMethod(); + + $testNewSelf = new self("foo"); + + $anonClass = new class () extends ForbiddenExtendedClass implements \ImplementedInterface, \SecondImplementedInterface + { + use \FullyQualified\SometTotallyDifferentTrait; + }; + } + + public function doubleCollonCases($testClass) + { + $constant = $testClass::ACCESS_CONSTANT; + $staticVar = DB::$variable; + + $user = UserModel::getQuery(); + + $user->where(function (DB $query) { + return UserModel::addNotBlockedToQuery($query); + }); + + $queryParts = [ + DB::select('id'), + DB::from(DB::raw('users')), + DB::where(UserModel::class, 'active', true), + ]; + + DB::query('INSERT INTO ...'); + + $this->getModel()::query(); + + parent::doubleCollonCases(); + + return DB::exec($queryParts); + } + } +} + +namespace BarNamespace { + trait FooTrait + { + use \SomeTraitB; + } +}