EnforceAaaPatternRector::findFirstAssert()   A
last analyzed

Complexity

Conditions 4
Paths 3

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 4.128

Importance

Changes 2
Bugs 0 Features 1
Metric Value
cc 4
eloc 4
c 2
b 0
f 1
nc 3
nop 1
dl 0
loc 9
ccs 4
cts 5
cp 0.8
crap 4.128
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace SavinMikhail\EnforceAaaPatternRector;
6
7
use PhpParser\Comment;
8
use PhpParser\Node;
9
use PhpParser\Node\Expr\MethodCall;
10
use PhpParser\Node\Expr\StaticCall;
11
use PhpParser\Node\Stmt;
12
use PhpParser\Node\Stmt\ClassMethod;
13
use PhpParser\Node\Stmt\Expression;
14
use Rector\Rector\AbstractRector;
15
use Symplify\RuleDocGenerator\Exception\PoorDocumentationException;
16
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
17
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
18
19
use function in_array;
20
21
final class EnforceAaaPatternRector extends AbstractRector
22
{
23
    /**
24
     * @throws PoorDocumentationException
25
     */
26
    public function getRuleDefinition(): RuleDefinition
27
    {
28
        return new RuleDefinition(
29
            description: 'Enforce AAA (Arrange-Act-Assert) pattern in PHPUnit test methods',
30
            codeSamples: [
31
                new CodeSample(
32
                    badCode: <<<'PHP_WRAP'
33
                        final class FooTest extends PHPUnit\Framework\TestCase
34
                        {
35
                            public function testFoo(): void
36
                            {
37
                                $date = new DateTimeImmutable('2025-01-01');
38
                                $formatted = $date->format('Y-m-d');
39
                                $this->assertEquals('2025-01-01', $formatted);
40
                            }
41
                        }
42
                        PHP_WRAP,
43
                    goodCode: <<<'PHP_WRAP'
44
                        final class FooTest extends PHPUnit\Framework\TestCase
45
                        {
46
                            public function testFoo(): void
47
                            {
48
                                // Arrange
49
                                $date = new DateTimeImmutable('2025-01-01');
50
                                // Act
51
                                $formatted = $date->format('Y-m-d');
52
                                // Assert
53
                                $this->assertEquals('2025-01-01', $formatted);
54
                            }
55
                        }
56
                        PHP_WRAP,
57
                ),
58
            ],
59
        );
60
    }
61
62 7
    public function getNodeTypes(): array
63
    {
64 7
        return [ClassMethod::class];
65
    }
66
67 7
    private function isTestMethod(ClassMethod $classMethod): bool
68
    {
69 7
        $name = $this->getName(node: $classMethod);
70 7
        if (str_starts_with(haystack: $name, needle: 'test')) {
0 ignored issues
show
Bug introduced by
It seems like $name can also be of type null; however, parameter $haystack of str_starts_with() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

70
        if (str_starts_with(/** @scrutinizer ignore-type */ haystack: $name, needle: 'test')) {
Loading history...
71 6
            return true;
72
        }
73
74 1
        $docComment = $classMethod->getDocComment();
75
76 1
        return $docComment !== null && str_contains(haystack: strtolower(string: $docComment->getText()), needle: '@test');
77
    }
78
79 3
    private function isAaaComment(Comment $comment): bool
80
    {
81 3
        $text = trim(string: $comment->getText());
82 3
        $coreText = trim(string: (string) preg_replace(pattern: '/^\/\/\s*|^\/\*\s*|\s*\*\/$/', replacement: '', subject: $text));
83 3
        $coreTextLower = strtolower(string: $coreText);
84
85 3
        return in_array(needle: $coreTextLower, haystack: ['arrange', 'act', 'assert'], strict: true);
86
    }
87
88 6
    private function removeAaaComments(Node $stmt): void
89
    {
90 6
        $filteredComments = [];
91 6
        foreach ($stmt->getComments() as $comment) {
92 3
            if (! $this->isAaaComment(comment: $comment)) {
93 2
                $filteredComments[] = $comment;
94
            }
95
        }
96
97 6
        $stmt->setAttribute('comments', $filteredComments);
98
    }
99
100 6
    private function addAaaComment(Node $stmt, string $aaaComment): void
101
    {
102 6
        $existing = $stmt->getComments();
103 6
        $aaa = new Comment(text: '// ' . $aaaComment);
104 6
        $stmt->setAttribute('comments', array_merge([$aaa], $existing));
105
    }
106
107 6
    private function isAssertCall(Node\Expr $expr): bool
108
    {
109 6
        if ($expr instanceof MethodCall
110 6
            && $expr->var instanceof Node\Expr\Variable
111 6
            && $this->isName(node: $expr->var, name: 'this')
112
        ) {
113 5
            $methodName = $this->getName(node: $expr->name);
114
115 5
            return $methodName !== null && str_starts_with(haystack: $methodName, needle: 'assert');
116
        }
117
118 6
        if ($expr instanceof StaticCall
119 6
            && $expr->class instanceof Node\Name
120 6
            && $this->isName(node: $expr->class, name: 'self')
121
        ) {
122 1
            $methodName = $this->getName(node: $expr->name);
123
124 1
            return $methodName !== null && str_starts_with(haystack: $methodName, needle: 'assert');
125
        }
126
127 6
        return false;
128
    }
129
130
    /**
131
     * @param Stmt[] $stmts
132
     */
133 6
    private function findFirstAssert(array $stmts): ?int
134
    {
135 6
        foreach ($stmts as $i => $stmt) {
136 6
            if ($stmt instanceof Expression && $this->isAssertCall(expr: $stmt->expr)) {
137 6
                return $i;
138
            }
139
        }
140
141
        return null;
142
    }
143
144
    /**
145
     * @param Stmt[] $stmts
146
     */
147 5
    private function findLastNonAssert(array $stmts, int $firstAssertIndex): int
148
    {
149
        // Find the last statement before the first assert that is not an assert
150 5
        for ($i = $firstAssertIndex - 1; $i >= 0; --$i) {
151 5
            if (! $this->isAssertStatement(stmt: $stmts[$i])) {
152 5
                return $i;
153
            }
154
        }
155
156
        return $firstAssertIndex - 1; // fallback
157
    }
158
159 5
    private function isAssertStatement(Stmt $stmt): bool
160
    {
161 5
        return $stmt instanceof Expression && $this->isAssertCall(expr: $stmt->expr);
162
    }
163
164 7
    public function refactor(Node $node): ?Node
165
    {
166 7
        if (! $node instanceof ClassMethod) {
167
            return null;
168
        }
169
170 7
        if (! $this->isTestMethod(classMethod: $node)) {
171 1
            return null;
172
        }
173
174 6
        if ($node->stmts === null || $node->stmts === []) {
175
            return null;
176
        }
177
178 6
        $stmts = $node->stmts;
179
180 6
        $firstAssertIndex = $this->findFirstAssert(stmts: $stmts);
181
182 6
        if ($firstAssertIndex === null) {
183
            return null;
184
        }
185
186
        // Remove all existing AAA comments first
187 6
        foreach ($stmts as $stmt) {
188 6
            $this->removeAaaComments(stmt: $stmt);
189
        }
190
191
        // Handle simple case: only one statement before assert (treat as Act only)
192 6
        if ($firstAssertIndex === 1) {
193 1
            $this->addAaaComment(stmt: $stmts[0], aaaComment: 'Act');
194
        } else {
195
            // Multiple statements before assert
196
            // First statement gets "Arrange"
197 5
            if (isset($stmts[0])) {
198 5
                $this->addAaaComment(stmt: $stmts[0], aaaComment: 'Arrange');
199
            }
200
201
            // Last non-assert statement before first assert gets "Act"
202 5
            $lastActIndex = $this->findLastNonAssert(stmts: $stmts, firstAssertIndex: $firstAssertIndex);
203 5
            if ($lastActIndex > 0 && isset($stmts[$lastActIndex])) {
204 5
                $this->addAaaComment(stmt: $stmts[$lastActIndex], aaaComment: 'Act');
205
            }
206
        }
207
208
        // First assert gets "Assert"
209 6
        if (isset($stmts[$firstAssertIndex])) {
210 6
            $this->addAaaComment(stmt: $stmts[$firstAssertIndex], aaaComment: 'Assert');
211
        }
212
213 6
        return $node;
214
    }
215
}
216