Skip to content

Commit af90c37

Browse files
authored
Ensure PHPDocs are always above attributes for classes (#1)
1 parent fd21c32 commit af90c37

File tree

2 files changed

+173
-0
lines changed

2 files changed

+173
-0
lines changed
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ticketswap\PhpCsFixerConfig\Fixer;
6+
7+
use PhpCsFixer\AbstractFixer;
8+
use PhpCsFixer\FixerDefinition\FixerDefinition;
9+
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
10+
use PhpCsFixer\FixerDefinition\VersionSpecification;
11+
use PhpCsFixer\FixerDefinition\VersionSpecificCodeSample;
12+
use PhpCsFixer\Tokenizer\CT;
13+
use PhpCsFixer\Tokenizer\FCT;
14+
use PhpCsFixer\Tokenizer\Tokens;
15+
use SplFileInfo;
16+
17+
/**
18+
* Ensures PHPDoc comments are positioned above attributes on class declarations.
19+
*/
20+
final class PhpdocAboveAttributeFixer extends AbstractFixer
21+
{
22+
public function getDefinition() : FixerDefinitionInterface
23+
{
24+
return new FixerDefinition(
25+
'PHPDoc comment must be positioned above attributes on class declarations.',
26+
[
27+
new VersionSpecificCodeSample(
28+
<<<'EOL'
29+
<?php
30+
31+
#[AsAlias]
32+
/**
33+
* This is a class comment.
34+
*/
35+
class MyClass {}
36+
37+
EOL,
38+
new VersionSpecification(8_00_00),
39+
),
40+
],
41+
);
42+
}
43+
44+
public function isCandidate(Tokens $tokens) : bool
45+
{
46+
return $tokens->isTokenKindFound(\T_ATTRIBUTE) && $tokens->isTokenKindFound(\T_DOC_COMMENT);
47+
}
48+
49+
/**
50+
* {@inheritdoc}
51+
*
52+
* Must run before NoBlankLinesAfterPhpdocFixer.
53+
*/
54+
public function getPriority() : int
55+
{
56+
return 1;
57+
}
58+
59+
protected function applyFix(SplFileInfo $file, Tokens $tokens) : void
60+
{
61+
for ($index = $tokens->count() - 1; $index > 0; --$index) {
62+
if ( ! $tokens[$index]->isGivenKind(\T_CLASS)) {
63+
continue;
64+
}
65+
66+
// Find the start of the class declaration (including attributes and PHPDoc)
67+
$startIndex = $this->findClassDeclarationStart($tokens, $index);
68+
69+
// Look for PHPDoc and attributes between startIndex and class keyword
70+
$phpdocIndex = null;
71+
$firstAttributeIndex = null;
72+
73+
for ($i = $startIndex; $i < $index; ++$i) {
74+
if ($tokens[$i]->isGivenKind(\T_DOC_COMMENT)) {
75+
$phpdocIndex = $i;
76+
}
77+
78+
if ($tokens[$i]->isGivenKind(\T_ATTRIBUTE) && $firstAttributeIndex === null) {
79+
$firstAttributeIndex = $i;
80+
}
81+
}
82+
83+
// If we have both PHPDoc and attribute, and PHPDoc comes after attribute, we need to swap
84+
if ($phpdocIndex !== null && $firstAttributeIndex !== null && $phpdocIndex > $firstAttributeIndex) {
85+
$this->swapPhpdocAndAttributes($tokens, $phpdocIndex, $firstAttributeIndex, $index);
86+
}
87+
}
88+
}
89+
90+
private function findClassDeclarationStart(Tokens $tokens, int $classIndex) : int
91+
{
92+
$index = $classIndex;
93+
94+
while ($index > 0) {
95+
$prevIndex = $tokens->getPrevMeaningfulToken($index - 1);
96+
97+
if ($prevIndex === null) {
98+
break;
99+
}
100+
101+
// Stop if we hit another class/interface/trait or function
102+
if ($tokens[$prevIndex]->isGivenKind([\T_CLASS, \T_INTERFACE, \T_TRAIT, \T_FUNCTION, \T_CLOSE_TAG, \T_OPEN_TAG]) || $tokens[$prevIndex]->equals(';')) {
103+
return $index;
104+
}
105+
106+
// Continue past visibility modifiers, abstract, final, readonly
107+
if ($tokens[$prevIndex]->isGivenKind([\T_PUBLIC, \T_PROTECTED, \T_PRIVATE, \T_ABSTRACT, \T_FINAL, FCT::T_READONLY])) {
108+
$index = $prevIndex;
109+
110+
continue;
111+
}
112+
113+
// Continue past attributes
114+
if ($tokens[$prevIndex]->isGivenKind(CT::T_ATTRIBUTE_CLOSE)) {
115+
// Find the opening bracket of the attribute
116+
$attributeStart = $tokens->findBlockStart(Tokens::BLOCK_TYPE_ATTRIBUTE, $prevIndex);
117+
$index = $attributeStart;
118+
119+
continue;
120+
}
121+
122+
// Continue past PHPDoc
123+
if ($tokens[$prevIndex]->isGivenKind(\T_DOC_COMMENT)) {
124+
$index = $prevIndex;
125+
126+
continue;
127+
}
128+
129+
break;
130+
}
131+
132+
return $index;
133+
}
134+
135+
private function swapPhpdocAndAttributes(Tokens $tokens, int $phpdocIndex, int $firstAttributeIndex, int $classIndex) : void
136+
{
137+
// Find the end of the last attribute before the PHPDoc
138+
$lastAttributeEndIndex = $phpdocIndex - 1;
139+
while ($lastAttributeEndIndex > $firstAttributeIndex && $tokens[$lastAttributeEndIndex]->isWhitespace()) {
140+
--$lastAttributeEndIndex;
141+
}
142+
143+
if ( ! $tokens[$lastAttributeEndIndex]->isGivenKind(CT::T_ATTRIBUTE_CLOSE)) {
144+
return; // Safety check
145+
}
146+
147+
// Find the end of PHPDoc
148+
$phpdocEndIndex = $phpdocIndex;
149+
while ($phpdocEndIndex + 1 < $classIndex && $tokens[$phpdocEndIndex + 1]->isWhitespace()) {
150+
++$phpdocEndIndex;
151+
}
152+
153+
// Extract PHPDoc block
154+
$phpdocTokens = [];
155+
for ($j = $phpdocIndex; $j <= $phpdocEndIndex; ++$j) {
156+
$phpdocTokens[] = clone $tokens[$j];
157+
}
158+
159+
// Extract attribute block (attributes + whitespace after them up to PHPDoc)
160+
$attributeTokens = [];
161+
for ($j = $firstAttributeIndex; $j < $phpdocIndex; ++$j) {
162+
$attributeTokens[] = clone $tokens[$j];
163+
}
164+
165+
// Reconstruct in correct order: PHPDoc first, then attributes
166+
$newTokens = array_merge($phpdocTokens, $attributeTokens);
167+
168+
// Replace the entire range
169+
$tokens->overrideRange($firstAttributeIndex, $phpdocEndIndex, $newTokens);
170+
}
171+
}

src/RuleSet/TicketSwapRuleSet.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
use Symplify\CodingStandard\TokenRunner\Whitespace\IndentResolver;
2727
use Symplify\CodingStandard\TokenRunner\Wrapper\FixerWrapper\ArrayWrapperFactory;
2828
use Ticketswap\PhpCsFixerConfig\Fixer\AttributesNewLineFixer;
29+
use Ticketswap\PhpCsFixerConfig\Fixer\PhpdocAboveAttributeFixer;
2930
use Ticketswap\PhpCsFixerConfig\Fixers;
3031
use Ticketswap\PhpCsFixerConfig\NameWrapper;
3132
use Ticketswap\PhpCsFixerConfig\Rules;
@@ -56,6 +57,7 @@ public static function create() : RuleSet
5657
new Fixers(
5758
new LineBreakAfterStatementsFixer(),
5859
new NameWrapper(new AttributesNewLineFixer()),
60+
new NameWrapper(new PhpdocAboveAttributeFixer()),
5961
new NameWrapper(new ArrayListItemNewlineFixer($arrayItemNewliner, $arrayAnalyzer, $arrayBlockInfoFinder)),
6062
new NameWrapper(new ArrayOpenerAndCloserNewlineFixer($arrayBlockInfoFinder, $whitespacesFixerConfig, $arrayAnalyzer)),
6163
new NameWrapper(new StandaloneLineInMultilineArrayFixer($arrayWrapperFactory, $tokensNewliner, $blockfinder)),

0 commit comments

Comments
 (0)