From 851df63df2cdda97bc64668f841ced95b3c55e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20=C5=BD=C3=A1=C4=8Dek?= Date: Mon, 24 Nov 2025 15:50:04 +0100 Subject: [PATCH] Introduce rule SlevomatCodingStandard.Classes.ClassKeywordOrder --- README.md | 1 + .../Sniffs/Classes/ClassKeywordOrderSniff.php | 112 ++++++++++++++++++ build/phpcs.xml | 1 + doc/classes.md | 6 + .../Classes/ClassKeywordOrderSniffTest.php | 39 ++++++ .../data/classKeywordOrderErrors.fixed.php | 56 +++++++++ .../Classes/data/classKeywordOrderErrors.php | 56 +++++++++ .../data/classKeywordOrderNoErrors.php | 56 +++++++++ 8 files changed, 327 insertions(+) create mode 100644 SlevomatCodingStandard/Sniffs/Classes/ClassKeywordOrderSniff.php create mode 100644 tests/Sniffs/Classes/ClassKeywordOrderSniffTest.php create mode 100644 tests/Sniffs/Classes/data/classKeywordOrderErrors.fixed.php create mode 100644 tests/Sniffs/Classes/data/classKeywordOrderErrors.php create mode 100644 tests/Sniffs/Classes/data/classKeywordOrderNoErrors.php diff --git a/README.md b/README.md index 2f75f9353..198b59d94 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Slevomat Coding Standard for [PHP_CodeSniffer](https://github.com/PHPCSStandards - [SlevomatCodingStandard.Classes.DisallowStringExpressionPropertyFetch](doc/classes.md#slevomatcodingstandardclassesdisallowstringexpressionpropertyfetch-) 🔧 - [SlevomatCodingStandard.Classes.EmptyLinesAroundClassBraces](doc/classes.md#slevomatcodingstandardclassesemptylinesaroundclassbraces-) 🔧 - [SlevomatCodingStandard.Classes.EnumCaseSpacing](doc/classes.md#slevomatcodingstandardclassesenumcasespacing-) 🔧 + - [SlevomatCodingStandard.Classes.ClassKeywordOrder](doc/classes.md#slevomatcodingstandardclassesclasskeywordorder-) 🔧 - [SlevomatCodingStandard.Classes.ForbiddenPublicProperty](doc/classes.md#slevomatcodingstandardclassesforbiddenpublicproperty) - [SlevomatCodingStandard.Classes.MethodSpacing](doc/classes.md#slevomatcodingstandardclassesmethodspacing-) 🔧 - [SlevomatCodingStandard.Classes.ModernClassNameReference](doc/classes.md#slevomatcodingstandardclassesmodernclassnamereference-) 🔧 diff --git a/SlevomatCodingStandard/Sniffs/Classes/ClassKeywordOrderSniff.php b/SlevomatCodingStandard/Sniffs/Classes/ClassKeywordOrderSniff.php new file mode 100644 index 000000000..544ffc1a5 --- /dev/null +++ b/SlevomatCodingStandard/Sniffs/Classes/ClassKeywordOrderSniff.php @@ -0,0 +1,112 @@ + + */ + public function register(): array + { + return [ + T_CLASS, + ]; + } + + public function process(File $phpcsFile, int $stackPtr): void + { + $tokens = $phpcsFile->getTokens(); + + $modifierTokens = [ + T_ABSTRACT => 'abstract', + T_FINAL => 'final', + T_READONLY => 'readonly', + ]; + + $foundModifiers = []; + $currentIndex = TokenHelper::findPreviousEffective($phpcsFile, $stackPtr - 1); + + while ($currentIndex !== null && isset($modifierTokens[$tokens[$currentIndex]['code']])) { + $foundModifiers[$currentIndex] = $tokens[$currentIndex]['code']; + $currentIndex = TokenHelper::findPreviousEffective($phpcsFile, $currentIndex - 1); + } + + if (count($foundModifiers) === 0) { + return; + } + + ksort($foundModifiers); + + $actualOrderCodes = array_values($foundModifiers); + $actualOrderText = array_map(static fn ($code) => $modifierTokens[$code], $actualOrderCodes); + + $sortedModifiers = $foundModifiers; + uasort($sortedModifiers, static function ($a, $b) { + $priority = [ + T_ABSTRACT => 0, + T_FINAL => 0, + T_READONLY => 1, + ]; + return $priority[$a] <=> $priority[$b]; + }); + + $expectedOrderCodes = array_values($sortedModifiers); + $expectedOrderText = array_map(static fn ($code) => $modifierTokens[$code], $expectedOrderCodes); + + if ($actualOrderCodes === $expectedOrderCodes) { + return; + } + + $error = 'Class keywords are not in the correct order. Found: "%s class"; Expected: "%s class"'; + $data = [ + implode(' ', $actualOrderText), + implode(' ', $expectedOrderText), + ]; + + $fix = $phpcsFile->addFixableError($error, $stackPtr, self::CODE_WRONG_CLASS_KEYWORD_ORDER, $data); + + if ($fix !== true) { + return; + } + + $phpcsFile->fixer->beginChangeset(); + + foreach (array_keys($foundModifiers) as $ptr) { + $phpcsFile->fixer->replaceToken($ptr, ''); + + if ($tokens[$ptr + 1]['code'] === T_WHITESPACE) { + $phpcsFile->fixer->replaceToken($ptr + 1, ''); + } + } + + $firstModifierPtr = array_key_first($foundModifiers); + + $newContent = implode(' ', $expectedOrderText) . ' '; + + $phpcsFile->fixer->addContentBefore($firstModifierPtr, $newContent); + + $phpcsFile->fixer->endChangeset(); + } + +} diff --git a/build/phpcs.xml b/build/phpcs.xml index f7243821a..d6f415240 100644 --- a/build/phpcs.xml +++ b/build/phpcs.xml @@ -222,6 +222,7 @@ + diff --git a/doc/classes.md b/doc/classes.md index a5688ccf4..eaa09abad 100644 --- a/doc/classes.md +++ b/doc/classes.md @@ -161,6 +161,12 @@ Sniff provides the following settings: * `minLinesCountBeforeWithoutComment`: minimum number of lines before enum case without a documentation comment or attribute * `maxLinesCountBeforeWithoutComment`: maximum number of lines before enum case without a documentation comment or attribute +#### SlevomatCodingStandard.Classes.ClassKeywordOrder 🔧 + +Enforces the correct order of class modifiers (e.g., `final`, `abstract`, `readonly`). + +Required order is (final | abstract) readonly class. That is, use either `final` or `abstract` (never both), then `readonly` if present, then `class`. + #### SlevomatCodingStandard.Classes.ForbiddenPublicProperty Disallows using public properties. diff --git a/tests/Sniffs/Classes/ClassKeywordOrderSniffTest.php b/tests/Sniffs/Classes/ClassKeywordOrderSniffTest.php new file mode 100644 index 000000000..19e8c095c --- /dev/null +++ b/tests/Sniffs/Classes/ClassKeywordOrderSniffTest.php @@ -0,0 +1,39 @@ +getErrorCount()); + + self::assertSniffError( + $report, + 30, + ClassKeywordOrderSniff::CODE_WRONG_CLASS_KEYWORD_ORDER, + 'Class keywords are not in the correct order. Found: "readonly final class"; Expected: "final readonly class"', + ); + + self::assertSniffError( + $report, + 49, + ClassKeywordOrderSniff::CODE_WRONG_CLASS_KEYWORD_ORDER, + 'Class keywords are not in the correct order. Found: "readonly abstract class"; Expected: "abstract readonly class"', + ); + + self::assertAllFixedInFile($report); + } + +} diff --git a/tests/Sniffs/Classes/data/classKeywordOrderErrors.fixed.php b/tests/Sniffs/Classes/data/classKeywordOrderErrors.fixed.php new file mode 100644 index 000000000..163ac1750 --- /dev/null +++ b/tests/Sniffs/Classes/data/classKeywordOrderErrors.fixed.php @@ -0,0 +1,56 @@ += 8.2 + +class Foo1 +{ + + public function bar() + { + + } +} + +final class Foo2 +{ + + public function bar() + { + + } +} + +readonly class Foo3 +{ + + public function bar() + { + + } +} + +final readonly class Foo4 +{ + + public function bar() + { + + } +} + + +abstract class Foo5 +{ + + public function bar() + { + + } +} + +abstract readonly class Foo6 +{ + + public function bar() + { + + } +} diff --git a/tests/Sniffs/Classes/data/classKeywordOrderErrors.php b/tests/Sniffs/Classes/data/classKeywordOrderErrors.php new file mode 100644 index 000000000..b2cc0a46d --- /dev/null +++ b/tests/Sniffs/Classes/data/classKeywordOrderErrors.php @@ -0,0 +1,56 @@ += 8.2 + +class Foo1 +{ + + public function bar() + { + + } +} + +final class Foo2 +{ + + public function bar() + { + + } +} + +readonly class Foo3 +{ + + public function bar() + { + + } +} + +readonly final class Foo4 +{ + + public function bar() + { + + } +} + + +abstract class Foo5 +{ + + public function bar() + { + + } +} + +readonly abstract class Foo6 +{ + + public function bar() + { + + } +} diff --git a/tests/Sniffs/Classes/data/classKeywordOrderNoErrors.php b/tests/Sniffs/Classes/data/classKeywordOrderNoErrors.php new file mode 100644 index 000000000..163ac1750 --- /dev/null +++ b/tests/Sniffs/Classes/data/classKeywordOrderNoErrors.php @@ -0,0 +1,56 @@ += 8.2 + +class Foo1 +{ + + public function bar() + { + + } +} + +final class Foo2 +{ + + public function bar() + { + + } +} + +readonly class Foo3 +{ + + public function bar() + { + + } +} + +final readonly class Foo4 +{ + + public function bar() + { + + } +} + + +abstract class Foo5 +{ + + public function bar() + { + + } +} + +abstract readonly class Foo6 +{ + + public function bar() + { + + } +}