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

keradus / PHP-CS-Fixer / 17279562118

27 Aug 2025 09:47PM UTC coverage: 94.693%. Remained the same
17279562118

push

github

keradus
CS

28316 of 29903 relevant lines covered (94.69%)

45.61 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
 * @phpstan-type _AutogeneratedInputConfiguration array{
39
 *  annotation_exclude?: list<string>,
40
 *  annotation_include?: list<string>,
41
 *  consider_absent_docblock_as_internal_class?: bool,
42
 *  exclude?: list<string>,
43
 *  include?: list<string>,
44
 * }
45
 * @phpstan-type _AutogeneratedComputedConfiguration array{
46
 *  annotation_exclude: array<string, string>,
47
 *  annotation_include: array<string, string>,
48
 *  consider_absent_docblock_as_internal_class: bool,
49
 *  exclude: array<string, string>,
50
 *  include: array<string, string>,
51
 * }
52
 *
53
 * @implements ConfigurableFixerInterface<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration>
54
 *
55
 * @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
56
 *
57
 * @no-named-arguments Parameter names are not covered by the backward compatibility promise.
58
 */
59
final class FinalInternalClassFixer extends AbstractFixer implements ConfigurableFixerInterface
60
{
61
    /** @use ConfigurableFixerTrait<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration> */
62
    use ConfigurableFixerTrait;
63

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

85
    private bool $checkAttributes;
86

87
    public function __construct()
88
    {
89
        parent::__construct();
42✔
90

91
        $this->checkAttributes = \PHP_VERSION_ID >= 8_00_00;
42✔
92
    }
93

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

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

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

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

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

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

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

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

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

167
            return true;
42✔
168
        }];
42✔
169

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

177
                $newValue[strtolower($key)] = true;
42✔
178
            }
179

180
            return $newValue;
42✔
181
        };
42✔
182

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

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

236
        $modifiers = $tokensAnalyzer->getClassyModifiers($index);
27✔
237

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

242
        $decisions = [];
27✔
243
        $currentIndex = $index;
27✔
244

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

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

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

256
                $currentIndex = $attributeStartIndex;
12✔
257
            }
258

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

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

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

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

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

283
            $tags[$tag] = true;
16✔
284
        }
285

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

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

294
        return null;
2✔
295
    }
296

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

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

310
                continue;
12✔
311
            }
312

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

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

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

324
        return null;
3✔
325
    }
326

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

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

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

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

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

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

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

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

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

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

368
        if (\count($intersect) > 0) {
42✔
369
            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✔
370
        }
371
    }
372
}
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