Skip to content

Commit a615bf4

Browse files
committed
Custom method groups order
1 parent 2e4918f commit a615bf4

6 files changed

+464
-5
lines changed

SlevomatCodingStandard/Sniffs/Classes/ClassStructureSniff.php

+175-5
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;
@@ -181,9 +189,15 @@ class ClassStructureSniff implements Sniff
181189
'__debuginfo' => self::GROUP_MAGIC_METHODS,
182190
];
183191

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

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

@@ -342,6 +356,12 @@ private function getGroupForToken(File $phpcsFile, int $pointer): string
342356
return self::SPECIAL_METHODS[$name];
343357
}
344358

359+
$methodGroup = $this->resolveMethodGroup($phpcsFile, $pointer, $name);
360+
361+
if ($methodGroup !== null) {
362+
return $methodGroup;
363+
}
364+
345365
$visibility = $this->getVisibilityForToken($phpcsFile, $pointer);
346366
$isStatic = $this->isMemberStatic($phpcsFile, $pointer);
347367
$isFinal = $this->isMethodFinal($phpcsFile, $pointer);
@@ -391,6 +411,109 @@ private function getGroupForToken(File $phpcsFile, int $pointer): string
391411
}
392412
}
393413

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

677+
/**
678+
* @return array<string, list<array{name: string|null, attributes: array<string>, annotations: array<string>}>>
679+
*/
680+
private function getNormalizedMethodGroups(): array
681+
{
682+
if ($this->normalizedMethodGroups === null) {
683+
$this->normalizedMethodGroups = [];
684+
$methodGroups = SniffSettingsHelper::normalizeAssociativeArray($this->methodGroups);
685+
686+
foreach ($methodGroups as $group => $groupDefinition) {
687+
$group = strtolower((string) $group);
688+
$this->normalizedMethodGroups[$group] = [];
689+
$methodDefinitions = preg_split('~\\s*,\\s*~', (string) $groupDefinition, -1, PREG_SPLIT_NO_EMPTY);
690+
/** @var list<string> $methodDefinitions */
691+
foreach ($methodDefinitions as $methodDefinition) {
692+
$tokens = preg_split('~(?=[#@])~', $methodDefinition);
693+
/** @var list<string> $tokens */
694+
$method = array_shift($tokens);
695+
$methodRequirement = [
696+
'name' => $method !== '' ? $method : null,
697+
'attributes' => [],
698+
'annotations' => [],
699+
];
700+
701+
foreach ($tokens as $token) {
702+
$key = $token[0] === '#' ? 'attributes' : 'annotations';
703+
$methodRequirement[$key][] = substr($token, 1);
704+
}
705+
706+
$this->normalizedMethodGroups[$group][] = $methodRequirement;
707+
}
708+
}
709+
}
710+
711+
return $this->normalizedMethodGroups;
712+
}
713+
554714
/**
555715
* @return array<string, int>
556716
*/
@@ -589,18 +749,20 @@ private function getNormalizedGroups(): array
589749
self::GROUP_MAGIC_METHODS,
590750
];
591751

752+
$normalizedMethodGroups = $this->getNormalizedMethodGroups();
592753
$normalizedGroupsWithShortcuts = [];
593754
$order = 1;
594755
foreach (SniffSettingsHelper::normalizeArray($this->groups) as $groupsString) {
595756
/** @var list<string> $groups */
596-
$groups = preg_split('~\\s*,\\s*~', strtolower($groupsString));
757+
$groups = preg_split('~\\s*,\\s*~', strtolower($groupsString), -1, PREG_SPLIT_NO_EMPTY);
597758
foreach ($groups as $groupOrShortcut) {
598759
$groupOrShortcut = preg_replace('~\\s+~', ' ', $groupOrShortcut);
599760

600761
if (
601762
!in_array($groupOrShortcut, $supportedGroups, true)
602763
&& !array_key_exists($groupOrShortcut, self::SHORTCUTS)
603764
&& $groupOrShortcut !== self::GROUP_INVOKE_METHOD
765+
&& !array_key_exists($groupOrShortcut, $normalizedMethodGroups)
604766
) {
605767
throw new UnsupportedClassGroupException($groupOrShortcut);
606768
}
@@ -613,7 +775,11 @@ private function getNormalizedGroups(): array
613775

614776
$normalizedGroups = [];
615777
foreach ($normalizedGroupsWithShortcuts as $groupOrShortcut => $groupOrder) {
616-
if (in_array($groupOrShortcut, $supportedGroups, true) || $groupOrShortcut === self::GROUP_INVOKE_METHOD) {
778+
if (
779+
in_array($groupOrShortcut, $supportedGroups, true)
780+
|| $groupOrShortcut === self::GROUP_INVOKE_METHOD
781+
|| array_key_exists($groupOrShortcut, $normalizedMethodGroups)
782+
) {
617783
$normalizedGroups[$groupOrShortcut] = $groupOrder;
618784
} else {
619785
foreach ($this->unpackShortcut($groupOrShortcut, $supportedGroups) as $group) {
@@ -629,12 +795,16 @@ private function getNormalizedGroups(): array
629795
}
630796
}
631797

632-
if ($normalizedGroups === []) {
798+
if ($normalizedGroups === [] && $normalizedMethodGroups === []) {
633799
$normalizedGroups = array_flip($supportedGroups);
634800
} else {
635-
$missingGroups = array_values(array_diff($supportedGroups, array_keys($normalizedGroups)));
801+
$missingGroups = array_diff(
802+
array_merge($supportedGroups, array_keys($normalizedMethodGroups)),
803+
array_keys($normalizedGroups),
804+
);
805+
636806
if ($missingGroups !== []) {
637-
throw new MissingClassGroupsException($missingGroups);
807+
throw new MissingClassGroupsException(array_values($missingGroups));
638808
}
639809
}
640810

doc/classes.md

+8
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,
@@ -63,6 +64,10 @@ constants, properties, static properties, methods, all public methods, all prote
6364
```xml
6465
<rule ref="SlevomatCodingStandard.Classes.ClassStructure">
6566
<properties>
67+
<property name="methodGroups" type="array">
68+
<element key="phpunit before" value="setUp, @before, #PHPUnit\Framework\Attributes\Before"/>
69+
</property>
70+
6671
<property name="groups" type="array">
6772
<element value="uses"/>
6873

@@ -77,6 +82,9 @@ constants, properties, static properties, methods, all public methods, all prote
7782

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

tests/Sniffs/Classes/ClassStructureSniffTest.php

+71
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,45 @@ class ClassStructureSniffTest extends TestCase
2626
'protected static final methods',
2727
];
2828

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

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

0 commit comments

Comments
 (0)