Skip to content

Commit 78639b6

Browse files
committed
Custom method groups order
1 parent 4ea0c29 commit 78639b6

6 files changed

+462
-5
lines changed

SlevomatCodingStandard/Sniffs/Classes/ClassStructureSniff.php

Lines changed: 173 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
use PHP_CodeSniffer\Files\File;
66
use PHP_CodeSniffer\Sniffs\Sniff;
77
use PHP_CodeSniffer\Util\Tokens;
8+
use SlevomatCodingStandard\Helpers\AnnotationHelper;
9+
use SlevomatCodingStandard\Helpers\AttributeHelper;
810
use SlevomatCodingStandard\Helpers\ClassHelper;
911
use SlevomatCodingStandard\Helpers\DocCommentHelper;
1012
use SlevomatCodingStandard\Helpers\FixerHelper;
1113
use SlevomatCodingStandard\Helpers\FunctionHelper;
14+
use SlevomatCodingStandard\Helpers\NamespaceHelper;
1215
use SlevomatCodingStandard\Helpers\PropertyHelper;
1316
use SlevomatCodingStandard\Helpers\SniffSettingsHelper;
1417
use SlevomatCodingStandard\Helpers\TokenHelper;
@@ -18,16 +21,21 @@
1821
use function array_key_exists;
1922
use function array_keys;
2023
use function array_merge;
24+
use function array_shift;
2125
use function array_values;
2226
use function assert;
2327
use function implode;
2428
use function in_array;
29+
use function ltrim;
2530
use function preg_replace;
2631
use function preg_split;
2732
use function sprintf;
2833
use function str_repeat;
2934
use function strtolower;
35+
use function substr;
36+
use const PREG_SPLIT_NO_EMPTY;
3037
use const T_ABSTRACT;
38+
use const T_ATTRIBUTE_END;
3139
use const T_CLOSE_CURLY_BRACKET;
3240
use const T_CONST;
3341
use const T_ENUM_CASE;
@@ -180,9 +188,15 @@ class ClassStructureSniff implements Sniff
180188
'__debuginfo' => self::GROUP_MAGIC_METHODS,
181189
];
182190

191+
/** @var array<string, string> */
192+
public $methodGroups = [];
193+
183194
/** @var list<string> */
184195
public $groups = [];
185196

197+
/** @var array<string, list<array{name: string|null, attributes: array<string>, annotations: array<string>}>>|null */
198+
private $normalizedMethodGroups;
199+
186200
/** @var array<string, int>|null */
187201
private $normalizedGroups;
188202

@@ -267,7 +281,6 @@ private function findNextGroup(File $phpcsFile, int $pointer, array $rootScopeTo
267281
{
268282
$tokens = $phpcsFile->getTokens();
269283
$groupTokenTypes = [T_USE, T_ENUM_CASE, T_CONST, T_VARIABLE, T_FUNCTION];
270-
271284
$currentTokenPointer = $pointer;
272285
while (true) {
273286
$currentTokenPointer = TokenHelper::findNext(
@@ -338,6 +351,12 @@ private function getGroupForToken(File $phpcsFile, int $pointer): string
338351
return self::SPECIAL_METHODS[$name];
339352
}
340353

354+
$methodGroup = $this->resolveMethodGroup($phpcsFile, $pointer, $name);
355+
356+
if ($methodGroup !== null) {
357+
return $methodGroup;
358+
}
359+
341360
$visibility = $this->getVisibilityForToken($phpcsFile, $pointer);
342361
$isStatic = $this->isMemberStatic($phpcsFile, $pointer);
343362
$isFinal = $this->isMethodFinal($phpcsFile, $pointer);
@@ -387,6 +406,109 @@ private function getGroupForToken(File $phpcsFile, int $pointer): string
387406
}
388407
}
389408

409+
private function resolveMethodGroup(File $phpcsFile, int $pointer, string $method): ?string
410+
{
411+
foreach ($this->getNormalizedMethodGroups() as $group => $methodRequirements) {
412+
foreach ($methodRequirements as $methodRequirement) {
413+
if ($methodRequirement['name'] !== null && $method !== strtolower($methodRequirement['name'])) {
414+
continue;
415+
}
416+
417+
if (
418+
$this->hasRequiredAnnotations($phpcsFile, $pointer, $methodRequirement['annotations'])
419+
&& $this->hasRequiredAttributes($phpcsFile, $pointer, $methodRequirement['attributes'])
420+
) {
421+
return $group;
422+
}
423+
}
424+
}
425+
426+
return null;
427+
}
428+
429+
/**
430+
* @param array<string> $requiredAnnotations
431+
*/
432+
private function hasRequiredAnnotations(File $phpcsFile, int $pointer, array $requiredAnnotations): bool
433+
{
434+
if ($requiredAnnotations === []) {
435+
return true;
436+
}
437+
438+
$annotations = [];
439+
440+
foreach (AnnotationHelper::getAnnotations($phpcsFile, $pointer) as $annotation) {
441+
$annotations[$annotation->getName()] = true;
442+
}
443+
444+
foreach ($requiredAnnotations as $requiredAnnotation) {
445+
if (!array_key_exists('@' . $requiredAnnotation, $annotations)) {
446+
return false;
447+
}
448+
}
449+
450+
return true;
451+
}
452+
453+
/**
454+
* @param array<string> $requiredAttributes
455+
*/
456+
private function hasRequiredAttributes(File $phpcsFile, int $pointer, array $requiredAttributes): bool
457+
{
458+
if ($requiredAttributes === []) {
459+
return true;
460+
}
461+
462+
$attributesClassNames = $this->getAttributeClassNamesForToken($phpcsFile, $pointer);
463+
464+
foreach ($requiredAttributes as $requiredAttribute) {
465+
if (!array_key_exists(strtolower($requiredAttribute), $attributesClassNames)) {
466+
return false;
467+
}
468+
}
469+
470+
return true;
471+
}
472+
473+
/**
474+
* @return array<string, string>
475+
*/
476+
private function getAttributeClassNamesForToken(File $phpcsFile, int $pointer): array
477+
{
478+
$tokens = $phpcsFile->getTokens();
479+
$attributePointer = null;
480+
$attributes = [];
481+
482+
while (true) {
483+
$attributeEndPointerCandidate = TokenHelper::findPrevious(
484+
$phpcsFile,
485+
[T_ATTRIBUTE_END, T_SEMICOLON, T_CLOSE_CURLY_BRACKET, T_OPEN_CURLY_BRACKET],
486+
$attributePointer ?? $pointer - 1
487+
);
488+
489+
if (
490+
$attributeEndPointerCandidate === null
491+
|| $tokens[$attributeEndPointerCandidate]['code'] !== T_ATTRIBUTE_END
492+
) {
493+
break;
494+
}
495+
496+
$attributePointer = $tokens[$attributeEndPointerCandidate]['attribute_opener'];
497+
498+
foreach (AttributeHelper::getAttributes($phpcsFile, $attributePointer) as $attribute) {
499+
$attributeClass = NamespaceHelper::resolveClassName(
500+
$phpcsFile,
501+
$attribute->getName(),
502+
$attribute->getStartPointer()
503+
);
504+
$attributeClass = ltrim($attributeClass, '\\');
505+
$attributes[strtolower($attributeClass)] = $attributeClass;
506+
}
507+
}
508+
509+
return $attributes;
510+
}
511+
390512
private function getVisibilityForToken(File $phpcsFile, int $pointer): int
391513
{
392514
$tokens = $phpcsFile->getTokens();
@@ -547,6 +669,43 @@ private function removeBlankLinesAfterMember(File $phpcsFile, int $memberEndPoin
547669
return $linesToRemove;
548670
}
549671

672+
/**
673+
* @return array<string, list<array{name: string|null, attributes: array<string>, annotations: array<string>}>>
674+
*/
675+
private function getNormalizedMethodGroups(): array
676+
{
677+
if ($this->normalizedMethodGroups === null) {
678+
$this->normalizedMethodGroups = [];
679+
$methodGroups = SniffSettingsHelper::normalizeAssociativeArray($this->methodGroups);
680+
681+
foreach ($methodGroups as $group => $groupDefinition) {
682+
$group = strtolower((string) $group);
683+
$this->normalizedMethodGroups[$group] = [];
684+
$methodDefinitions = preg_split('~\\s*,\\s*~', (string) $groupDefinition, -1, PREG_SPLIT_NO_EMPTY);
685+
/** @var list<string> $methodDefinitions */
686+
foreach ($methodDefinitions as $methodDefinition) {
687+
$tokens = preg_split('~(?=[#@])~', $methodDefinition);
688+
/** @var array<string> $tokens */
689+
$method = array_shift($tokens);
690+
$methodRequirement = [
691+
'name' => $method !== '' ? $method : null,
692+
'attributes' => [],
693+
'annotations' => [],
694+
];
695+
696+
foreach ($tokens as $token) {
697+
$key = $token[0] === '#' ? 'attributes' : 'annotations';
698+
$methodRequirement[$key][] = substr($token, 1);
699+
}
700+
701+
$this->normalizedMethodGroups[$group][] = $methodRequirement;
702+
}
703+
}
704+
}
705+
706+
return $this->normalizedMethodGroups;
707+
}
708+
550709
/**
551710
* @return array<string, int>
552711
*/
@@ -585,17 +744,19 @@ private function getNormalizedGroups(): array
585744
self::GROUP_MAGIC_METHODS,
586745
];
587746

747+
$normalizedMethodGroups = $this->getNormalizedMethodGroups();
588748
$normalizedGroupsWithShortcuts = [];
589749
$order = 1;
590750
foreach (SniffSettingsHelper::normalizeArray($this->groups) as $groupsString) {
591751
/** @var list<string> $groups */
592-
$groups = preg_split('~\\s*,\\s*~', strtolower($groupsString));
752+
$groups = preg_split('~\\s*,\\s*~', strtolower($groupsString), -1, PREG_SPLIT_NO_EMPTY);
593753
foreach ($groups as $groupOrShortcut) {
594754
$groupOrShortcut = preg_replace('~\\s+~', ' ', $groupOrShortcut);
595755

596756
if (
597757
!in_array($groupOrShortcut, $supportedGroups, true)
598758
&& !array_key_exists($groupOrShortcut, self::SHORTCUTS)
759+
&& !array_key_exists($groupOrShortcut, $normalizedMethodGroups)
599760
) {
600761
throw new UnsupportedClassGroupException($groupOrShortcut);
601762
}
@@ -608,7 +769,10 @@ private function getNormalizedGroups(): array
608769

609770
$normalizedGroups = [];
610771
foreach ($normalizedGroupsWithShortcuts as $groupOrShortcut => $groupOrder) {
611-
if (in_array($groupOrShortcut, $supportedGroups, true)) {
772+
if (
773+
in_array($groupOrShortcut, $supportedGroups, true)
774+
|| array_key_exists($groupOrShortcut, $normalizedMethodGroups)
775+
) {
612776
$normalizedGroups[$groupOrShortcut] = $groupOrder;
613777
} else {
614778
foreach ($this->unpackShortcut($groupOrShortcut, $supportedGroups) as $group) {
@@ -624,10 +788,14 @@ private function getNormalizedGroups(): array
624788
}
625789
}
626790

627-
if ($normalizedGroups === []) {
791+
if ($normalizedGroups === [] && $normalizedMethodGroups === []) {
628792
$normalizedGroups = array_flip($supportedGroups);
629793
} else {
630-
$missingGroups = array_diff($supportedGroups, array_keys($normalizedGroups));
794+
$missingGroups = array_diff(
795+
array_merge($supportedGroups, array_keys($normalizedMethodGroups)),
796+
array_keys($normalizedGroups)
797+
);
798+
631799
if ($missingGroups !== []) {
632800
throw new MissingClassGroupsException($missingGroups);
633801
}

doc/classes.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Checks that class/trait/interface members are in the correct order.
4545
Sniff provides the following settings:
4646

4747
* `groups`: order of groups. Use multiple groups in one `<element value="">` to not differentiate among them. You can use specific groups or shortcuts.
48+
* `methodGroups`: custom method groups. Define a custom group for special methods based on their name, annotation, or attribute.
4849

4950
**List of supported groups**:
5051
uses,
@@ -64,6 +65,10 @@ constants, properties, static properties, methods, all public methods, all prote
6465
```xml
6566
<rule ref="SlevomatCodingStandard.Classes.ClassStructure">
6667
<properties>
68+
<property name="methodGroups" type="array">
69+
<element key="phpunit before" value="setUp, @before, #PHPUnit\Framework\Attributes\Before"/>
70+
</property>
71+
6772
<property name="groups" type="array">
6873
<element value="uses"/>
6974

@@ -78,6 +83,9 @@ constants, properties, static properties, methods, all public methods, all prote
7883

7984
<!-- Constructor is first, then all public methods, then protected/private methods and magic methods are last -->
8085
<element value="constructor"/>
86+
87+
<!-- PHPUnit's before hooks are placed before all other public methods using a custom method group regardless their visibility -->
88+
<element value="phpunit before"/>
8189
<element value="all public methods"/>
8290
<element value="methods"/>
8391
<element value="magic methods"/>

tests/Sniffs/Classes/ClassStructureSniffTest.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,45 @@ class ClassStructureSniffTest extends TestCase
2525
'protected static final methods',
2626
];
2727

28+
private const METHOD_GROUPS = [
29+
'phpunit before class' => 'setUpBeforeClass, @beforeClass, #PHPUnit\Framework\Attributes\BeforeClass',
30+
'phpunit after class' => 'tearDownAfterClass, @afterClass, #PHPUnit\Framework\Attributes\AfterClass',
31+
'phpunit before' => 'setUp, @before, #PHPUnit\Framework\Attributes\Before',
32+
'phpunit after' => 'tearDown, @after, #PHPUnit\Framework\Attributes\After',
33+
];
34+
35+
private const METHOD_GROUP_RULES = [
36+
'uses',
37+
'public constants',
38+
'protected constants',
39+
'private constants',
40+
'enum cases',
41+
'public static properties',
42+
'protected static properties',
43+
'private static properties',
44+
'public properties',
45+
'protected properties',
46+
'private properties',
47+
'constructor',
48+
'static constructors',
49+
'destructor',
50+
'phpunit before class',
51+
'phpunit after class',
52+
'phpunit before',
53+
'phpunit after',
54+
'public methods, public final methods',
55+
'public abstract methods',
56+
'public static methods, public static final methods',
57+
'public static abstract methods',
58+
'magic methods',
59+
'protected methods, protected final methods',
60+
'protected abstract methods',
61+
'protected static methods, protected static final methods',
62+
'protected static abstract methods',
63+
'private methods',
64+
'private static methods',
65+
];
66+
2867
public function testNoErrors(): void
2968
{
3069
$report = self::checkFile(__DIR__ . '/data/classStructureSniffNoErrors.php');
@@ -145,6 +184,38 @@ public function testErrorsWithShortcuts(): void
145184
self::assertAllFixedInFile($report);
146185
}
147186

187+
public function testNoErrorsWithMethodGroupRules(): void
188+
{
189+
$report = self::checkFile(
190+
__DIR__ . '/data/classStructureSniffNoErrorsWithMethodGroupRules.php',
191+
[
192+
'methodGroups' => self::METHOD_GROUPS,
193+
'groups' => self::METHOD_GROUP_RULES,
194+
]
195+
);
196+
197+
self::assertNoSniffErrorInFile($report);
198+
}
199+
200+
public function testErrorsWithMethodGroupRules(): void
201+
{
202+
$report = self::checkFile(
203+
__DIR__ . '/data/classStructureSniffErrorsWithMethodGroupRules.php',
204+
[
205+
'methodGroups' => self::METHOD_GROUPS,
206+
'groups' => self::METHOD_GROUP_RULES,
207+
]
208+
);
209+
210+
self::assertSame(5, $report->getErrorCount());
211+
self::assertSniffError($report, 22, ClassStructureSniff::CODE_INCORRECT_GROUP_ORDER);
212+
self::assertSniffError($report, 33, ClassStructureSniff::CODE_INCORRECT_GROUP_ORDER);
213+
self::assertSniffError($report, 44, ClassStructureSniff::CODE_INCORRECT_GROUP_ORDER);
214+
self::assertSniffError($report, 48, ClassStructureSniff::CODE_INCORRECT_GROUP_ORDER);
215+
self::assertSniffError($report, 67, ClassStructureSniff::CODE_INCORRECT_GROUP_ORDER);
216+
self::assertAllFixedInFile($report);
217+
}
218+
148219
public function testThrowExceptionForUnsupportedGroup(): void
149220
{
150221
try {

0 commit comments

Comments
 (0)