diff --git a/SlevomatCodingStandard/Sniffs/Classes/ClassStructureSniff.php b/SlevomatCodingStandard/Sniffs/Classes/ClassStructureSniff.php index e01288ba7..8d0786108 100644 --- a/SlevomatCodingStandard/Sniffs/Classes/ClassStructureSniff.php +++ b/SlevomatCodingStandard/Sniffs/Classes/ClassStructureSniff.php @@ -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; @@ -180,9 +183,15 @@ class ClassStructureSniff implements Sniff '__debuginfo' => self::GROUP_MAGIC_METHODS, ]; + /** @var array */ + public $methodGroups = []; + /** @var list */ public $groups = []; + /** @var array>|null */ + private $normalizedMethodGroups; + /** @var array|null */ private $normalizedGroups; @@ -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( @@ -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); @@ -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'])) { + continue; + } + + if ($requiredAnnotations = $methodRequirement['annotations']) { + if (!$this->hasRequiredAnnotations($phpcsFile, $pointer, $requiredAnnotations)) { + continue; + } + } + + if ($requiredAttributes = $methodRequirement['attributes']) { + if (!$this->hasRequiredAttributes($phpcsFile, $pointer, $requiredAttributes)) { + continue; + } + } + + return $group; + } + } + + return null; + } + + 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; + } + + 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; + } + + 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(); @@ -547,6 +654,41 @@ private function removeBlankLinesAfterMember(File $phpcsFile, int $memberEndPoin return $linesToRemove; } + /** + * @return array + */ + 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 */ @@ -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 $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); } @@ -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) { @@ -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); } diff --git a/doc/classes.md b/doc/classes.md index 5bbed250b..61404e80a 100644 --- a/doc/classes.md +++ b/doc/classes.md @@ -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 `` 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, @@ -64,6 +65,10 @@ constants, properties, static properties, methods, all public methods, all prote ```xml + + + + @@ -78,6 +83,9 @@ constants, properties, static properties, methods, all public methods, all prote + + + diff --git a/tests/Sniffs/Classes/ClassStructureSniffTest.php b/tests/Sniffs/Classes/ClassStructureSniffTest.php index ed5e5c524..c47df911e 100644 --- a/tests/Sniffs/Classes/ClassStructureSniffTest.php +++ b/tests/Sniffs/Classes/ClassStructureSniffTest.php @@ -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'); @@ -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 { diff --git a/tests/Sniffs/Classes/data/classStructureSniffErrorsWithMethodGroupRules.fixed.php b/tests/Sniffs/Classes/data/classStructureSniffErrorsWithMethodGroupRules.fixed.php new file mode 100644 index 000000000..5c1c66d51 --- /dev/null +++ b/tests/Sniffs/Classes/data/classStructureSniffErrorsWithMethodGroupRules.fixed.php @@ -0,0 +1,70 @@ +