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

keradus / PHP-CS-Fixer / 17377459942

01 Sep 2025 12:19PM UTC coverage: 94.684% (-0.009%) from 94.693%
17377459942

push

github

web-flow
chore: `Tokens::offsetSet` - explicit validation of input (#9004)

1 of 5 new or added lines in 1 file covered. (20.0%)

306 existing lines in 60 files now uncovered.

28390 of 29984 relevant lines covered (94.68%)

45.5 hits per line

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

97.86
/src/Fixer/Comment/HeaderCommentFixer.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\Comment;
16

17
use PhpCsFixer\AbstractFixer;
18
use PhpCsFixer\ConfigurationException\InvalidFixerConfigurationException;
19
use PhpCsFixer\Fixer\ConfigurableFixerInterface;
20
use PhpCsFixer\Fixer\ConfigurableFixerTrait;
21
use PhpCsFixer\Fixer\WhitespacesAwareFixerInterface;
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\PregException;
30
use PhpCsFixer\Tokenizer\Token;
31
use PhpCsFixer\Tokenizer\Tokens;
32
use Symfony\Component\OptionsResolver\Options;
33

34
/**
35
 * @phpstan-type _AutogeneratedInputConfiguration array{
36
 *  comment_type?: 'PHPDoc'|'comment',
37
 *  header: string,
38
 *  location?: 'after_declare_strict'|'after_open',
39
 *  separate?: 'both'|'bottom'|'none'|'top',
40
 *  validator?: null|string,
41
 * }
42
 * @phpstan-type _AutogeneratedComputedConfiguration array{
43
 *  comment_type: 'PHPDoc'|'comment',
44
 *  header: string,
45
 *  location: 'after_declare_strict'|'after_open',
46
 *  separate: 'both'|'bottom'|'none'|'top',
47
 *  validator: null|string,
48
 * }
49
 *
50
 * @implements ConfigurableFixerInterface<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration>
51
 *
52
 * @author Antonio J. García Lagar <aj@garcialagar.es>
53
 *
54
 * @no-named-arguments Parameter names are not covered by the backward compatibility promise.
55
 */
56
final class HeaderCommentFixer extends AbstractFixer implements ConfigurableFixerInterface, WhitespacesAwareFixerInterface
57
{
58
    /** @use ConfigurableFixerTrait<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration> */
59
    use ConfigurableFixerTrait;
60

61
    /**
62
     * @internal
63
     */
64
    public const HEADER_PHPDOC = 'PHPDoc';
65

66
    /**
67
     * @internal
68
     */
69
    public const HEADER_COMMENT = 'comment';
70

71
    public function getDefinition(): FixerDefinitionInterface
72
    {
73
        return new FixerDefinition(
3✔
74
            'Add, replace or remove header comment.',
3✔
75
            [
3✔
76
                new CodeSample(
3✔
77
                    <<<'PHP'
3✔
78
                        <?php
79
                        declare(strict_types=1);
80

81
                        namespace A\B;
82

83
                        echo 1;
84

85
                        PHP,
3✔
86
                    [
3✔
87
                        'header' => 'Made with love.',
3✔
88
                    ]
3✔
89
                ),
3✔
90
                new CodeSample(
3✔
91
                    <<<'PHP'
3✔
92
                        <?php
93
                        declare(strict_types=1);
94

95
                        namespace A\B;
96

97
                        echo 1;
98

99
                        PHP,
3✔
100
                    [
3✔
101
                        'header' => 'Made with love.',
3✔
102
                        'comment_type' => self::HEADER_PHPDOC,
3✔
103
                        'location' => 'after_open',
3✔
104
                        'separate' => 'bottom',
3✔
105
                    ]
3✔
106
                ),
3✔
107
                new CodeSample(
3✔
108
                    <<<'PHP'
3✔
109
                        <?php
110
                        declare(strict_types=1);
111

112
                        namespace A\B;
113

114
                        echo 1;
115

116
                        PHP,
3✔
117
                    [
3✔
118
                        'header' => 'Made with love.',
3✔
119
                        'comment_type' => self::HEADER_COMMENT,
3✔
120
                        'location' => 'after_declare_strict',
3✔
121
                    ]
3✔
122
                ),
3✔
123
                new CodeSample(
3✔
124
                    <<<'PHP'
3✔
125
                        <?php
126
                        declare(strict_types=1);
127
                        /*
128
                         * Made with love.
129
                         *
130
                         * Extra content.
131
                         */
132
                        namespace A\B;
133

134
                        echo 1;
135

136
                        PHP,
3✔
137
                    [
3✔
138
                        'header' => 'Made with love.',
3✔
139
                        'validator' => '/Made with love(?P<EXTRA>.*)??/s',
3✔
140
                        'comment_type' => self::HEADER_COMMENT,
3✔
141
                        'location' => 'after_declare_strict',
3✔
142
                    ]
3✔
143
                ),
3✔
144
                new CodeSample(
3✔
145
                    <<<'PHP'
3✔
146
                        <?php
147
                        declare(strict_types=1);
148

149
                        /*
150
                         * Comment is not wanted here.
151
                         */
152

153
                        namespace A\B;
154

155
                        echo 1;
156

157
                        PHP,
3✔
158
                    [
3✔
159
                        'header' => '',
3✔
160
                    ]
3✔
161
                ),
3✔
162
            ]
3✔
163
        );
3✔
164
    }
165

166
    public function isCandidate(Tokens $tokens): bool
167
    {
168
        return $tokens->isMonolithicPhp() && !$tokens->isTokenKindFound(\T_OPEN_TAG_WITH_ECHO);
52✔
169
    }
170

171
    /**
172
     * {@inheritdoc}
173
     *
174
     * Must run before BlankLinesBeforeNamespaceFixer, SingleBlankLineBeforeNamespaceFixer, SingleLineCommentStyleFixer.
175
     * Must run after DeclareStrictTypesFixer, NoBlankLinesAfterPhpdocFixer.
176
     */
177
    public function getPriority(): int
178
    {
179
        // When this fixer is configured with ["separate" => "bottom", "comment_type" => "PHPDoc"]
180
        // and the target file has no namespace or declare() construct,
181
        // the fixed header comment gets trimmed by NoBlankLinesAfterPhpdocFixer if we run before it.
182
        return -30;
1✔
183
    }
184

185
    protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
186
    {
187
        $headerAsComment = $this->getHeaderAsComment();
41✔
188
        $location = $this->configuration['location'];
41✔
189
        $locationIndices = [];
41✔
190

191
        foreach (['after_open', 'after_declare_strict'] as $possibleLocation) {
41✔
192
            $locationIndex = $this->findHeaderCommentInsertionIndex($tokens, $possibleLocation);
41✔
193

194
            if (!isset($locationIndices[$locationIndex]) || $possibleLocation === $location) {
41✔
195
                $locationIndices[$locationIndex] = $possibleLocation;
41✔
196
            }
197
        }
198

199
        // pre-run to find existing comment, if dynamic content is allowed
200
        if (null !== $this->configuration['validator']) {
41✔
201
            foreach ($locationIndices as $possibleLocation) {
4✔
202
                // figure out where the comment should be placed
203
                $headerNewIndex = $this->findHeaderCommentInsertionIndex($tokens, $possibleLocation);
4✔
204

205
                // check if there is already a comment
206
                $headerCurrentIndex = $this->findHeaderCommentCurrentIndex($tokens, $headerAsComment, $headerNewIndex - 1);
4✔
207

208
                if (null === $headerCurrentIndex) {
4✔
209
                    continue;
3✔
210
                }
211
                $currentHeaderComment = $tokens[$headerCurrentIndex]->getContent();
4✔
212

213
                if ($this->doesTokenFulfillValidator($tokens[$headerCurrentIndex])) {
4✔
214
                    $headerAsComment = $currentHeaderComment;
4✔
215
                }
216
            }
217
        }
218

219
        foreach ($locationIndices as $possibleLocation) {
41✔
220
            // figure out where the comment should be placed
221
            $headerNewIndex = $this->findHeaderCommentInsertionIndex($tokens, $possibleLocation);
41✔
222

223
            // check if there is already a comment
224
            $headerCurrentIndex = $this->findHeaderCommentCurrentIndex($tokens, $headerAsComment, $headerNewIndex - 1);
41✔
225

226
            if (null === $headerCurrentIndex) {
41✔
227
                if ('' === $this->configuration['header'] || $possibleLocation !== $location) {
31✔
228
                    continue;
11✔
229
                }
230

231
                $this->insertHeader($tokens, $headerAsComment, $headerNewIndex);
29✔
232

233
                continue;
29✔
234
            }
235

236
            $currentHeaderComment = $tokens[$headerCurrentIndex]->getContent();
41✔
237
            $sameComment = $headerAsComment === $currentHeaderComment;
41✔
238
            $expectedLocation = $possibleLocation === $location;
41✔
239

240
            if (!$sameComment || !$expectedLocation) {
41✔
241
                if ($expectedLocation xor $sameComment) {
14✔
242
                    $this->removeHeader($tokens, $headerCurrentIndex);
13✔
243
                }
244

245
                if ('' === $this->configuration['header']) {
14✔
246
                    continue;
2✔
247
                }
248

249
                if ($possibleLocation === $location) {
12✔
250
                    $this->insertHeader($tokens, $headerAsComment, $headerNewIndex);
8✔
251
                }
252

253
                continue;
12✔
254
            }
255

256
            $this->fixWhiteSpaceAroundHeader($tokens, $headerCurrentIndex);
40✔
257
        }
258
    }
259

260
    protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
261
    {
262
        $fixerName = $this->getName();
70✔
263

264
        return new FixerConfigurationResolver([
70✔
265
            (new FixerOptionBuilder('header', 'Proper header content.'))
70✔
266
                ->setAllowedTypes(['string'])
70✔
267
                ->setNormalizer(static function (Options $options, string $value) use ($fixerName): string {
70✔
268
                    if ('' === trim($value)) {
53✔
269
                        return '';
10✔
270
                    }
271

272
                    if (str_contains($value, '*/')) {
44✔
273
                        throw new InvalidFixerConfigurationException($fixerName, 'Cannot use \'*/\' in header.');
1✔
274
                    }
275

276
                    return $value;
43✔
277
                })
70✔
278
                ->getOption(),
70✔
279
            (new FixerOptionBuilder('validator', 'RegEx validator for header content.'))
70✔
280
                ->setAllowedTypes(['string', 'null'])
70✔
281
                ->setNormalizer(static function (Options $options, ?string $value) use ($fixerName): ?string {
70✔
282
                    if (null !== $value) {
55✔
283
                        try {
284
                            Preg::match($value, '');
5✔
285
                        } catch (PregException $exception) {
1✔
286
                            throw new InvalidFixerConfigurationException($fixerName, 'Provided RegEx is not valid.');
1✔
287
                        }
288
                    }
289

290
                    return $value;
54✔
291
                })
70✔
292
                ->setDefault(null)
70✔
293
                ->getOption(),
70✔
294
            (new FixerOptionBuilder('comment_type', 'Comment syntax type.'))
70✔
295
                ->setAllowedValues([self::HEADER_PHPDOC, self::HEADER_COMMENT])
70✔
296
                ->setDefault(self::HEADER_COMMENT)
70✔
297
                ->getOption(),
70✔
298
            (new FixerOptionBuilder('location', 'The location of the inserted header.'))
70✔
299
                ->setAllowedValues(['after_open', 'after_declare_strict'])
70✔
300
                ->setDefault('after_declare_strict')
70✔
301
                ->getOption(),
70✔
302
            (new FixerOptionBuilder('separate', 'Whether the header should be separated from the file content with a new line.'))
70✔
303
                ->setAllowedValues(['both', 'top', 'bottom', 'none'])
70✔
304
                ->setDefault('both')
70✔
305
                ->getOption(),
70✔
306
        ]);
70✔
307
    }
308

309
    private function doesTokenFulfillValidator(Token $token): bool
310
    {
311
        if (null === $this->configuration['validator']) {
4✔
UNCOV
312
            throw new \LogicException(\sprintf("Cannot call '%s' method while missing config:validator.", __METHOD__));
×
313
        }
314
        $currentHeaderComment = $token->getContent();
4✔
315

316
        $lines = implode("\n", array_map(
4✔
317
            static fn (string $line): string => ' *' === $line ? '' : (str_starts_with($line, ' * ') ? substr($line, 3) : $line),
4✔
318
            \array_slice(explode("\n", $currentHeaderComment), 1, -1),
4✔
319
        ));
4✔
320

321
        return Preg::match($this->configuration['validator'], $lines);
4✔
322
    }
323

324
    /**
325
     * Enclose the given text in a comment block.
326
     */
327
    private function getHeaderAsComment(): string
328
    {
329
        $lineEnding = $this->whitespacesConfig->getLineEnding();
41✔
330
        $comment = (self::HEADER_COMMENT === $this->configuration['comment_type'] ? '/*' : '/**').$lineEnding;
41✔
331
        $lines = explode("\n", str_replace("\r", '', $this->configuration['header']));
41✔
332

333
        foreach ($lines as $line) {
41✔
334
            $comment .= rtrim(' * '.$line).$lineEnding;
41✔
335
        }
336

337
        return $comment.' */';
41✔
338
    }
339

340
    private function findHeaderCommentCurrentIndex(Tokens $tokens, string $headerAsComment, int $headerNewIndex): ?int
341
    {
342
        $index = $tokens->getNextNonWhitespace($headerNewIndex);
41✔
343

344
        if (null === $index || !$tokens[$index]->isComment()) {
41✔
345
            return null;
28✔
346
        }
347

348
        $next = $index + 1;
41✔
349

350
        if (!isset($tokens[$next]) || \in_array($this->configuration['separate'], ['top', 'none'], true) || !$tokens[$index]->isGivenKind(\T_DOC_COMMENT)) {
41✔
351
            return $index;
30✔
352
        }
353

354
        if ($tokens[$next]->isWhitespace()) {
15✔
355
            if (!Preg::match('/^\h*\R\h*$/D', $tokens[$next]->getContent())) {
15✔
356
                return $index;
9✔
357
            }
358

359
            ++$next;
9✔
360
        }
361

362
        if (!isset($tokens[$next]) || !$tokens[$next]->isClassy() && !$tokens[$next]->isGivenKind(\T_FUNCTION)) {
9✔
363
            return $index;
4✔
364
        }
365

366
        if (
367
            $headerAsComment === $tokens[$index]->getContent()
5✔
368
            || (null !== $this->configuration['validator'] && $this->doesTokenFulfillValidator($tokens[$index]))
5✔
369
        ) {
370
            return $index;
2✔
371
        }
372

373
        return null;
3✔
374
    }
375

376
    /**
377
     * Find the index where the header comment must be inserted.
378
     */
379
    private function findHeaderCommentInsertionIndex(Tokens $tokens, string $location): int
380
    {
381
        $openTagIndex = $tokens[0]->isGivenKind(\T_INLINE_HTML) ? 1 : 0;
41✔
382

383
        if ('after_open' === $location) {
41✔
384
            return $openTagIndex + 1;
41✔
385
        }
386

387
        $index = $tokens->getNextMeaningfulToken($openTagIndex);
41✔
388

389
        if (null === $index) {
41✔
390
            return $openTagIndex + 1; // file without meaningful tokens but an open tag, comment should always be placed directly after the open tag
3✔
391
        }
392

393
        if (!$tokens[$index]->isGivenKind(\T_DECLARE)) {
38✔
394
            return $openTagIndex + 1;
23✔
395
        }
396

397
        $next = $tokens->getNextMeaningfulToken($index);
15✔
398

399
        if (null === $next || !$tokens[$next]->equals('(')) {
15✔
UNCOV
400
            return $openTagIndex + 1;
×
401
        }
402

403
        $next = $tokens->getNextMeaningfulToken($next);
15✔
404

405
        if (null === $next || !$tokens[$next]->equals([\T_STRING, 'strict_types'], false)) {
15✔
406
            return $openTagIndex + 1;
1✔
407
        }
408

409
        $next = $tokens->getNextMeaningfulToken($next);
14✔
410

411
        if (null === $next || !$tokens[$next]->equals('=')) {
14✔
UNCOV
412
            return $openTagIndex + 1;
×
413
        }
414

415
        $next = $tokens->getNextMeaningfulToken($next);
14✔
416

417
        if (null === $next || !$tokens[$next]->isGivenKind(\T_LNUMBER)) {
14✔
UNCOV
418
            return $openTagIndex + 1;
×
419
        }
420

421
        $next = $tokens->getNextMeaningfulToken($next);
14✔
422

423
        if (null === $next || !$tokens[$next]->equals(')')) {
14✔
UNCOV
424
            return $openTagIndex + 1;
×
425
        }
426

427
        $next = $tokens->getNextMeaningfulToken($next);
14✔
428

429
        if (null === $next || !$tokens[$next]->equals(';')) { // don't insert after close tag
14✔
430
            return $openTagIndex + 1;
1✔
431
        }
432

433
        return $next + 1;
13✔
434
    }
435

436
    private function fixWhiteSpaceAroundHeader(Tokens $tokens, int $headerIndex): void
437
    {
438
        $lineEnding = $this->whitespacesConfig->getLineEnding();
40✔
439

440
        // fix lines after header comment
441
        if (
442
            ('both' === $this->configuration['separate'] || 'bottom' === $this->configuration['separate'])
40✔
443
            && null !== $tokens->getNextMeaningfulToken($headerIndex)
40✔
444
        ) {
445
            $expectedLineCount = 2;
33✔
446
        } else {
447
            $expectedLineCount = 1;
7✔
448
        }
449

450
        if ($headerIndex === \count($tokens) - 1) {
40✔
451
            $tokens->insertAt($headerIndex + 1, new Token([\T_WHITESPACE, str_repeat($lineEnding, $expectedLineCount)]));
1✔
452
        } else {
453
            $lineBreakCount = $this->getLineBreakCount($tokens, $headerIndex, 1);
40✔
454

455
            if ($lineBreakCount < $expectedLineCount) {
40✔
456
                $missing = str_repeat($lineEnding, $expectedLineCount - $lineBreakCount);
31✔
457

458
                if ($tokens[$headerIndex + 1]->isWhitespace()) {
31✔
459
                    $tokens[$headerIndex + 1] = new Token([\T_WHITESPACE, $missing.$tokens[$headerIndex + 1]->getContent()]);
15✔
460
                } else {
461
                    $tokens->insertAt($headerIndex + 1, new Token([\T_WHITESPACE, $missing]));
17✔
462
                }
463
            } elseif ($lineBreakCount > $expectedLineCount && $tokens[$headerIndex + 1]->isWhitespace()) {
40✔
464
                $newLinesToRemove = $lineBreakCount - $expectedLineCount;
2✔
465
                $tokens[$headerIndex + 1] = new Token([
2✔
466
                    \T_WHITESPACE,
2✔
467
                    Preg::replace("/^\\R{{$newLinesToRemove}}/", '', $tokens[$headerIndex + 1]->getContent()),
2✔
468
                ]);
2✔
469
            }
470
        }
471

472
        // fix lines before header comment
473
        $expectedLineCount = 'both' === $this->configuration['separate'] || 'top' === $this->configuration['separate'] ? 2 : 1;
40✔
474
        $prev = $tokens->getPrevNonWhitespace($headerIndex);
40✔
475

476
        $regex = '/\h$/';
40✔
477

478
        if ($tokens[$prev]->isGivenKind(\T_OPEN_TAG) && Preg::match($regex, $tokens[$prev]->getContent())) {
40✔
479
            $tokens[$prev] = new Token([\T_OPEN_TAG, Preg::replace($regex, $lineEnding, $tokens[$prev]->getContent())]);
1✔
480
        }
481

482
        $lineBreakCount = $this->getLineBreakCount($tokens, $headerIndex, -1);
40✔
483

484
        if ($lineBreakCount < $expectedLineCount) {
40✔
485
            // because of the way the insert index was determined for header comment there cannot be an empty token here
486
            $tokens->insertAt($headerIndex, new Token([\T_WHITESPACE, str_repeat($lineEnding, $expectedLineCount - $lineBreakCount)]));
34✔
487
        }
488
    }
489

490
    private function getLineBreakCount(Tokens $tokens, int $index, int $direction): int
491
    {
492
        $whitespace = '';
40✔
493

494
        for ($index += $direction; isset($tokens[$index]); $index += $direction) {
40✔
495
            $token = $tokens[$index];
40✔
496

497
            if ($token->isWhitespace()) {
40✔
498
                $whitespace .= $token->getContent();
40✔
499

500
                continue;
40✔
501
            }
502

503
            if (-1 === $direction && $token->isGivenKind(\T_OPEN_TAG)) {
40✔
504
                $whitespace .= $token->getContent();
35✔
505
            }
506

507
            if ('' !== $token->getContent()) {
40✔
508
                break;
40✔
509
            }
510
        }
511

512
        return substr_count($whitespace, "\n");
40✔
513
    }
514

515
    private function removeHeader(Tokens $tokens, int $index): void
516
    {
517
        $prevIndex = $index - 1;
13✔
518
        $prevToken = $tokens[$prevIndex];
13✔
519
        $newlineRemoved = false;
13✔
520

521
        if ($prevToken->isWhitespace()) {
13✔
522
            $content = $prevToken->getContent();
11✔
523

524
            if (Preg::match('/\R/', $content)) {
11✔
525
                $newlineRemoved = true;
9✔
526
            }
527

528
            $content = Preg::replace('/\R?\h*$/', '', $content);
11✔
529

530
            $tokens->ensureWhitespaceAtIndex($prevIndex, 0, $content);
11✔
531
        }
532

533
        $nextIndex = $index + 1;
13✔
534
        $nextToken = $tokens[$nextIndex] ?? null;
13✔
535

536
        if (!$newlineRemoved && null !== $nextToken && $nextToken->isWhitespace()) {
13✔
537
            $content = Preg::replace('/^\R/', '', $nextToken->getContent());
4✔
538

539
            $tokens->ensureWhitespaceAtIndex($nextIndex, 0, $content);
4✔
540
        }
541

542
        $tokens->clearTokenAndMergeSurroundingWhitespace($index);
13✔
543
    }
544

545
    private function insertHeader(Tokens $tokens, string $headerAsComment, int $index): void
546
    {
547
        $tokens->insertAt($index, new Token([self::HEADER_COMMENT === $this->configuration['comment_type'] ? \T_COMMENT : \T_DOC_COMMENT, $headerAsComment]));
37✔
548
        $this->fixWhiteSpaceAroundHeader($tokens, $index);
37✔
549
    }
550
}
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