Skip to content

Commit

Permalink
Custom method groups order
Browse files Browse the repository at this point in the history
  • Loading branch information
maryo committed May 18, 2024
1 parent 4ea0c29 commit ed6291b
Show file tree
Hide file tree
Showing 6 changed files with 445 additions and 5 deletions.
161 changes: 156 additions & 5 deletions SlevomatCodingStandard/Sniffs/Classes/ClassStructureSniff.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Sniffs\Sniff;
use PHP_CodeSniffer\Util\Tokens;
use SlevomatCodingStandard\Helpers\AnnotationHelper;
use SlevomatCodingStandard\Helpers\AttributeHelper;
use SlevomatCodingStandard\Helpers\ClassHelper;
use SlevomatCodingStandard\Helpers\DocCommentHelper;
use SlevomatCodingStandard\Helpers\FixerHelper;
use SlevomatCodingStandard\Helpers\FunctionHelper;
use SlevomatCodingStandard\Helpers\NamespaceHelper;
use SlevomatCodingStandard\Helpers\PropertyHelper;
use SlevomatCodingStandard\Helpers\SniffSettingsHelper;
use SlevomatCodingStandard\Helpers\TokenHelper;
Expand Down Expand Up @@ -180,9 +183,15 @@ class ClassStructureSniff implements Sniff
'__debuginfo' => self::GROUP_MAGIC_METHODS,
];

/** @var array<string, string> */
public $methodGroups = [];

/** @var list<string> */
public $groups = [];

/** @var array<string, list<string>>|null */
private $normalizedMethodGroups;

/** @var array<string, int>|null */
private $normalizedGroups;

Expand Down Expand Up @@ -267,7 +276,6 @@ private function findNextGroup(File $phpcsFile, int $pointer, array $rootScopeTo
{
$tokens = $phpcsFile->getTokens();
$groupTokenTypes = [T_USE, T_ENUM_CASE, T_CONST, T_VARIABLE, T_FUNCTION];

$currentTokenPointer = $pointer;
while (true) {
$currentTokenPointer = TokenHelper::findNext(
Expand Down Expand Up @@ -338,6 +346,12 @@ private function getGroupForToken(File $phpcsFile, int $pointer): string
return self::SPECIAL_METHODS[$name];
}

$methodGroup = $this->resolveMethodGroup($phpcsFile, $pointer, $name);

if ($methodGroup !== null) {
return $methodGroup;
}

$visibility = $this->getVisibilityForToken($phpcsFile, $pointer);
$isStatic = $this->isMemberStatic($phpcsFile, $pointer);
$isFinal = $this->isMethodFinal($phpcsFile, $pointer);
Expand Down Expand Up @@ -387,6 +401,99 @@ private function getGroupForToken(File $phpcsFile, int $pointer): string
}
}

private function resolveMethodGroup(File $phpcsFile, int $pointer, string $method): ?string
{
foreach ($this->getNormalizedMethodGroups() as $group => $methodRequirements) {
foreach ($methodRequirements as $methodRequirement) {
if ($methodRequirement['name'] !== null && $method !== strtolower($methodRequirement['name'])) {

Check failure on line 408 in SlevomatCodingStandard/Sniffs/Classes/ClassStructureSniff.php

View workflow job for this annotation

GitHub Actions / Static analysis (highest, 8.3, ubuntu-latest)

Offset 'name' does not exist on array<string>|string.

Check failure on line 408 in SlevomatCodingStandard/Sniffs/Classes/ClassStructureSniff.php

View workflow job for this annotation

GitHub Actions / Static analysis (highest, 8.3, ubuntu-latest)

Strict comparison using !== between string and null will always evaluate to true.
continue;
}

if ($requiredAnnotations = $methodRequirement['annotations']) {

Check failure on line 412 in SlevomatCodingStandard/Sniffs/Classes/ClassStructureSniff.php

View workflow job for this annotation

GitHub Actions / Static analysis (highest, 8.3, ubuntu-latest)

Offset 'annotations' does not exist on array<string>|string.

Check failure on line 412 in SlevomatCodingStandard/Sniffs/Classes/ClassStructureSniff.php

View workflow job for this annotation

GitHub Actions / Static analysis (highest, 8.3, ubuntu-latest)

Only booleans are allowed in an if condition, string given.
if (!$this->hasRequiredAnnotations($phpcsFile, $pointer, $requiredAnnotations)) {

Check failure on line 413 in SlevomatCodingStandard/Sniffs/Classes/ClassStructureSniff.php

View workflow job for this annotation

GitHub Actions / Static analysis (highest, 8.3, ubuntu-latest)

Parameter #3 $requiredAnnotations of method SlevomatCodingStandard\Sniffs\Classes\ClassStructureSniff::hasRequiredAnnotations() expects array, string given.
continue;
}
}

if ($requiredAttributes = $methodRequirement['attributes']) {

Check failure on line 418 in SlevomatCodingStandard/Sniffs/Classes/ClassStructureSniff.php

View workflow job for this annotation

GitHub Actions / Static analysis (highest, 8.3, ubuntu-latest)

Offset 'attributes' does not exist on array<string>|string.

Check failure on line 418 in SlevomatCodingStandard/Sniffs/Classes/ClassStructureSniff.php

View workflow job for this annotation

GitHub Actions / Static analysis (highest, 8.3, ubuntu-latest)

Only booleans are allowed in an if condition, string given.
if (!$this->hasRequiredAttributes($phpcsFile, $pointer, $requiredAttributes)) {

Check failure on line 419 in SlevomatCodingStandard/Sniffs/Classes/ClassStructureSniff.php

View workflow job for this annotation

GitHub Actions / Static analysis (highest, 8.3, ubuntu-latest)

Parameter #3 $requiredAttributes of method SlevomatCodingStandard\Sniffs\Classes\ClassStructureSniff::hasRequiredAttributes() expects array, string given.
continue;
}
}

return $group;
}
}

return null;
}

private function hasRequiredAnnotations(File $phpcsFile, int $pointer, array $requiredAnnotations): bool

Check failure on line 431 in SlevomatCodingStandard/Sniffs/Classes/ClassStructureSniff.php

View workflow job for this annotation

GitHub Actions / Static analysis (highest, 8.3, ubuntu-latest)

Method SlevomatCodingStandard\Sniffs\Classes\ClassStructureSniff::hasRequiredAnnotations() has parameter $requiredAnnotations with no value type specified in iterable type array.
{
$annotations = [];

foreach (AnnotationHelper::getAnnotations($phpcsFile, $pointer) as $annotation) {
$annotations[$annotation->getName()] = true;
}

foreach ($requiredAnnotations as $requiredAnnotation) {
if (!array_key_exists('@' . $requiredAnnotation, $annotations)) {
return false;
}
}

return true;
}

private function hasRequiredAttributes(File $phpcsFile, int $pointer, array $requiredAttributes): bool

Check failure on line 448 in SlevomatCodingStandard/Sniffs/Classes/ClassStructureSniff.php

View workflow job for this annotation

GitHub Actions / Static analysis (highest, 8.3, ubuntu-latest)

Method SlevomatCodingStandard\Sniffs\Classes\ClassStructureSniff::hasRequiredAttributes() has parameter $requiredAttributes with no value type specified in iterable type array.
{
$attributesClassNames = $this->getAttributeClassNamesForToken($phpcsFile, $pointer);

foreach ($requiredAttributes as $requiredAttribute) {
if (!array_key_exists(strtolower($requiredAttribute), $attributesClassNames)) {
return false;
}
}

return true;
}

private function getAttributeClassNamesForToken(File $phpcsFile, int $pointer): array
{
$tokens = $phpcsFile->getTokens();
$attributePointer = null;
$attributes = [];

while (true) {
$attributeEndPointerCandidate = TokenHelper::findPrevious(
$phpcsFile,
[T_ATTRIBUTE_END, T_SEMICOLON, T_CLOSE_CURLY_BRACKET, T_OPEN_CURLY_BRACKET],
$attributePointer ?? ($pointer - 1)
);

if (
$attributeEndPointerCandidate === null
|| $tokens[$attributeEndPointerCandidate]['code'] !== T_ATTRIBUTE_END
) {
break;
}

$attributePointer = $tokens[$attributeEndPointerCandidate]['attribute_opener'];

foreach (AttributeHelper::getAttributes($phpcsFile, $attributePointer) as $attribute) {
$attributeClass = NamespaceHelper::resolveClassName(
$phpcsFile,
$attribute->getName(),
$attribute->getStartPointer()
);
$attributeClass = ltrim($attributeClass, '\\');
$attributes[strtolower($attributeClass)] = $attributeClass;
}
}

return $attributes;
}

private function getVisibilityForToken(File $phpcsFile, int $pointer): int
{
$tokens = $phpcsFile->getTokens();
Expand Down Expand Up @@ -547,6 +654,41 @@ private function removeBlankLinesAfterMember(File $phpcsFile, int $memberEndPoin
return $linesToRemove;
}

/**
* @return array<string, array{method: string|null, attributes: string[], annotations: string[]}>
*/
private function getNormalizedMethodGroups(): array
{
if ($this->normalizedMethodGroups === null) {
$this->normalizedMethodGroups = [];
$methodGroups = SniffSettingsHelper::normalizeAssociativeArray($this->methodGroups);

foreach ($methodGroups as $group => $groupDefinition) {
$group = strtolower($group);
$this->normalizedMethodGroups[$group] = [];

foreach (preg_split('~\\s*,\\s*~', $groupDefinition, -1, PREG_SPLIT_NO_EMPTY) as $methodDefinition) {
$tokens = preg_split('~(?=[#@])~', $methodDefinition);
$method = array_shift($tokens);
$methodRequirement = [
'name' => $method !== '' ? $method : null,
'attributes' => [],
'annotations' => [],
];

foreach ($tokens as $token) {
$key = $token[0] === '#' ? 'attributes' : 'annotations';
$methodRequirement[$key][] = substr($token, 1);
}

$this->normalizedMethodGroups[$group][] = $methodRequirement;
}
}
}

return $this->normalizedMethodGroups;
}

/**
* @return array<string, int>
*/
Expand Down Expand Up @@ -585,17 +727,19 @@ private function getNormalizedGroups(): array
self::GROUP_MAGIC_METHODS,
];

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

if (
!in_array($groupOrShortcut, $supportedGroups, true)
&& !array_key_exists($groupOrShortcut, self::SHORTCUTS)
&& !array_key_exists($groupOrShortcut, $normalizedMethodGroups)
) {
throw new UnsupportedClassGroupException($groupOrShortcut);
}
Expand All @@ -608,7 +752,10 @@ private function getNormalizedGroups(): array

$normalizedGroups = [];
foreach ($normalizedGroupsWithShortcuts as $groupOrShortcut => $groupOrder) {
if (in_array($groupOrShortcut, $supportedGroups, true)) {
if (
in_array($groupOrShortcut, $supportedGroups, true)
|| array_key_exists($groupOrShortcut, $normalizedMethodGroups)
) {
$normalizedGroups[$groupOrShortcut] = $groupOrder;
} else {
foreach ($this->unpackShortcut($groupOrShortcut, $supportedGroups) as $group) {
Expand All @@ -624,10 +771,14 @@ private function getNormalizedGroups(): array
}
}

if ($normalizedGroups === []) {
if ($normalizedGroups === [] && $normalizedMethodGroups === []) {
$normalizedGroups = array_flip($supportedGroups);
} else {
$missingGroups = array_diff($supportedGroups, array_keys($normalizedGroups));
$missingGroups = array_diff(
array_merge($supportedGroups, array_keys($normalizedMethodGroups)),
array_keys($normalizedGroups)
);

if ($missingGroups !== []) {
throw new MissingClassGroupsException($missingGroups);
}
Expand Down
8 changes: 8 additions & 0 deletions doc/classes.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Checks that class/trait/interface members are in the correct order.
Sniff provides the following settings:

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

**List of supported groups**:
uses,
Expand All @@ -64,6 +65,10 @@ constants, properties, static properties, methods, all public methods, all prote
```xml
<rule ref="SlevomatCodingStandard.Classes.ClassStructure">
<properties>
<property name="methodGroups" type="array">
<element key="phpunit before" value="setUp, @before, #PHPUnit\Framework\Attributes\Before"/>
</property>

<property name="groups" type="array">
<element value="uses"/>

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

<!-- Constructor is first, then all public methods, then protected/private methods and magic methods are last -->
<element value="constructor"/>

<!-- PHPUnit's before hooks are placed before all other public methods using a custom method group regardless their visibility -->
<element value="phpunit before"/>
<element value="all public methods"/>
<element value="methods"/>
<element value="magic methods"/>
Expand Down
71 changes: 71 additions & 0 deletions tests/Sniffs/Classes/ClassStructureSniffTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,45 @@ class ClassStructureSniffTest extends TestCase
'protected static final methods',
];

private const METHOD_GROUPS = [
'phpunit before class' => 'setUpBeforeClass, @beforeClass, #PHPUnit\Framework\Attributes\BeforeClass',
'phpunit after class' => 'tearDownAfterClass, @afterClass, #PHPUnit\Framework\Attributes\AfterClass',
'phpunit before' => 'setUp, @before, #PHPUnit\Framework\Attributes\Before',
'phpunit after' => 'tearDown, @after, #PHPUnit\Framework\Attributes\After',
];

private const METHOD_GROUP_RULES = [
'uses',
'public constants',
'protected constants',
'private constants',
'enum cases',
'public static properties',
'protected static properties',
'private static properties',
'public properties',
'protected properties',
'private properties',
'constructor',
'static constructors',
'destructor',
'phpunit before class',
'phpunit after class',
'phpunit before',
'phpunit after',
'public methods, public final methods',
'public abstract methods',
'public static methods, public static final methods',
'public static abstract methods',
'magic methods',
'protected methods, protected final methods',
'protected abstract methods',
'protected static methods, protected static final methods',
'protected static abstract methods',
'private methods',
'private static methods',
];

public function testNoErrors(): void
{
$report = self::checkFile(__DIR__ . '/data/classStructureSniffNoErrors.php');
Expand Down Expand Up @@ -145,6 +184,38 @@ public function testErrorsWithShortcuts(): void
self::assertAllFixedInFile($report);
}

public function testNoErrorsWithMethodGroupRules(): void
{
$report = self::checkFile(
__DIR__ . '/data/classStructureSniffNoErrorsWithMethodGroupRules.php',
[
'methodGroups' => self::METHOD_GROUPS,
'groups' => self::METHOD_GROUP_RULES,
]
);

self::assertNoSniffErrorInFile($report);
}

public function testErrorsWithMethodGroupRules(): void
{
$report = self::checkFile(
__DIR__ . '/data/classStructureSniffErrorsWithMethodGroupRules.php',
[
'methodGroups' => self::METHOD_GROUPS,
'groups' => self::METHOD_GROUP_RULES,
]
);

self::assertSame(5, $report->getErrorCount());
self::assertSniffError($report, 22, ClassStructureSniff::CODE_INCORRECT_GROUP_ORDER);
self::assertSniffError($report, 33, ClassStructureSniff::CODE_INCORRECT_GROUP_ORDER);
self::assertSniffError($report, 44, ClassStructureSniff::CODE_INCORRECT_GROUP_ORDER);
self::assertSniffError($report, 48, ClassStructureSniff::CODE_INCORRECT_GROUP_ORDER);
self::assertSniffError($report, 67, ClassStructureSniff::CODE_INCORRECT_GROUP_ORDER);
self::assertAllFixedInFile($report);
}

public function testThrowExceptionForUnsupportedGroup(): void
{
try {
Expand Down
Loading

0 comments on commit ed6291b

Please sign in to comment.