From 3f8129b4222946a2d264fee0cbd2f8bb04990c80 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sat, 9 May 2026 15:22:11 +0200 Subject: [PATCH 1/2] Extend DocBlockTagOrder to class/interface/trait docblocks Adds a configurable `classOrder` property (default: template, extends, implements, property, property-read, property-write, method, mixin) so class-level docblocks get the same consistent tag ordering that function/method docblocks already enforce. Makes the existing `order` property public so both can be overridden via XML configuration. --- .../Commenting/DocBlockTagOrderSniff.php | 76 +++++++++++++++---- .../Commenting/DocBlockTagOrderSniffTest.php | 67 ++++++++++++++++ tests/_data/DocBlockTagOrder/after.php | 28 +++++++ tests/_data/DocBlockTagOrder/before.php | 28 +++++++ .../DocBlockTagOrder/custom-order.after.php | 13 ++++ .../DocBlockTagOrder/custom-order.before.php | 13 ++++ 6 files changed, 209 insertions(+), 16 deletions(-) create mode 100644 tests/PhpCollective/Sniffs/Commenting/DocBlockTagOrderSniffTest.php create mode 100644 tests/_data/DocBlockTagOrder/after.php create mode 100644 tests/_data/DocBlockTagOrder/before.php create mode 100644 tests/_data/DocBlockTagOrder/custom-order.after.php create mode 100644 tests/_data/DocBlockTagOrder/custom-order.before.php diff --git a/PhpCollective/Sniffs/Commenting/DocBlockTagOrderSniff.php b/PhpCollective/Sniffs/Commenting/DocBlockTagOrderSniff.php index f527b4d..8f9fa51 100644 --- a/PhpCollective/Sniffs/Commenting/DocBlockTagOrderSniff.php +++ b/PhpCollective/Sniffs/Commenting/DocBlockTagOrderSniff.php @@ -13,7 +13,7 @@ use PhpCollective\Traits\CommentingTrait; /** - * Method doc blocks should have a consistent order of tag types. + * Function/method and class/interface/trait doc blocks should have a consistent order of tag types. * * @author Mark Scherer * @license MIT @@ -23,11 +23,23 @@ class DocBlockTagOrderSniff extends AbstractSniff use CommentingTrait; /** - * All other tags will go above those + * Tag order for function/method docblocks. All other tags will go above those. + * + * Configurable via XML: + * + * ``` xml + * + * + * + * + * + * ``` + * + * Note: leading "at" sign on tag names is optional; it will be normalized. * * @var array */ - protected array $order = [ + public array $order = [ '@deprecated', '@see', '@param', @@ -35,12 +47,30 @@ class DocBlockTagOrderSniff extends AbstractSniff '@return', ]; + /** + * Tag order for class/interface/trait docblocks. All other tags will go above those. + * + * Configurable via XML the same way as `$order`. + * + * @var array + */ + public array $classOrder = [ + '@template', + '@extends', + '@implements', + '@property', + '@property-read', + '@property-write', + '@method', + '@mixin', + ]; + /** * @inheritDoc */ public function register(): array { - return [T_FUNCTION]; + return [T_FUNCTION, T_CLASS, T_INTERFACE, T_TRAIT]; } /** @@ -49,11 +79,14 @@ public function register(): array public function process(File $phpcsFile, $stackPtr): void { $tokens = $phpcsFile->getTokens(); + $isFunction = $tokens[$stackPtr]['code'] === T_FUNCTION; - // Don't mess with closures - $prevIndex = $phpcsFile->findPrevious(Tokens::$emptyTokens, $stackPtr - 1, null, true); - if (!$this->isGivenKind(Tokens::$methodPrefixes, $tokens[$prevIndex])) { - return; + if ($isFunction) { + // Don't mess with closures + $prevIndex = $phpcsFile->findPrevious(Tokens::$emptyTokens, $stackPtr - 1, null, true); + if (!$this->isGivenKind(Tokens::$methodPrefixes, $tokens[$prevIndex])) { + return; + } } $docBlockEndIndex = $this->findRelatedDocBlock($phpcsFile, $stackPtr); @@ -63,20 +96,23 @@ public function process(File $phpcsFile, $stackPtr): void $docBlockStartIndex = $tokens[$docBlockEndIndex]['comment_opener']; + $order = $isFunction ? $this->order : $this->classOrder; + $tags = $this->readTags($phpcsFile, $docBlockStartIndex, $docBlockEndIndex); - $tags = $this->checkAnnotationTagOrder($tags); + $tags = $this->checkAnnotationTagOrder($tags, $order); - $this->fixOrder($phpcsFile, $docBlockStartIndex, $docBlockEndIndex, $tags); + $this->fixOrder($phpcsFile, $docBlockStartIndex, $docBlockEndIndex, $tags, $order); } /** * @param array> $tags + * @param array $orderList * * @return array> */ - protected function checkAnnotationTagOrder(array $tags): array + protected function checkAnnotationTagOrder(array $tags, array $orderList): array { - $order = $this->getTagOrderMap(); + $order = $this->getTagOrderMap($orderList); $currentOrder = null; foreach ($tags as $i => $tag) { @@ -206,10 +242,11 @@ protected function getContent(array $tokens, int $start, int $end): string * @param int $docBlockStartIndex * @param int $docBlockEndIndex * @param array> $tags + * @param array $orderList * * @return void */ - protected function fixOrder(File $phpcsFile, int $docBlockStartIndex, int $docBlockEndIndex, array $tags): void + protected function fixOrder(File $phpcsFile, int $docBlockStartIndex, int $docBlockEndIndex, array $tags, array $orderList): void { $errors = []; foreach ($tags as $i => $tag) { @@ -231,7 +268,7 @@ protected function fixOrder(File $phpcsFile, int $docBlockStartIndex, int $docBl $phpcsFile->fixer->beginChangeset(); - $order = $this->getTagOrderMap(); + $order = $this->getTagOrderMap($orderList); $newOrder = []; foreach ($tags as $tag) { @@ -262,10 +299,17 @@ protected function fixOrder(File $phpcsFile, int $docBlockStartIndex, int $docBl } /** + * @param array $orderList + * * @return array */ - protected function getTagOrderMap(): array + protected function getTagOrderMap(array $orderList): array { - return array_flip($this->order); + $normalized = []; + foreach ($orderList as $tag) { + $normalized[] = str_starts_with($tag, '@') ? $tag : '@' . $tag; + } + + return array_flip($normalized); } } diff --git a/tests/PhpCollective/Sniffs/Commenting/DocBlockTagOrderSniffTest.php b/tests/PhpCollective/Sniffs/Commenting/DocBlockTagOrderSniffTest.php new file mode 100644 index 0000000..e7a68b9 --- /dev/null +++ b/tests/PhpCollective/Sniffs/Commenting/DocBlockTagOrderSniffTest.php @@ -0,0 +1,67 @@ +assertSnifferFindsFixableErrors($sniff, 2, 2); + } + + /** + * @return void + */ + public function testDocBlockTagOrderFixer(): void + { + $sniff = new DocBlockTagOrderSniff(); + $this->assertSnifferCanFixErrors($sniff, 2); + } + + /** + * Custom $classOrder set via property override (mirrors XML configuration). + * + * @return void + */ + public function testDocBlockTagOrderCustomClassOrder(): void + { + $this->prefix = 'custom-order.'; + + $sniff = new DocBlockTagOrderSniff(); + $sniff->classOrder = ['@mixin', '@method', '@property', '@extends']; + + $this->assertSnifferCanFixErrors($sniff, 1); + + $this->prefix = null; + } + + /** + * Tag names without leading "@" should be normalized. + * + * @return void + */ + public function testDocBlockTagOrderCustomClassOrderWithoutAtPrefix(): void + { + $this->prefix = 'custom-order.'; + + $sniff = new DocBlockTagOrderSniff(); + $sniff->classOrder = ['mixin', 'method', 'property', 'extends']; + + $this->assertSnifferCanFixErrors($sniff, 1); + + $this->prefix = null; + } +} diff --git a/tests/_data/DocBlockTagOrder/after.php b/tests/_data/DocBlockTagOrder/after.php new file mode 100644 index 0000000..5ff3017 --- /dev/null +++ b/tests/_data/DocBlockTagOrder/after.php @@ -0,0 +1,28 @@ + + * @property \Foo $Boards + * @property \Foo $LastUsers + * @method \Foo save(\Bar $entity, array $options = []) + * @method \Foo get(mixed $primaryKey, array $finder = 'all') + * @mixin \BehaviorOne + * @mixin \BehaviorTwo + */ +class FixMe +{ + /** + * @param string $foo + * @throws \RuntimeException + * @return string + */ + public function badOrder(string $foo): string + { + return $foo; + } +} diff --git a/tests/_data/DocBlockTagOrder/before.php b/tests/_data/DocBlockTagOrder/before.php new file mode 100644 index 0000000..aa5fe1d --- /dev/null +++ b/tests/_data/DocBlockTagOrder/before.php @@ -0,0 +1,28 @@ + + * @method \Foo get(mixed $primaryKey, array $finder = 'all') + * @property \Foo $LastUsers + * @mixin \BehaviorTwo + */ +class FixMe +{ + /** + * @return string + * @param string $foo + * @throws \RuntimeException + */ + public function badOrder(string $foo): string + { + return $foo; + } +} diff --git a/tests/_data/DocBlockTagOrder/custom-order.after.php b/tests/_data/DocBlockTagOrder/custom-order.after.php new file mode 100644 index 0000000..c2d469f --- /dev/null +++ b/tests/_data/DocBlockTagOrder/custom-order.after.php @@ -0,0 +1,13 @@ + Date: Sat, 9 May 2026 16:39:28 +0200 Subject: [PATCH 2/2] Strip blank-line separators when reordering tags When class/interface/trait docblocks have blank " * " separator lines between tag groups, the reordered output preserved them in now-arbitrary positions, splitting newly-coherent same-kind tag groups for no reason. This was caused by getEndIndex() walking back to the previous line without skipping blank docblock lines, so a tag's content range extended through any trailing blank line. The rebuilt content then re-emitted those blanks. Walk back to the actual content (T_DOC_COMMENT_STRING / TAG) instead. Multi-line tag content is unaffected. Adds a fixture that reproduces the case (mirrors a real-world CakePHP table where IDE-helper output had grown over time with blank-line separators). --- .../Commenting/DocBlockTagOrderSniff.php | 9 ++++----- .../Commenting/DocBlockTagOrderSniffTest.php | 17 +++++++++++++++++ .../DocBlockTagOrder/blank-lines.after.php | 17 +++++++++++++++++ .../DocBlockTagOrder/blank-lines.before.php | 19 +++++++++++++++++++ 4 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 tests/_data/DocBlockTagOrder/blank-lines.after.php create mode 100644 tests/_data/DocBlockTagOrder/blank-lines.before.php diff --git a/PhpCollective/Sniffs/Commenting/DocBlockTagOrderSniff.php b/PhpCollective/Sniffs/Commenting/DocBlockTagOrderSniff.php index 8f9fa51..9042f3e 100644 --- a/PhpCollective/Sniffs/Commenting/DocBlockTagOrderSniff.php +++ b/PhpCollective/Sniffs/Commenting/DocBlockTagOrderSniff.php @@ -189,13 +189,12 @@ protected function getEndIndex(array $tokens, int $index): int $index++; } - // Jump to the previous line - $currentLine = $tokens[$index]['line']; - while ($tokens[$index]['line'] === $currentLine) { + // Walk back to the line that actually contains this tag's content, + // skipping over any blank " * " separator lines so they are not folded + // into this tag's range and re-emitted in the rebuilt docblock. + while ($index > $startIndex && $tokens[$index]['code'] !== T_DOC_COMMENT_STRING && $tokens[$index]['code'] !== T_DOC_COMMENT_TAG) { $index--; } - // Fix for single line doc blocks - $index = max($index, $startIndex); return $this->getLastTokenOfLine($tokens, $index); } diff --git a/tests/PhpCollective/Sniffs/Commenting/DocBlockTagOrderSniffTest.php b/tests/PhpCollective/Sniffs/Commenting/DocBlockTagOrderSniffTest.php index e7a68b9..4f02afa 100644 --- a/tests/PhpCollective/Sniffs/Commenting/DocBlockTagOrderSniffTest.php +++ b/tests/PhpCollective/Sniffs/Commenting/DocBlockTagOrderSniffTest.php @@ -64,4 +64,21 @@ public function testDocBlockTagOrderCustomClassOrderWithoutAtPrefix(): void $this->prefix = null; } + + /** + * Blank-line separators between tag groups should be normalized away when tags are reordered, + * otherwise an orphan blank line may end up splitting a now-coherent same-kind tag group. + * + * @return void + */ + public function testDocBlockTagOrderNormalizesBlankLineSeparators(): void + { + $this->prefix = 'blank-lines.'; + + $sniff = new DocBlockTagOrderSniff(); + + $this->assertSnifferCanFixErrors($sniff, 1); + + $this->prefix = null; + } } diff --git a/tests/_data/DocBlockTagOrder/blank-lines.after.php b/tests/_data/DocBlockTagOrder/blank-lines.after.php new file mode 100644 index 0000000..01c2899 --- /dev/null +++ b/tests/_data/DocBlockTagOrder/blank-lines.after.php @@ -0,0 +1,17 @@ +