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 a80b2c2
Show file tree
Hide file tree
Showing 6 changed files with 461 additions and 5 deletions.
177 changes: 172 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 All @@ -18,16 +21,21 @@
use function array_key_exists;
use function array_keys;
use function array_merge;
use function array_shift;
use function array_values;
use function assert;
use function implode;
use function in_array;
use function ltrim;
use function preg_replace;
use function preg_split;
use function sprintf;
use function str_repeat;
use function strtolower;
use function substr;
use const PREG_SPLIT_NO_EMPTY;
use const T_ABSTRACT;
use const T_ATTRIBUTE_END;
use const T_CLOSE_CURLY_BRACKET;
use const T_CONST;
use const T_ENUM_CASE;
Expand Down Expand Up @@ -180,9 +188,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<array{name: string|null, attributes: array<string>, annotations: array<string>}>>|null */
private $normalizedMethodGroups;

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

Expand Down Expand Up @@ -267,7 +281,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 +351,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 +406,108 @@ 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'])) {
continue;
}

if ($methodRequirement['annotations'] !== []) {
if (!$this->hasRequiredAnnotations($phpcsFile, $pointer, $methodRequirement['annotations'])) {
continue;
}
}

if ($methodRequirement['attributes'] !== []) {
if (!$this->hasRequiredAttributes($phpcsFile, $pointer, $methodRequirement['attributes'])) {
continue;
}
}

return $group;
}
}

return null;
}

/**
* @param array<string> $requiredAnnotations
*/
private function hasRequiredAnnotations(File $phpcsFile, int $pointer, array $requiredAnnotations): bool
{
$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;
}

/**
* @param array<string> $requiredAttributes
*/
private function hasRequiredAttributes(File $phpcsFile, int $pointer, array $requiredAttributes): bool
{
$attributesClassNames = $this->getAttributeClassNamesForToken($phpcsFile, $pointer);

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

return true;
}

/**
* @return array<string, string>
*/
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 +668,43 @@ private function removeBlankLinesAfterMember(File $phpcsFile, int $memberEndPoin
return $linesToRemove;
}

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

foreach ($methodGroups as $group => $groupDefinition) {
$group = strtolower((string) $group);
$this->normalizedMethodGroups[$group] = [];
$methodDefinitions = preg_split('~\\s*,\\s*~', (string) $groupDefinition, -1, PREG_SPLIT_NO_EMPTY);
/** @var list<string> $methodDefinitions */
foreach ($methodDefinitions as $methodDefinition) {
$tokens = preg_split('~(?=[#@])~', $methodDefinition);
/** @var array<string> $tokens */
$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 +743,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 +768,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 +787,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 a80b2c2

Please sign in to comment.