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

keradus / PHP-CS-Fixer / 16018263876

02 Jul 2025 06:58AM UTC coverage: 94.846% (-0.002%) from 94.848%
16018263876

push

github

keradus
debug2

28193 of 29725 relevant lines covered (94.85%)

45.34 hits per line

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

97.39
/src/Fixer/ClassNotation/FinalInternalClassFixer.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of PHP CS Fixer.
7
 *
8
 * (c) Fabien Potencier <fabien@symfony.com>
9
 *     Dariusz Rumiński <dariusz.ruminski@gmail.com>
10
 *
11
 * This source file is subject to the MIT license that is bundled
12
 * with this source code in the file LICENSE.
13
 */
14

15
namespace PhpCsFixer\Fixer\ClassNotation;
16

17
use PhpCsFixer\AbstractFixer;
18
use PhpCsFixer\ConfigurationException\InvalidFixerConfigurationException;
19
use PhpCsFixer\DocBlock\DocBlock;
20
use PhpCsFixer\Fixer\ConfigurableFixerInterface;
21
use PhpCsFixer\Fixer\ConfigurableFixerTrait;
22
use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver;
23
use PhpCsFixer\FixerConfiguration\FixerConfigurationResolverInterface;
24
use PhpCsFixer\FixerConfiguration\FixerOptionBuilder;
25
use PhpCsFixer\FixerDefinition\CodeSample;
26
use PhpCsFixer\FixerDefinition\FixerDefinition;
27
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
28
use PhpCsFixer\Preg;
29
use PhpCsFixer\Tokenizer\CT;
30
use PhpCsFixer\Tokenizer\FCT;
31
use PhpCsFixer\Tokenizer\Token;
32
use PhpCsFixer\Tokenizer\Tokens;
33
use PhpCsFixer\Tokenizer\TokensAnalyzer;
34
use PhpCsFixer\Utils;
35
use Symfony\Component\OptionsResolver\Options;
36

37
/**
38
 * @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
39
 *
40
 * @implements ConfigurableFixerInterface<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration>
41
 *
42
 * @phpstan-type _AutogeneratedInputConfiguration array{
43
 *  annotation_exclude?: list<string>,
44
 *  annotation_include?: list<string>,
45
 *  consider_absent_docblock_as_internal_class?: bool,
46
 *  exclude?: list<string>,
47
 *  include?: list<string>,
48
 * }
49
 * @phpstan-type _AutogeneratedComputedConfiguration array{
50
 *  annotation_exclude: array<string, string>,
51
 *  annotation_include: array<string, string>,
52
 *  consider_absent_docblock_as_internal_class: bool,
53
 *  exclude: array<string, string>,
54
 *  include: array<string, string>,
55
 * }
56
 */
57
final class FinalInternalClassFixer extends AbstractFixer implements ConfigurableFixerInterface
58
{
59
    /** @use ConfigurableFixerTrait<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration> */
60
    use ConfigurableFixerTrait;
61

62
    private const DEFAULTS = [
63
        'include' => [
64
            'internal',
65
        ],
66
        'exclude' => [
67
            'final',
68
            'Entity',
69
            'ORM\Entity',
70
            'ORM\Mapping\Entity',
71
            'Mapping\Entity',
72
            'Document',
73
            'ODM\Document',
74
        ],
75
    ];
76
    private const CLASS_CANDIDATE_ACCEPT_TYPES = [
77
        CT::T_ATTRIBUTE_CLOSE,
78
        T_DOC_COMMENT,
79
        T_COMMENT, // Skip comments
80
        FCT::T_READONLY,
81
    ];
82

83
    private bool $checkAttributes;
84

85
    public function __construct()
86
    {
87
        parent::__construct();
42✔
88

89
        $this->checkAttributes = \PHP_VERSION_ID >= 8_00_00;
42✔
90
    }
91

92
    public function getDefinition(): FixerDefinitionInterface
93
    {
94
        return new FixerDefinition(
3✔
95
            'Internal classes should be `final`.',
3✔
96
            [
3✔
97
                new CodeSample("<?php\n/**\n * @internal\n */\nclass Sample\n{\n}\n"),
3✔
98
                new CodeSample(
3✔
99
                    "<?php\n/**\n * @CUSTOM\n */\nclass A{}\n\n/**\n * @CUSTOM\n * @not-fix\n */\nclass B{}\n",
3✔
100
                    [
3✔
101
                        'include' => ['@Custom'],
3✔
102
                        'exclude' => ['@not-fix'],
3✔
103
                    ]
3✔
104
                ),
3✔
105
            ],
3✔
106
            null,
3✔
107
            'Changing classes to `final` might cause code execution to break.'
3✔
108
        );
3✔
109
    }
110

111
    /**
112
     * {@inheritdoc}
113
     *
114
     * Must run before ProtectedToPrivateFixer, SelfStaticAccessorFixer.
115
     * Must run after PhpUnitInternalClassFixer.
116
     */
117
    public function getPriority(): int
118
    {
119
        return 67;
1✔
120
    }
121

122
    public function isCandidate(Tokens $tokens): bool
123
    {
124
        return $tokens->isTokenKindFound(T_CLASS);
30✔
125
    }
126

127
    public function isRisky(): bool
128
    {
129
        return true;
1✔
130
    }
131

132
    protected function configurePostNormalisation(): void
133
    {
134
        $this->assertConfigHasNoConflicts();
42✔
135
    }
136

137
    protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
138
    {
139
        $tokensAnalyzer = new TokensAnalyzer($tokens);
30✔
140

141
        for ($index = $tokens->count() - 1; 0 <= $index; --$index) {
30✔
142
            if (!$tokens[$index]->isGivenKind(T_CLASS) || !$this->isClassCandidate($tokensAnalyzer, $tokens, $index)) {
30✔
143
                continue;
30✔
144
            }
145

146
            // make class 'final'
147
            $tokens->insertSlices([
21✔
148
                $index => [
21✔
149
                    new Token([T_FINAL, 'final']),
21✔
150
                    new Token([T_WHITESPACE, ' ']),
21✔
151
                ],
21✔
152
            ]);
21✔
153
        }
154
    }
155

156
    protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
157
    {
158
        $annotationsAsserts = [static function (array $values): bool {
42✔
159
            foreach ($values as $value) {
42✔
160
                if ('' === $value) {
42✔
161
                    return false;
×
162
                }
163
            }
164

165
            return true;
42✔
166
        }];
42✔
167

168
        $annotationsNormalizer = static function (Options $options, array $value): array {
42✔
169
            $newValue = [];
42✔
170
            foreach ($value as $key) {
42✔
171
                if (str_starts_with($key, '@')) {
42✔
172
                    $key = substr($key, 1);
42✔
173
                }
174

175
                $newValue[strtolower($key)] = true;
42✔
176
            }
177

178
            return $newValue;
42✔
179
        };
42✔
180

181
        return new FixerConfigurationResolver([
42✔
182
            (new FixerOptionBuilder('annotation_include', 'Class level attribute or annotation tags that must be set in order to fix the class (case insensitive).'))
42✔
183
                ->setAllowedTypes(['string[]'])
42✔
184
                ->setAllowedValues($annotationsAsserts)
42✔
185
                ->setDefault(
42✔
186
                    array_map(
42✔
187
                        static fn (string $string) => '@'.$string,
42✔
188
                        self::DEFAULTS['include'],
42✔
189
                    ),
42✔
190
                )
42✔
191
                ->setNormalizer($annotationsNormalizer)
42✔
192
                ->setDeprecationMessage('Use `include` to configure PHPDoc annotations tags and attributes.')
42✔
193
                ->getOption(),
42✔
194
            (new FixerOptionBuilder('annotation_exclude', 'Class level attribute or annotation tags that must be omitted to fix the class, even if all of the white list ones are used as well (case insensitive).'))
42✔
195
                ->setAllowedTypes(['string[]'])
42✔
196
                ->setAllowedValues($annotationsAsserts)
42✔
197
                ->setDefault(
42✔
198
                    array_map(
42✔
199
                        static fn (string $string) => '@'.$string,
42✔
200
                        self::DEFAULTS['exclude'],
42✔
201
                    ),
42✔
202
                )
42✔
203
                ->setNormalizer($annotationsNormalizer)
42✔
204
                ->setDeprecationMessage('Use `exclude` to configure PHPDoc annotations tags and attributes.')
42✔
205
                ->getOption(),
42✔
206
            (new FixerOptionBuilder('include', 'Class level attribute or annotation tags that must be set in order to fix the class (case insensitive).'))
42✔
207
                ->setAllowedTypes(['string[]'])
42✔
208
                ->setAllowedValues($annotationsAsserts)
42✔
209
                ->setDefault(self::DEFAULTS['include'])
42✔
210
                ->setNormalizer($annotationsNormalizer)
42✔
211
                ->getOption(),
42✔
212
            (new FixerOptionBuilder('exclude', 'Class level attribute or annotation tags that must be omitted to fix the class, even if all of the white list ones are used as well (case insensitive).'))
42✔
213
                ->setAllowedTypes(['string[]'])
42✔
214
                ->setAllowedValues($annotationsAsserts)
42✔
215
                ->setDefault(self::DEFAULTS['exclude'])
42✔
216
                ->setNormalizer($annotationsNormalizer)
42✔
217
                ->getOption(),
42✔
218
            (new FixerOptionBuilder('consider_absent_docblock_as_internal_class', 'Whether classes without any DocBlock should be fixed to final.'))
42✔
219
                ->setAllowedTypes(['bool'])
42✔
220
                ->setDefault(false)
42✔
221
                ->getOption(),
42✔
222
        ]);
42✔
223
    }
224

225
    /**
226
     * @param int $index T_CLASS index
227
     */
228
    private function isClassCandidate(TokensAnalyzer $tokensAnalyzer, Tokens $tokens, int $index): bool
229
    {
230
        if ($tokensAnalyzer->isAnonymousClass($index)) {
30✔
231
            return false;
4✔
232
        }
233

234
        $modifiers = $tokensAnalyzer->getClassyModifiers($index);
27✔
235

236
        if (isset($modifiers['final']) || isset($modifiers['abstract'])) {
27✔
237
            return false; // ignore class; it is abstract or already final
20✔
238
        }
239

240
        $decisions = [];
27✔
241
        $currentIndex = $index;
27✔
242

243
        while (null !== $currentIndex) {
27✔
244
            $currentIndex = $tokens->getPrevNonWhitespace($currentIndex);
27✔
245

246
            if (!$tokens[$currentIndex]->isGivenKind(self::CLASS_CANDIDATE_ACCEPT_TYPES)) {
27✔
247
                break;
27✔
248
            }
249

250
            if ($this->checkAttributes && $tokens[$currentIndex]->isGivenKind(CT::T_ATTRIBUTE_CLOSE)) {
26✔
251
                $attributeStartIndex = $tokens->findBlockStart(Tokens::BLOCK_TYPE_ATTRIBUTE, $currentIndex);
12✔
252
                $decisions[] = $this->isClassCandidateBasedOnAttribute($tokens, $attributeStartIndex, $currentIndex);
12✔
253

254
                $currentIndex = $attributeStartIndex;
12✔
255
            }
256

257
            if ($tokens[$currentIndex]->isGivenKind([T_DOC_COMMENT])) {
26✔
258
                $decisions[] = $this->isClassCandidateBasedOnPhpDoc($tokens, $currentIndex);
17✔
259
            }
260
        }
261

262
        if (\in_array(false, $decisions, true)) {
27✔
263
            return false;
9✔
264
        }
265

266
        return \in_array(true, $decisions, true)
23✔
267
            || ([] === $decisions && true === $this->configuration['consider_absent_docblock_as_internal_class']);
23✔
268
    }
269

270
    private function isClassCandidateBasedOnPhpDoc(Tokens $tokens, int $index): ?bool
271
    {
272
        $doc = new DocBlock($tokens[$index]->getContent());
17✔
273
        $tags = [];
17✔
274

275
        foreach ($doc->getAnnotations() as $annotation) {
17✔
276
            if (!Preg::match('/@([^\(\s]+)/', $annotation->getContent(), $matches)) {
17✔
277
                continue;
1✔
278
            }
279
            $tag = strtolower(substr(array_shift($matches), 1));
16✔
280

281
            $tags[$tag] = true;
16✔
282
        }
283

284
        if (\count(array_intersect_key($this->configuration['exclude'], $tags)) > 0) {
17✔
285
            return false;
5✔
286
        }
287

288
        if ($this->isConfiguredAsInclude($tags)) {
16✔
289
            return true;
14✔
290
        }
291

292
        return null;
2✔
293
    }
294

295
    private function isClassCandidateBasedOnAttribute(Tokens $tokens, int $startIndex, int $endIndex): ?bool
296
    {
297
        $attributeCandidates = [];
12✔
298
        $attributeString = '';
12✔
299
        $currentIndex = $startIndex;
12✔
300

301
        while ($currentIndex < $endIndex && null !== ($currentIndex = $tokens->getNextMeaningfulToken($currentIndex))) {
12✔
302
            if (!$tokens[$currentIndex]->isGivenKind([T_STRING, T_NS_SEPARATOR])) {
12✔
303
                if ('' !== $attributeString) {
12✔
304
                    $attributeCandidates[$attributeString] = true;
12✔
305
                    $attributeString = '';
12✔
306
                }
307

308
                continue;
12✔
309
            }
310

311
            $attributeString .= strtolower($tokens[$currentIndex]->getContent());
12✔
312
        }
313

314
        if (\count(array_intersect_key($this->configuration['exclude'], $attributeCandidates)) > 0) {
12✔
315
            return false;
4✔
316
        }
317

318
        if ($this->isConfiguredAsInclude($attributeCandidates)) {
10✔
319
            return true;
9✔
320
        }
321

322
        return null;
3✔
323
    }
324

325
    /**
326
     * @param array<string, bool> $attributes
327
     */
328
    private function isConfiguredAsInclude(array $attributes): bool
329
    {
330
        if (0 === \count($this->configuration['include'])) {
24✔
331
            return true;
×
332
        }
333

334
        return \count(array_intersect_key($this->configuration['include'], $attributes)) > 0;
24✔
335
    }
336

337
    private function assertConfigHasNoConflicts(): void
338
    {
339
        foreach (['include' => 'annotation_include', 'exclude' => 'annotation_exclude'] as $newConfigKey => $oldConfigKey) {
42✔
340
            $defaults = [];
42✔
341

342
            foreach (self::DEFAULTS[$newConfigKey] as $foo) {
42✔
343
                $defaults[strtolower($foo)] = true;
42✔
344
            }
345

346
            $newConfigIsSet = $this->configuration[$newConfigKey] !== $defaults;
42✔
347
            $oldConfigIsSet = $this->configuration[$oldConfigKey] !== $defaults;
42✔
348

349
            if ($newConfigIsSet && $oldConfigIsSet) {
42✔
350
                throw new InvalidFixerConfigurationException($this->getName(), \sprintf('Configuration cannot contain deprecated option "%s" and new option "%s".', $oldConfigKey, $newConfigKey));
2✔
351
            }
352

353
            if ($oldConfigIsSet) {
42✔
354
                $this->configuration[$newConfigKey] = $this->configuration[$oldConfigKey]; // @phpstan-ignore-line crazy mapping, to be removed while cleaning up deprecated options
×
355
                $this->checkAttributes = false; // run in old mode
×
356
            }
357

358
            // if ($newConfigIsSet) - only new config is set, all good
359
            // if (!$newConfigIsSet && !$oldConfigIsSet) - both are set as to default values, all good
360

361
            unset($this->configuration[$oldConfigKey]); // @phpstan-ignore-line crazy mapping, to be removed while cleaning up deprecated options
42✔
362
        }
363

364
        $intersect = array_intersect_assoc($this->configuration['include'], $this->configuration['exclude']);
42✔
365

366
        if (\count($intersect) > 0) {
42✔
367
            throw new InvalidFixerConfigurationException($this->getName(), \sprintf('Annotation cannot be used in both "include" and "exclude" list, got duplicates: %s.', Utils::naturalLanguageJoin(array_keys($intersect))));
1✔
368
        }
369
    }
370
}
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