88use PhpParser \Node \AttributeGroup ;
99use PhpParser \Node \Stmt \Class_ ;
1010use PHPStan \Reflection \ReflectionProvider ;
11+ use Rector \Contract \Rector \ConfigurableRectorInterface ;
1112use Rector \Php80 \NodeAnalyzer \PhpAttributeAnalyzer ;
1213use Rector \PhpAttribute \NodeFactory \PhpAttributeGroupFactory ;
1314use Rector \PHPUnit \NodeAnalyzer \TestsNodeAnalyzer ;
15+ use Rector \PHPUnit \ValueObject \TestClassSuffixesConfig ;
1416use Rector \Rector \AbstractRector ;
15- use Symplify \RuleDocGenerator \ValueObject \CodeSample \CodeSample ;
17+ use Symplify \RuleDocGenerator \ValueObject \CodeSample \ConfiguredCodeSample ;
1618use Symplify \RuleDocGenerator \ValueObject \RuleDefinition ;
19+ use Webmozart \Assert \Assert ;
1720use function array_filter ;
1821use function array_merge ;
1922use function count ;
2427use function strtolower ;
2528use function trim ;
2629
27- final class AddCoversClassAttributeRector extends AbstractRector
30+ final class AddCoversClassAttributeRector extends AbstractRector implements ConfigurableRectorInterface
2831{
32+ /**
33+ * @var string[]
34+ */
35+ private array $ testClassSuffixes = ['Test ' , 'TestCase ' ];
36+
2937 public function __construct (
3038 private readonly ReflectionProvider $ reflectionProvider ,
3139 private readonly PhpAttributeGroupFactory $ phpAttributeGroupFactory ,
3240 private readonly PhpAttributeAnalyzer $ phpAttributeAnalyzer ,
33- private readonly TestsNodeAnalyzer $ testsNodeAnalyzer,
41+ private readonly TestsNodeAnalyzer $ testsNodeAnalyzer
3442 ) {
3543 }
3644
3745 public function getRuleDefinition (): RuleDefinition
3846 {
39- return new RuleDefinition ('Adds `#[CoversClass(...)]` attribute to test files guessing source class name. ' , [
40- new CodeSample (
41- <<<'CODE_SAMPLE'
42- class SomeService
43- {
44- }
45-
46- use PHPUnit\Framework\TestCase;
47-
48- class SomeServiceTest extends TestCase
49- {
50- }
51- CODE_SAMPLE
52- ,
53- <<<'CODE_SAMPLE'
54- class SomeService
55- {
56- }
57-
58- use PHPUnit\Framework\TestCase;
59- use PHPUnit\Framework\Attributes\CoversClass;
60-
61- #[CoversClass(SomeService::class)]
62- class SomeServiceTest extends TestCase
63- {
64- }
65- CODE_SAMPLE
66- ,
67- ),
68- ]);
47+ return new RuleDefinition (
48+ 'Adds `#[CoversClass(...)]` attribute to test files guessing source class name. ' ,
49+ [
50+ new ConfiguredCodeSample (
51+ <<<'CODE_SAMPLE'
52+ class SomeService
53+ {
54+ }
55+
56+ use PHPUnit\Framework\TestCase;
57+
58+ class SomeServiceFunctionalTest extends TestCase
59+ {
60+ }
61+ CODE_SAMPLE
62+ ,
63+ <<<'CODE_SAMPLE'
64+ class SomeService
65+ {
66+ }
67+
68+ use PHPUnit\Framework\TestCase;
69+ use PHPUnit\Framework\Attributes\CoversClass;
70+
71+ #[CoversClass(SomeService::class)]
72+ class SomeServiceFunctionalTest extends TestCase
73+ {
74+ }
75+ CODE_SAMPLE
76+ ,
77+ [new TestClassSuffixesConfig (['Test ' , 'TestCase ' , 'FunctionalTest ' , 'IntegrationTest ' ])]
78+ ),
79+ ]
80+ );
6981 }
7082
7183 /**
@@ -91,11 +103,13 @@ public function refactor(Node $node): ?Node
91103 return null ;
92104 }
93105
94- if ($ this ->phpAttributeAnalyzer ->hasPhpAttributes ($ node , [
95- 'PHPUnit \\Framework \\Attributes \\CoversNothing ' ,
96- 'PHPUnit \\Framework \\Attributes \\CoversClass ' ,
97- 'PHPUnit \\Framework \\Attributes \\CoversFunction ' ,
98- ])) {
106+ if (
107+ $ this ->phpAttributeAnalyzer ->hasPhpAttributes ($ node , [
108+ 'PHPUnit \\Framework \\Attributes \\CoversNothing ' ,
109+ 'PHPUnit \\Framework \\Attributes \\CoversClass ' ,
110+ 'PHPUnit \\Framework \\Attributes \\CoversFunction ' ,
111+ ])
112+ ) {
99113 return null ;
100114 }
101115
@@ -113,24 +127,45 @@ public function refactor(Node $node): ?Node
113127 return $ node ;
114128 }
115129
130+ /**
131+ * @param mixed[] $configuration
132+ */
133+ public function configure (array $ configuration ): void
134+ {
135+ Assert::countBetween ($ configuration , 0 , 1 );
136+
137+ if (isset ($ configuration [0 ])) {
138+ Assert::isInstanceOf ($ configuration [0 ], TestClassSuffixesConfig::class);
139+ $ this ->testClassSuffixes = $ configuration [0 ]->getSuffixes ();
140+ }
141+ }
142+
116143 /**
117144 * @return string[]
118145 */
119146 private function resolveSourceClassNames (string $ className ): array
120147 {
121148 $ classNameParts = explode ('\\' , $ className );
122149 $ partCount = count ($ classNameParts );
123- $ classNameParts [$ partCount - 1 ] = preg_replace (['#TestCase$# ' , '#Test$# ' ], '' , $ classNameParts [$ partCount - 1 ]);
150+
151+ // Sort suffixes by length (longest first) to ensure more specific patterns match first
152+ $ sortedSuffixes = $ this ->testClassSuffixes ;
153+ usort ($ sortedSuffixes , static fn (string $ a , string $ b ): int => strlen ($ b ) <=> strlen ($ a ));
154+
155+ $ patterns = [];
156+ foreach ($ sortedSuffixes as $ sortedSuffix ) {
157+ $ patterns [] = '# ' . preg_quote ($ sortedSuffix , '# ' ) . '$# ' ;
158+ }
159+
160+ $ classNameParts [$ partCount - 1 ] = preg_replace ($ patterns , '' , $ classNameParts [$ partCount - 1 ]);
124161
125162 $ possibleTestClassNames = [implode ('\\' , $ classNameParts )];
126163
127164 $ partsWithoutTests = array_filter (
128165 $ classNameParts ,
129- static fn (string |null $ part ): bool => $ part === null ? false : ! in_array (
130- strtolower ($ part ),
131- ['test ' , 'tests ' ],
132- true
133- ),
166+ static fn (string |null $ part ): bool => $ part === null
167+ ? false
168+ : ! in_array (strtolower ($ part ), ['test ' , 'tests ' ], true )
134169 );
135170
136171 $ possibleTestClassNames [] = implode ('\\' , $ partsWithoutTests );
0 commit comments