• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

systemsdk / phpcpd / #31

16 Feb 2026 08:55PM UTC coverage: 78.378% (+2.6%) from 75.818%
#31

push

DKravtsov
### Added

* Added `--ignore-no-files option` to return a success exit code if no files were found.
* Added `#[SuppressCpd]` to ignore code clones inside a class or method (`use Systemsdk\PhpCPD\Attributes\SuppressCpd;`).

### Updated

* Improved Suffix Tree-based algorithm for code clone detection.
* Updated Dev environment: Updated XDebug, Phing, dev composer dependencies.

129 of 150 new or added lines in 6 files covered. (86.0%)

2 existing lines in 2 files now uncovered.

841 of 1073 relevant lines covered (78.38%)

8.49 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.25
/src/Detector/SuppressionGuard.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Systemsdk\PhpCPD\Detector;
6

7
use function count;
8
use function file_get_contents;
9
use function is_array;
10
use function token_get_all;
11

12
use const T_ATTRIBUTE;
13
use const T_CURLY_OPEN;
14
use const T_DOLLAR_OPEN_CURLY_BRACES;
15
use const T_NAME_FULLY_QUALIFIED;
16
use const T_NAME_QUALIFIED;
17
use const T_STRING;
18

19
final class SuppressionGuard
20
{
21
    /**
22
     * @var array<string, array<int, array{start: int, end: int}>>
23
     */
24
    private array $cache = [];
25

26
    public function isLineSuppressed(string $file, int $line): bool
27
    {
28
        $ranges = $this->getSuppressedRanges($file);
26✔
29

30
        foreach ($ranges as $range) {
26✔
31
            if ($line >= $range['start'] && $line <= $range['end']) {
12✔
32
                return true;
12✔
33
            }
34
        }
35

36
        return false;
26✔
37
    }
38

39
    /**
40
     * Parses the file to find all #[SuppressCpd] attribute ranges.
41
     *
42
     * @return array<int, array{start: int, end: int}>
43
     */
44
    private function getSuppressedRanges(string $file): array
45
    {
46
        if (isset($this->cache[$file])) {
26✔
47
            return $this->cache[$file];
26✔
48
        }
49

50
        $this->cache[$file] = [];
26✔
51
        $content = @file_get_contents($file);
26✔
52

53
        if ($content === false) {
26✔
NEW
54
            return [];
×
55
        }
56

57
        $tokens = token_get_all($content);
26✔
58
        $count = count($tokens);
26✔
59

60
        for ($i = 0; $i < $count; $i++) {
26✔
61
            $token = $tokens[$i];
26✔
62

63
            // 1. Found the start of the attribute "#["
64
            if (!is_array($token) || $token[0] !== T_ATTRIBUTE) {
26✔
65
                continue;
26✔
66
            }
67

68
            // 2. Scan the attribute body until the VERY LAST "]" bracket
69
            $hasSuppressName = false;
14✔
70
            $nestingLevel = 0; // Square brackets nesting level inside the attribute
14✔
71
            $j = $i + 1;
14✔
72

73
            while ($j < $count) {
14✔
74
                $t = $tokens[$j];
14✔
75

76
                // Check for SuppressCpd name (handling simple names, FQCN, and aliased names)
77
                if (
78
                    is_array($t)
14✔
79
                    && (
80
                        $t[0] === T_STRING
14✔
81
                        || $t[0] === T_NAME_QUALIFIED
14✔
82
                        || $t[0] === T_NAME_FULLY_QUALIFIED
14✔
83
                    )
84
                    && str_contains($t[1], 'SuppressCpd')
14✔
85
                ) {
86
                    $hasSuppressName = true;
12✔
87
                }
88

89
                // Handle nested brackets [ ... ]
90
                if ($t === '[') {
14✔
91
                    $nestingLevel++;
4✔
92
                } elseif ($t === ']') {
14✔
93
                    if ($nestingLevel > 0) {
14✔
94
                        // Closing bracket of a nested array inside the attribute
95
                        // Example: #[Route(['path'])] - we are here
96
                        $nestingLevel--;
4✔
97
                    } else {
98
                        // Final closing bracket of the attribute itself
99
                        // Example: #[... , SuppressCpd] - we are here
100
                        break;
14✔
101
                    }
102
                } elseif ($t === ';' || $t === '{' || (is_array($t) && $t[0] === T_CURLY_OPEN)) {
14✔
NEW
103
                    break; // Safety guard: attribute didn't close properly before code started
×
104
                }
105

106
                $j++;
14✔
107
            }
108

109
            if (!$hasSuppressName) {
14✔
110
                continue;
2✔
111
            }
112

113
            // 3. Attribute found! Look for the code block (scope) it applies to
114
            // Start searching immediately after the attribute's closing bracket ($j)
115
            $braceBalance = 0;
12✔
116
            $parenBalance = 0; // Track parentheses in the method signature
12✔
117
            $foundOpeningBrace = false;
12✔
118
            $attrLine = (int)$token[2];
12✔
119

120
            for ($k = $j + 1; $k < $count; $k++) {
12✔
121
                $t = $tokens[$k];
12✔
122

123
                if (!$foundOpeningBrace) {
12✔
124
                    // Track parentheses to safely ignore anonymous classes defined in default arguments
125
                    if ($t === '(') {
12✔
126
                        $parenBalance++;
8✔
127

128
                        continue;
8✔
129
                    }
130
                    if ($t === ')') {
12✔
131
                        $parenBalance--;
8✔
132

133
                        continue;
8✔
134
                    }
135

136
                    // Support for properties and abstract methods (ending with ";")
137
                    // Guard: react to ';' only if we are not inside a method signature's parentheses
138
                    if ($t === ';' && $braceBalance === 0 && $parenBalance === 0) {
12✔
NEW
139
                        $this->saveRange($file, $attrLine, $this->findScopeEnd($k, $j, $tokens));
×
NEW
140
                        break;
×
141
                    }
142

143
                    // Open the block only if we are NOT inside a method signature ($parenBalance === 0)
144
                    if (
145
                        $parenBalance === 0
12✔
146
                        && (
147
                            $t === '{'
12✔
148
                            || (is_array($t) && ($t[0] === T_CURLY_OPEN || $t[0] === T_DOLLAR_OPEN_CURLY_BRACES))
12✔
149
                        )
150
                    ) {
151
                        $foundOpeningBrace = true;
12✔
152
                        $braceBalance++;
12✔
153
                    }
154

155
                    continue;
12✔
156
                }
157

158
                // Inside the block {...}
159
                if ($t === '{' || (is_array($t) && ($t[0] === T_CURLY_OPEN || $t[0] === T_DOLLAR_OPEN_CURLY_BRACES))) {
12✔
160
                    $braceBalance++;
4✔
161
                } elseif ($t === '}') {
12✔
162
                    $braceBalance--;
12✔
163

164
                    if ($braceBalance === 0) {
12✔
165
                        // Block closed
166
                        $this->saveRange($file, $attrLine, $this->findScopeEnd($k, $j, $tokens));
12✔
167
                        break;
12✔
168
                    }
169
                }
170
            }
171
        }
172

173
        return $this->cache[$file];
26✔
174
    }
175

176
    /**
177
     * Helper to find the end line number of the scope using lookbehind.
178
     *
179
     * @param array<int, string|array{0: int, 1: string, 2: int}> $tokens
180
     */
181
    private function findScopeEnd(int $currentIndex, int $limitIndex, array $tokens): int
182
    {
183
        // Go backwards from the current token to find the last token with a line number
184
        for ($z = $currentIndex; $z > $limitIndex; $z--) {
12✔
185
            if (isset($tokens[$z]) && is_array($tokens[$z])) {
12✔
186
                return (int)$tokens[$z][2];
12✔
187
            }
188
        }
189

190
        // Fallback
NEW
191
        return isset($tokens[$currentIndex]) && is_array($tokens[$currentIndex])
×
NEW
192
            ? (int)$tokens[$currentIndex][2]
×
NEW
193
            : 0;
×
194
    }
195

196
    private function saveRange(string $file, int $start, int $end): void
197
    {
198
        if ($end > 0) {
12✔
199
            $this->cache[$file][] = [
12✔
200
                'start' => $start,
12✔
201
                'end' => $end,
12✔
202
            ];
12✔
203
        }
204
    }
205
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc