Skip to content

Commit 915a7cd

Browse files
arxeisskukulich
authored andcommitted
Add Forbidden Classes sniff
1 parent 6f84771 commit 915a7cd

File tree

6 files changed

+522
-1
lines changed

6 files changed

+522
-1
lines changed

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,30 @@ Looks for `use` alias that is same as unqualified name.
312312

313313
Disallows references.
314314

315+
#### SlevomatCodingStandard.PHP.ForbiddenClasses 🔧
316+
317+
Sniff checks forbidden classes, interfaces, parent classes and traits. And provide the following settings:
318+
319+
* `forbiddenClasses`: Creating instances with `new` keyword or accessing with `::` operator
320+
* `forbiddenExtends`: Extending with `extends` keyword
321+
* `forbiddenInterfaces`: Used in `implements` section
322+
* `forbiddenTraits`: Imported with `use` keyword
323+
324+
Optionally can be passed as an alternative for auto fixes. See `phpcs.xml` file example:
325+
326+
```xml
327+
<rule ref="SlevomatCodingStandard.PHP.ForbiddenClasses">
328+
<properties>
329+
<property name="forbiddenClasses" type="array">
330+
<element key="Validator" value="Illuminate\Support\Facades\Validator"/>
331+
</property>
332+
<property name="forbiddenTraits" type="array">
333+
<element key="\AuthTrait" value="null"/>
334+
</property>
335+
</properties>
336+
</rule>
337+
```
338+
315339
#### SlevomatCodingStandard.PHP.RequireExplicitAssertion 🔧
316340

317341
Requires assertion via `assert` instead of inline documentation comments.
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace SlevomatCodingStandard\Sniffs\PHP;
4+
5+
use PHP_CodeSniffer\Files\File;
6+
use PHP_CodeSniffer\Sniffs\Sniff;
7+
use SlevomatCodingStandard\Helpers\NamespaceHelper;
8+
use SlevomatCodingStandard\Helpers\ReferencedNameHelper;
9+
use SlevomatCodingStandard\Helpers\TokenHelper;
10+
use SlevomatCodingStandard\Helpers\UseStatementHelper;
11+
use function array_key_exists;
12+
use function array_merge;
13+
use function array_pop;
14+
use function count;
15+
use function in_array;
16+
use function is_array;
17+
use function min;
18+
use function sprintf;
19+
use function strlen;
20+
use function strtolower;
21+
use const PHP_INT_MAX;
22+
use const T_COMMA;
23+
use const T_DOUBLE_COLON;
24+
use const T_EXTENDS;
25+
use const T_IMPLEMENTS;
26+
use const T_NEW;
27+
use const T_OPEN_CURLY_BRACKET;
28+
use const T_SEMICOLON;
29+
use const T_USE;
30+
31+
class ForbiddenClassesSniff implements Sniff
32+
{
33+
34+
public const CODE_FORBIDDEN_CLASS = 'ForbiddenClass';
35+
public const CODE_FORBIDDEN_PARENT_CLASS = 'ForbiddenParentClass';
36+
public const CODE_FORBIDDEN_INTERFACE = 'ForbiddenInterface';
37+
public const CODE_FORBIDDEN_TRAIT = 'ForbiddenTrait';
38+
39+
/** @var array<string, (string|null)> */
40+
public $forbiddenClasses = [];
41+
42+
/** @var array<string, (string|null)> */
43+
public $forbiddenExtends = [];
44+
45+
/** @var array<string, (string|null)> */
46+
public $forbiddenInterfaces = [];
47+
48+
/** @var array<string, (string|null)> */
49+
public $forbiddenTraits = [];
50+
51+
/** @var array<string> */
52+
private static $keywordReferences = ['self', 'parent', 'static'];
53+
54+
/**
55+
* @return array<int, (int|string)>
56+
*/
57+
public function register(): array
58+
{
59+
$searchTokens = [];
60+
61+
if (count($this->forbiddenClasses) > 0) {
62+
$this->forbiddenClasses = self::normalizeInputOption($this->forbiddenClasses);
63+
$searchTokens[] = T_NEW;
64+
$searchTokens[] = T_DOUBLE_COLON;
65+
}
66+
67+
if (count($this->forbiddenExtends) > 0) {
68+
$this->forbiddenExtends = self::normalizeInputOption($this->forbiddenExtends);
69+
$searchTokens[] = T_EXTENDS;
70+
}
71+
72+
if (count($this->forbiddenInterfaces) > 0) {
73+
$this->forbiddenInterfaces = self::normalizeInputOption($this->forbiddenInterfaces);
74+
$searchTokens[] = T_IMPLEMENTS;
75+
}
76+
77+
if (count($this->forbiddenTraits) > 0) {
78+
$this->forbiddenTraits = self::normalizeInputOption($this->forbiddenTraits);
79+
$searchTokens[] = T_USE;
80+
}
81+
82+
return $searchTokens;
83+
}
84+
85+
/**
86+
* @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint
87+
* @param File $phpcsFile
88+
* @param int $tokenPointer
89+
*/
90+
public function process(File $phpcsFile, $tokenPointer): void
91+
{
92+
$tokens = $phpcsFile->getTokens();
93+
$token = $tokens[$tokenPointer];
94+
$nameTokens = array_merge(TokenHelper::$nameTokenCodes, TokenHelper::$ineffectiveTokenCodes);
95+
96+
if (
97+
$token['code'] === T_IMPLEMENTS
98+
|| ($token['code'] === T_USE && UseStatementHelper::isTraitUse($phpcsFile, $tokenPointer))
99+
) {
100+
$endTokenPointer = TokenHelper::findNext(
101+
$phpcsFile,
102+
[T_SEMICOLON, T_OPEN_CURLY_BRACKET],
103+
$tokenPointer
104+
);
105+
$references = $this->getAllReferences($phpcsFile, $tokenPointer, $endTokenPointer);
106+
107+
if ($token['code'] === T_IMPLEMENTS) {
108+
$this->checkReferences($phpcsFile, $tokenPointer, $references, $this->forbiddenInterfaces);
109+
} else {
110+
// Fixer does not work when traits contains aliases
111+
$this->checkReferences(
112+
$phpcsFile,
113+
$tokenPointer,
114+
$references,
115+
$this->forbiddenTraits,
116+
$tokens[$endTokenPointer]['code'] !== T_OPEN_CURLY_BRACKET
117+
);
118+
}
119+
} elseif (in_array($token['code'], [T_NEW, T_EXTENDS], true)) {
120+
$endTokenPointer = TokenHelper::findNextExcluding($phpcsFile, $nameTokens, $tokenPointer + 1);
121+
$references = $this->getAllReferences($phpcsFile, $tokenPointer, $endTokenPointer);
122+
123+
$this->checkReferences(
124+
$phpcsFile,
125+
$tokenPointer,
126+
$references,
127+
$token['code'] === T_NEW ? $this->forbiddenClasses : $this->forbiddenExtends
128+
);
129+
} elseif ($token['code'] === T_DOUBLE_COLON && !$this->isTraitsConflictResolutionToken($token)) {
130+
$startTokenPointer = TokenHelper::findPreviousExcluding($phpcsFile, $nameTokens, $tokenPointer - 1);
131+
$references = $this->getAllReferences($phpcsFile, $startTokenPointer, $tokenPointer);
132+
133+
$this->checkReferences($phpcsFile, $tokenPointer, $references, $this->forbiddenClasses);
134+
}
135+
}
136+
137+
/**
138+
* @param File $phpcsFile
139+
* @param int $tokenPointer
140+
* @param array{fullyQualifiedName: string, startPointer: int|null, endPointer: int|null}[] $references
141+
* @param array<string, (string|null)> $forbiddenNames
142+
* @param bool $isFixable
143+
*/
144+
private function checkReferences(
145+
File $phpcsFile,
146+
int $tokenPointer,
147+
array $references,
148+
array $forbiddenNames,
149+
bool $isFixable = true
150+
): void
151+
{
152+
$token = $phpcsFile->getTokens()[$tokenPointer];
153+
$details = [
154+
T_NEW => ['class', self::CODE_FORBIDDEN_CLASS],
155+
T_DOUBLE_COLON => ['class', self::CODE_FORBIDDEN_CLASS],
156+
T_EXTENDS => ['as a parent class', self::CODE_FORBIDDEN_PARENT_CLASS],
157+
T_IMPLEMENTS => ['interface', self::CODE_FORBIDDEN_INTERFACE],
158+
T_USE => ['trait', self::CODE_FORBIDDEN_TRAIT],
159+
];
160+
161+
foreach ($references as $reference) {
162+
if (!array_key_exists($reference['fullyQualifiedName'], $forbiddenNames)) {
163+
continue;
164+
}
165+
166+
$alternative = $forbiddenNames[$reference['fullyQualifiedName']];
167+
[$nameType, $code] = $details[$token['code']];
168+
169+
if ($alternative === null) {
170+
$phpcsFile->addError(
171+
sprintf('Usage of %s %s is forbidden.', $reference['fullyQualifiedName'], $nameType),
172+
$reference['startPointer'],
173+
$code
174+
);
175+
} elseif (!$isFixable) {
176+
$phpcsFile->addError(
177+
sprintf(
178+
'Usage of %s %s is forbidden, use %s instead.',
179+
$reference['fullyQualifiedName'],
180+
$nameType,
181+
$alternative
182+
),
183+
$reference['startPointer'],
184+
$code
185+
);
186+
} else {
187+
$fix = $phpcsFile->addFixableError(
188+
sprintf(
189+
'Usage of %s %s is forbidden, use %s instead.',
190+
$reference['fullyQualifiedName'],
191+
$nameType,
192+
$alternative
193+
),
194+
$reference['startPointer'],
195+
$code
196+
);
197+
if (!$fix) {
198+
continue;
199+
}
200+
201+
$phpcsFile->fixer->beginChangeset();
202+
$phpcsFile->fixer->replaceToken($reference['startPointer'], $alternative);
203+
for ($i = $reference['startPointer'] + 1; $i <= $reference['endPointer']; $i++) {
204+
$phpcsFile->fixer->replaceToken($i, '');
205+
}
206+
$phpcsFile->fixer->endChangeset();
207+
}
208+
}
209+
}
210+
211+
/**
212+
* @param array<string, array<int, int|string>|int|string> $token
213+
* @return bool
214+
*/
215+
private function isTraitsConflictResolutionToken(array $token): bool
216+
{
217+
return is_array($token['conditions']) && array_pop($token['conditions']) === T_USE;
218+
}
219+
220+
/**
221+
* @param File $phpcsFile
222+
* @param int $startPointer
223+
* @param int $endPointer
224+
* @return array{fullyQualifiedName: string, startPointer: int|null, endPointer: int|null}[]
225+
*/
226+
private function getAllReferences(File $phpcsFile, int $startPointer, int $endPointer): array
227+
{
228+
// Always ignore first token
229+
$startPointer++;
230+
$references = [];
231+
232+
while ($startPointer < $endPointer) {
233+
$nextComma = TokenHelper::findNext($phpcsFile, [T_COMMA], $startPointer + 1);
234+
$nextSeparator = min($endPointer, $nextComma ?? PHP_INT_MAX);
235+
$reference = ReferencedNameHelper::getReferenceName($phpcsFile, $startPointer, $nextSeparator - 1);
236+
237+
if (
238+
strlen($reference) !== 0
239+
&& !in_array(strtolower($reference), self::$keywordReferences, true)
240+
) {
241+
$references[] = [
242+
'fullyQualifiedName' => NamespaceHelper::resolveClassName($phpcsFile, $reference, $startPointer),
243+
'startPointer' => TokenHelper::findNextEffective($phpcsFile, $startPointer, $endPointer),
244+
'endPointer' => TokenHelper::findPreviousEffective($phpcsFile, $nextSeparator - 1, $startPointer),
245+
];
246+
}
247+
248+
$startPointer = $nextSeparator + 1;
249+
}
250+
251+
return $references;
252+
}
253+
254+
/**
255+
* @param array<string, (string|null)> $option
256+
* @return array<string, (string|null)>
257+
*/
258+
private static function normalizeInputOption(array $option): array
259+
{
260+
$forbiddenClasses = [];
261+
foreach ($option as $forbiddenClass => $alternative) {
262+
$forbiddenClasses[self::normalizeClassName($forbiddenClass)] = self::normalizeClassName($alternative);
263+
}
264+
265+
return $forbiddenClasses;
266+
}
267+
268+
private static function normalizeClassName(?string $typeName): ?string
269+
{
270+
if ($typeName === null || strlen($typeName) === 0 || strtolower($typeName) === 'null') {
271+
return null;
272+
}
273+
274+
return NamespaceHelper::getFullyQualifiedTypeName($typeName);
275+
}
276+
277+
}

SlevomatCodingStandard/Sniffs/TestCase.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ abstract class TestCase extends \PHPUnit\Framework\TestCase
2626

2727
/**
2828
* @param string $filePath
29-
* @param (string|int|bool|(string|int|bool)[])[] $sniffProperties
29+
* @param (string|int|bool|array<int|string, (string|int|bool|null)>)[] $sniffProperties
3030
* @param string[] $codesToCheck
3131
* @return File
3232
*/

0 commit comments

Comments
 (0)