Skip to content

Commit cafea0d

Browse files
committed
Adds the OrderedDocblock class and implements tag ordering for PHPDoc normalization.
Signed-off-by: Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
1 parent e8bc43a commit cafea0d

4 files changed

Lines changed: 174 additions & 10 deletions

File tree

src/Docblock/OrderedDocblock.php

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of fast-forward/dev-tools.
7+
*
8+
* This source file is subject to the license bundled
9+
* with this source code in the file LICENSE.
10+
*
11+
* @copyright Copyright (c) 2026 Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
12+
* @license https://opensource.org/licenses/MIT MIT License
13+
*
14+
* @see https://github.com/php-fast-forward/dev-tools
15+
* @see https://github.com/php-fast-forward
16+
* @see https://datatracker.ietf.org/doc/html/rfc2119
17+
*/
18+
19+
namespace FastForward\DevTools\Docblock;
20+
21+
use Override;
22+
use phootwork\collection\ArrayList;
23+
use phpowermove\docblock\Docblock;
24+
use phpowermove\docblock\tags\AbstractTag;
25+
use ReflectionClass;
26+
use ReflectionFunctionAbstract;
27+
use ReflectionProperty;
28+
29+
/**
30+
* Represents a Docblock that preserves and sorts tags in a defined order for PHPDoc normalization.
31+
*/
32+
final class OrderedDocblock extends Docblock
33+
{
34+
/**
35+
* Creates an OrderedDocblock instance from a docblock string or reflection.
36+
*
37+
* @param ReflectionFunctionAbstract|ReflectionClass|ReflectionProperty|string $docblock the docblock source
38+
*
39+
* @return self the created OrderedDocblock instance
40+
*/
41+
#[Override]
42+
public static function create(
43+
ReflectionFunctionAbstract|ReflectionClass|ReflectionProperty|string $docblock = ''
44+
): self {
45+
return new self($docblock);
46+
}
47+
48+
/**
49+
* Returns the tags sorted by the defined priority order: param, return, throws, then others.
50+
*
51+
* @return ArrayList<AbstractTag> the sorted tags
52+
*/
53+
#[Override]
54+
public function getSortedTags(): ArrayList
55+
{
56+
$tagOrder = [
57+
'param' => 10,
58+
'return' => 20,
59+
'throws' => 30,
60+
];
61+
62+
$indexedTags = [];
63+
64+
/** @var AbstractTag $tag */
65+
foreach ($this->getTags()->toArray() as $index => $tag) {
66+
$indexedTags[] = [
67+
'index' => $index,
68+
'tag' => $tag,
69+
];
70+
}
71+
72+
usort($indexedTags, static function (array $left, array $right) use ($tagOrder): int {
73+
/** @var AbstractTag $leftTag */
74+
$leftTag = $left['tag'];
75+
76+
/** @var AbstractTag $rightTag */
77+
$rightTag = $right['tag'];
78+
79+
$leftPriority = $tagOrder[$leftTag->getTagName()] ?? 1000;
80+
$rightPriority = $tagOrder[$rightTag->getTagName()] ?? 1000;
81+
82+
if ($leftPriority !== $rightPriority) {
83+
return $leftPriority <=> $rightPriority;
84+
}
85+
86+
$tagNameComparison = $leftTag->getTagName() <=> $rightTag->getTagName();
87+
if (0 !== $tagNameComparison) {
88+
return $tagNameComparison;
89+
}
90+
91+
return $left['index'] <=> $right['index'];
92+
});
93+
94+
$sorted = new ArrayList();
95+
96+
foreach ($indexedTags as $item) {
97+
$sorted->add($item['tag']);
98+
}
99+
100+
return $sorted;
101+
}
102+
}

src/Rector/AddMissingMethodPhpDocRector.php

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
namespace FastForward\DevTools\Rector;
2020

21+
use FastForward\DevTools\Docblock\OrderedDocblock;
2122
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
2223
use PHPStan\Reflection\ClassReflection;
2324
use phpowermove\docblock\Docblock;
@@ -93,7 +94,7 @@ public function refactor(Node $node): Node
9394

9495
$docComment = $node->getDocComment();
9596
$docblock = $docComment instanceof Doc
96-
? new Docblock($docComment->getText())
97+
? OrderedDocblock::create($docComment->getText())
9798
: $this->createDocblockFromReflection($node);
9899

99100
$existingParamVariables = $this->getExistingParamVariables($docblock);
@@ -381,37 +382,37 @@ private function resolveNameToString(Name $name): string
381382
*
382383
* @param ClassMethod $node the associated target structure explicitly handled internally
383384
*
384-
* @return Docblock the built virtualized docblock reference precisely retrieved natively
385+
* @return OrderedDocblock the built virtualized docblock reference precisely retrieved natively
385386
*/
386-
private function createDocblockFromReflection(ClassMethod $node): Docblock
387+
private function createDocblockFromReflection(ClassMethod $node): OrderedDocblock
387388
{
388389
$scope = ScopeFetcher::fetch($node);
389390
$classReflection = $scope->getClassReflection();
390391

391392
if (! $classReflection instanceof ClassReflection) {
392-
return new Docblock('/** */');
393+
return OrderedDocblock::create('/** */');
393394
}
394395

395396
$methodName = $this->getName($node->name);
396397

397398
if (null === $methodName) {
398-
return new Docblock('/** */');
399+
return OrderedDocblock::create('/** */');
399400
}
400401

401402
$nativeReflection = $classReflection->getNativeReflection();
402403

403404
if (! $nativeReflection->hasMethod($methodName)) {
404-
return new Docblock('/** */');
405+
return OrderedDocblock::create('/** */');
405406
}
406407

407408
$reflectionMethod = $nativeReflection->getMethod($methodName);
408409
$reflectionDocComment = $reflectionMethod->getDocComment();
409410

410411
if (! \is_string($reflectionDocComment) || '' === $reflectionDocComment) {
411-
return new Docblock('/** */');
412+
return OrderedDocblock::create('/** */');
412413
}
413414

414-
return new Docblock($reflectionDocComment);
415+
return OrderedDocblock::create($reflectionDocComment);
415416
}
416417

417418
/**
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of fast-forward/dev-tools.
7+
*
8+
* This source file is subject to the license bundled
9+
* with this source code in the file LICENSE.
10+
*
11+
* @copyright Copyright (c) 2026 Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
12+
* @license https://opensource.org/licenses/MIT MIT License
13+
*
14+
* @see https://github.com/php-fast-forward/dev-tools
15+
* @see https://github.com/php-fast-forward
16+
* @see https://datatracker.ietf.org/doc/html/rfc2119
17+
*/
18+
19+
namespace FastForward\DevTools\Tests\Docblock;
20+
21+
use FastForward\DevTools\Docblock\OrderedDocblock;
22+
use PHPUnit\Framework\Attributes\CoversClass;
23+
use PHPUnit\Framework\Attributes\Test;
24+
use PHPUnit\Framework\TestCase;
25+
26+
#[CoversClass(OrderedDocblock::class)]
27+
final class OrderedDocblockTest extends TestCase
28+
{
29+
/**
30+
* @return void
31+
*/
32+
#[Test]
33+
public function getSortedTagsWillOrderByPriority(): void
34+
{
35+
$docblock = "/**\n * @throws Exception\n * @param string \$a\n * @return int\n * @param int \$b\n */";
36+
$ordered = OrderedDocblock::create($docblock);
37+
$tags = $ordered->getSortedTags()
38+
->toArray();
39+
$tagNames = array_map(fn($tag) => $tag->getTagName(), $tags);
40+
self::assertSame(['param', 'param', 'return', 'throws'], $tagNames);
41+
}
42+
43+
/**
44+
* @return void
45+
*/
46+
#[Test]
47+
public function getSortedTagsWillPreserveOrderWithinGroups(): void
48+
{
49+
$docblock = "/**\n * @param string \$a\n * @param int \$b\n * @return int\n * @throws Exception\n */";
50+
$ordered = OrderedDocblock::create($docblock);
51+
$tags = $ordered->getSortedTags()
52+
->toArray();
53+
self::assertSame('param', $tags[0]->getTagName());
54+
self::assertSame('param', $tags[1]->getTagName());
55+
self::assertSame('return', $tags[2]->getTagName());
56+
self::assertSame('throws', $tags[3]->getTagName());
57+
}
58+
}

tests/Rector/AddMissingMethodPhpDocRectorTest.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
namespace FastForward\DevTools\Tests\Rector;
2020

21+
use FastForward\DevTools\Docblock\OrderedDocblock;
2122
use ReflectionClass;
2223
use Rector\NodeNameResolver\NodeNameResolver;
2324
use Rector\NodeAnalyzer\CallAnalyzer;
@@ -39,10 +40,12 @@
3940
use PhpParser\Node\Expr\Throw_;
4041
use PHPUnit\Framework\Attributes\CoversClass;
4142
use PHPUnit\Framework\Attributes\Test;
43+
use PHPUnit\Framework\Attributes\UsesClass;
4244
use PHPUnit\Framework\TestCase;
4345
use Prophecy\PhpUnit\ProphecyTrait;
4446

4547
#[CoversClass(AddMissingMethodPhpDocRector::class)]
48+
#[UsesClass(OrderedDocblock::class)]
4649
final class AddMissingMethodPhpDocRectorTest extends TestCase
4750
{
4851
use ProphecyTrait;
@@ -270,7 +273,7 @@ public function refactorWillInsertBlankLinesBetweenTagGroups(): void
270273
->getText();
271274

272275
// Should have blank lines between all different groups
273-
self::assertStringContainsString("@param string \$p\n *\n * @throws Exception\n *\n * @return int", $doc);
276+
self::assertStringContainsString("@param string \$p\n *\n * @return int\n *\n * @throws Exception", $doc);
274277
}
275278

276279
/**
@@ -370,7 +373,7 @@ public function refactorWillHandleMixedAndMessyTags(): void
370373

371374
// Should reorder and normalize spacing
372375
// Order is param, throws, return. Blank lines between DIFFERENT groups.
373-
self::assertStringContainsString("@param string \$a\n *\n * @throws \Exception\n *\n * @return void", $doc);
376+
self::assertStringContainsString("@param string \$a\n *\n * @return void\n *\n * @throws \Exception", $doc);
374377
}
375378

376379
/**

0 commit comments

Comments
 (0)