From a80b2c21a765c336509c1498599180086a72e03b Mon Sep 17 00:00:00 2001
From: Marek <stipek@shoptet.cz>
Date: Sat, 18 May 2024 17:19:44 +0200
Subject: [PATCH] Custom method groups order

---
 .../Sniffs/Classes/ClassStructureSniff.php    | 177 +++++++++++++++++-
 doc/classes.md                                |   8 +
 .../Classes/ClassStructureSniffTest.php       |  71 +++++++
 ...eSniffErrorsWithMethodGroupRules.fixed.php |  70 +++++++
 ...ructureSniffErrorsWithMethodGroupRules.php |  70 +++++++
 ...ctureSniffNoErrorsWithMethodGroupRules.php |  70 +++++++
 6 files changed, 461 insertions(+), 5 deletions(-)
 create mode 100644 tests/Sniffs/Classes/data/classStructureSniffErrorsWithMethodGroupRules.fixed.php
 create mode 100644 tests/Sniffs/Classes/data/classStructureSniffErrorsWithMethodGroupRules.php
 create mode 100644 tests/Sniffs/Classes/data/classStructureSniffNoErrorsWithMethodGroupRules.php

diff --git a/SlevomatCodingStandard/Sniffs/Classes/ClassStructureSniff.php b/SlevomatCodingStandard/Sniffs/Classes/ClassStructureSniff.php
index e01288ba7..ea76e49be 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;
@@ -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;
@@ -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;
 
@@ -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(
@@ -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);
@@ -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();
@@ -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>
 	 */
@@ -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);
 					}
@@ -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) {
@@ -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);
 				}
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 `<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,
@@ -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"/>
 
@@ -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"/>
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 @@
+<?php
+
+class ClassStructureSniffErrorsWithMethodGroupRulesData
+{
+	public function __construct()
+	{
+	}
+
+	public function __destruct()
+	{
+	}
+
+	public static function setUpBeforeClass()
+	{
+	}
+
+	#[PHPUnit\Framework\Attributes\BeforeClass]
+	public static function beforeClassUsingAttribute()
+	{
+	}
+
+	/**
+	 * @beforeClass
+	 */
+	public static function beforeClassUsingAnnotation()
+	{
+	}
+
+	#[PHPUnit\Framework\Attributes\Before]
+	protected function beforeUsingAttribute()
+	{
+	}
+
+	protected function setUp()
+	{
+	}
+
+	/**
+	 * @before
+	 */
+	protected function beforeUsingAnnotation()
+	{
+	}
+
+	#[PHPUnit\Framework\Attributes\After]
+	#[ReturnTypeWillChange]
+	protected function afterUsingAttribute()
+	{
+	}
+
+	/**
+	 * @after
+	 */
+	#[ReturnTypeWillChange]
+	protected function afterUsingAnnotation()
+	{
+	}
+
+	public function lorem()
+	{
+	}
+
+	protected function ipsum()
+	{
+	}
+
+	private function dolor()
+	{
+	}
+}
diff --git a/tests/Sniffs/Classes/data/classStructureSniffErrorsWithMethodGroupRules.php b/tests/Sniffs/Classes/data/classStructureSniffErrorsWithMethodGroupRules.php
new file mode 100644
index 000000000..5d4808a4b
--- /dev/null
+++ b/tests/Sniffs/Classes/data/classStructureSniffErrorsWithMethodGroupRules.php
@@ -0,0 +1,70 @@
+<?php
+
+class ClassStructureSniffErrorsWithMethodGroupRulesData
+{
+	public function __construct()
+	{
+	}
+
+	public function __destruct()
+	{
+	}
+
+	#[PHPUnit\Framework\Attributes\Before]
+	protected function beforeUsingAttribute()
+	{
+	}
+
+	public function lorem()
+	{
+	}
+
+	public static function setUpBeforeClass()
+	{
+	}
+
+	#[PHPUnit\Framework\Attributes\BeforeClass]
+	public static function beforeClassUsingAttribute()
+	{
+	}
+
+	#[PHPUnit\Framework\Attributes\After]
+	#[ReturnTypeWillChange]
+	protected function afterUsingAttribute()
+	{
+	}
+
+	protected function ipsum()
+	{
+	}
+
+	/**
+	 * @beforeClass
+	 */
+	public static function beforeClassUsingAnnotation()
+	{
+	}
+
+	protected function setUp()
+	{
+	}
+
+	/**
+	 * @before
+	 */
+	protected function beforeUsingAnnotation()
+	{
+	}
+
+	private function dolor()
+	{
+	}
+
+	/**
+	 * @after
+	 */
+	#[ReturnTypeWillChange]
+	protected function afterUsingAnnotation()
+	{
+	}
+}
diff --git a/tests/Sniffs/Classes/data/classStructureSniffNoErrorsWithMethodGroupRules.php b/tests/Sniffs/Classes/data/classStructureSniffNoErrorsWithMethodGroupRules.php
new file mode 100644
index 000000000..c6a2b530c
--- /dev/null
+++ b/tests/Sniffs/Classes/data/classStructureSniffNoErrorsWithMethodGroupRules.php
@@ -0,0 +1,70 @@
+<?php
+
+class ClassStructureSniffNoErrorsWithMethodGroupRulesData
+{
+	public function __construct()
+	{
+	}
+
+	public function __destruct()
+	{
+	}
+
+	public static function setUpBeforeClass()
+	{
+	}
+
+	#[PHPUnit\Framework\Attributes\BeforeClass]
+	public static function beforeClassUsingAttribute()
+	{
+	}
+
+	/**
+	 * @beforeClass
+	 */
+	public static function beforeClassUsingAnnotation()
+	{
+	}
+
+	protected function setUp()
+	{
+	}
+
+	#[PHPUnit\Framework\Attributes\Before]
+	protected function beforeUsingAttribute()
+	{
+	}
+
+	/**
+	 * @before
+	 */
+	protected function beforeUsingAnnotation()
+	{
+	}
+
+	#[PHPUnit\Framework\Attributes\After]
+	#[ReturnTypeWillChange]
+	protected function afterUsingAttribute()
+	{
+	}
+
+	/**
+	 * @after
+	 */
+	#[ReturnTypeWillChange]
+	protected function afterUsingAnnotation()
+	{
+	}
+
+	public function lorem()
+	{
+	}
+
+	protected function ipsum()
+	{
+	}
+
+	private function dolor()
+	{
+	}
+}