Skip to content

Add Forbidden Classes sniff #966

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 9, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<rule ref="SlevomatCodingStandard.PHP.ForbiddenClasses">
<properties>
<property name="forbiddenClasses" type="array">
<element key="Validator" value="Illuminate\Support\Facades\Validator"/>
</property>
<property name="forbiddenTraits" type="array">
<element key="\AuthTrait" value="null"/>
</property>
</properties>
</rule>
```

#### SlevomatCodingStandard.PHP.RequireExplicitAssertion 🔧

Requires assertion via `assert` instead of inline documentation comments.
Expand Down
9 changes: 9 additions & 0 deletions SlevomatCodingStandard/Helpers/NamespaceHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ public static function isFullyQualifiedName(string $typeName): bool
return StringHelper::startsWith($typeName, self::NAMESPACE_SEPARATOR);
}

public static function getFullyQualifiedTypeName(string $typeName): string
{
if (self::isFullyQualifiedName($typeName)) {
return $typeName;
}

return sprintf('%s%s', self::NAMESPACE_SEPARATOR, $typeName);
}

public static function hasNamespace(string $typeName): bool
{
$parts = self::getNameParts($typeName);
Expand Down
277 changes: 277 additions & 0 deletions SlevomatCodingStandard/Sniffs/PHP/ForbiddenClassesSniff.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
<?php declare(strict_types = 1);

namespace SlevomatCodingStandard\Sniffs\PHP;

use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Sniffs\Sniff;
use SlevomatCodingStandard\Helpers\NamespaceHelper;
use SlevomatCodingStandard\Helpers\ReferencedNameHelper;
use SlevomatCodingStandard\Helpers\TokenHelper;
use SlevomatCodingStandard\Helpers\UseStatementHelper;
use function array_key_exists;
use function array_merge;
use function array_pop;
use function count;
use function in_array;
use function is_array;
use function min;
use function sprintf;
use function strlen;
use function strtolower;
use const PHP_INT_MAX;
use const T_COMMA;
use const T_DOUBLE_COLON;
use const T_EXTENDS;
use const T_IMPLEMENTS;
use const T_NEW;
use const T_OPEN_CURLY_BRACKET;
use const T_SEMICOLON;
use const T_USE;

class ForbiddenClassesSniff implements Sniff
{

public const CODE_FORBIDDEN_CLASS = 'ForbiddenClass';
public const CODE_FORBIDDEN_PARENT_CLASS = 'ForbiddenParentClass';
public const CODE_FORBIDDEN_INTERFACE = 'ForbiddenInterface';
public const CODE_FORBIDDEN_TRAIT = 'ForbiddenTrait';

/** @var array<string, (string|null)> */
public $forbiddenClasses = [];

/** @var array<string, (string|null)> */
public $forbiddenExtends = [];

/** @var array<string, (string|null)> */
public $forbiddenInterfaces = [];

/** @var array<string, (string|null)> */
public $forbiddenTraits = [];

/** @var array<string> */
private static $keywordReferences = ['self', 'parent', 'static'];

/**
* @return array<int, (int|string)>
*/
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<string, (string|null)> $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<string, array<int, int|string>|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<string, (string|null)> $option
* @return array<string, (string|null)>
*/
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);
}

}
2 changes: 1 addition & 1 deletion SlevomatCodingStandard/Sniffs/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int|string, (string|int|bool|null)>)[] $sniffProperties
* @param string[] $codesToCheck
* @return File
*/
Expand Down
22 changes: 22 additions & 0 deletions tests/Helpers/NamespaceHelperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace SlevomatCodingStandard\Helpers;

use function sprintf;
use const T_STRING;

class NamespaceHelperTest extends TestCase
Expand Down Expand Up @@ -29,6 +30,15 @@ public function testIsFullyQualifiedName(string $typeName): void
self::assertTrue(NamespaceHelper::isFullyQualifiedName($typeName));
}

/**
* @dataProvider dataIsFullyQualifiedName
* @param string $typeName
*/
public function testGetFullyQualifiedTypeNameUnchanged(string $typeName): void
{
self::assertSame($typeName, NamespaceHelper::getFullyQualifiedTypeName($typeName));
}

/**
* @return string[][]
*/
Expand All @@ -51,6 +61,18 @@ public function testIsNotFullyQualifiedName(string $typeName): void
self::assertFalse(NamespaceHelper::isFullyQualifiedName($typeName));
}

/**
* @dataProvider dataIsNotFullyQualifiedName
* @param string $typeName
*/
public function testGetFullyQualifiedTypeNameChanged(string $typeName): void
{
self::assertSame(
sprintf('\\%s', $typeName),
NamespaceHelper::getFullyQualifiedTypeName($typeName)
);
}

/**
* @return string[][]
*/
Expand Down
Loading