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

keradus / PHP-CS-Fixer / 13466172931

21 Feb 2025 10:17PM UTC coverage: 94.942% (+0.01%) from 94.928%
13466172931

push

github

keradus
Merge remote-tracking branch 'upstream/master' into header

14 of 27 new or added lines in 3 files covered. (51.85%)

6 existing lines in 2 files now uncovered.

28064 of 29559 relevant lines covered (94.94%)

43.05 hits per line

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

97.9
/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
 * @author Antonio J. García Lagar <aj@garcialagar.es>
36
 *
37
 * @implements ConfigurableFixerInterface<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration>
38
 *
39
 * @phpstan-type _AutogeneratedInputConfiguration array{
40
 *  comment_type?: 'PHPDoc'|'comment',
41
 *  header: string,
42
 *  location?: 'after_declare_strict'|'after_open',
43
 *  separate?: 'both'|'bottom'|'none'|'top',
44
 *  validator?: null|string
45
 * }
46
 * @phpstan-type _AutogeneratedComputedConfiguration array{
47
 *  comment_type: 'PHPDoc'|'comment',
48
 *  header: string,
49
 *  location: 'after_declare_strict'|'after_open',
50
 *  separate: 'both'|'bottom'|'none'|'top',
51
 *  validator: null|string
52
 * }
53
 */
54
final class HeaderCommentFixer extends AbstractFixer implements ConfigurableFixerInterface, WhitespacesAwareFixerInterface
55
{
56
    /** @use ConfigurableFixerTrait<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration> */
57
    use ConfigurableFixerTrait;
58

59
    /**
60
     * @internal
61
     */
62
    public const HEADER_PHPDOC = 'PHPDoc';
63

64
    /**
65
     * @internal
66
     */
67
    public const HEADER_COMMENT = 'comment';
68

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

78
namespace A\B;
79

80
echo 1;
81
',
3✔
82
                    [
3✔
83
                        'header' => 'Made with love.',
3✔
84
                    ]
3✔
85
                ),
3✔
86
                new CodeSample(
3✔
87
                    '<?php
3✔
88
declare(strict_types=1);
89

90
namespace A\B;
91

92
echo 1;
93
',
3✔
94
                    [
3✔
95
                        'header' => 'Made with love.',
3✔
96
                        'comment_type' => self::HEADER_PHPDOC,
3✔
97
                        'location' => 'after_open',
3✔
98
                        'separate' => 'bottom',
3✔
99
                    ]
3✔
100
                ),
3✔
101
                new CodeSample(
3✔
102
                    '<?php
3✔
103
declare(strict_types=1);
104

105
namespace A\B;
106

107
echo 1;
108
',
3✔
109
                    [
3✔
110
                        'header' => 'Made with love.',
3✔
111
                        'comment_type' => self::HEADER_COMMENT,
3✔
112
                        'location' => 'after_declare_strict',
3✔
113
                    ]
3✔
114
                ),
3✔
115
                new CodeSample(
3✔
116
                    '<?php
3✔
117
declare(strict_types=1);
118
/*
119
 * Made with love.
120
 *
121
 * Extra content.
122
 */
123
namespace A\B;
124

125
echo 1;
126
',
3✔
127
                    [
3✔
128
                        'header' => 'Made with love.',
3✔
129
                        'validator' => '/Made with love(?P<EXTRA>.*)??/s',
3✔
130
                        'comment_type' => self::HEADER_COMMENT,
3✔
131
                        'location' => 'after_declare_strict',
3✔
132
                    ]
3✔
133
                ),
3✔
134
                new CodeSample(
3✔
135
                    '<?php
3✔
136
declare(strict_types=1);
137

138
/*
139
 * Comment is not wanted here.
140
 */
141

142
namespace A\B;
143

144
echo 1;
145
',
3✔
146
                    [
3✔
147
                        'header' => '',
3✔
148
                    ]
3✔
149
                ),
3✔
150
            ]
3✔
151
        );
3✔
152
    }
153

154
    public function isCandidate(Tokens $tokens): bool
155
    {
156
        return $tokens->isMonolithicPhp() && !$tokens->isTokenKindFound(T_OPEN_TAG_WITH_ECHO);
49✔
157
    }
158

159
    /**
160
     * {@inheritdoc}
161
     *
162
     * Must run before BlankLinesBeforeNamespaceFixer, SingleBlankLineBeforeNamespaceFixer, SingleLineCommentStyleFixer.
163
     * Must run after DeclareStrictTypesFixer, NoBlankLinesAfterPhpdocFixer.
164
     */
165
    public function getPriority(): int
166
    {
167
        // When this fixer is configured with ["separate" => "bottom", "comment_type" => "PHPDoc"]
168
        // and the target file has no namespace or declare() construct,
169
        // the fixed header comment gets trimmed by NoBlankLinesAfterPhpdocFixer if we run before it.
170
        return -30;
1✔
171
    }
172

173
    protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
174
    {
175
        $headerAsComment = $this->getHeaderAsComment();
38✔
176
        $location = $this->configuration['location'];
38✔
177
        $locationIndices = [];
38✔
178

179
        foreach (['after_open', 'after_declare_strict'] as $possibleLocation) {
38✔
180
            $locationIndex = $this->findHeaderCommentInsertionIndex($tokens, $possibleLocation);
38✔
181

182
            if (!isset($locationIndices[$locationIndex]) || $possibleLocation === $location) {
38✔
183
                $locationIndices[$locationIndex] = $possibleLocation;
38✔
184
            }
185
        }
186

187
        // pre-run to find existing comment, if dynamic content is allowed
188
        if (null !== $this->configuration['validator']) {
38✔
189
            foreach ($locationIndices as $possibleLocation) {
4✔
190
                // figure out where the comment should be placed
191
                $headerNewIndex = $this->findHeaderCommentInsertionIndex($tokens, $possibleLocation);
4✔
192

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

196
                if (null === $headerCurrentIndex) {
4✔
197
                    continue;
3✔
198
                }
199
                $currentHeaderComment = $tokens[$headerCurrentIndex]->getContent();
4✔
200

201
                $lines = implode("\n", array_map(
4✔
202
                    static fn (string $line): string => ' *' === $line ? '' : (str_starts_with($line, ' * ') ? substr($line, 3) : $line),
4✔
203
                    \array_slice(explode("\n", $currentHeaderComment), 1, -1),
4✔
204
                ));
4✔
205

206
                if ($this->doesTokenFulfillValidator($tokens[$headerCurrentIndex])) {
4✔
207
                    $headerAsComment = $currentHeaderComment;
4✔
208
                }
209
            }
210
        }
211

212
        foreach ($locationIndices as $possibleLocation) {
38✔
213
            // figure out where the comment should be placed
214
            $headerNewIndex = $this->findHeaderCommentInsertionIndex($tokens, $possibleLocation);
38✔
215

216
            // check if there is already a comment
217
            $headerCurrentIndex = $this->findHeaderCommentCurrentIndex($tokens, $headerAsComment, $headerNewIndex - 1);
38✔
218

219
            if (null === $headerCurrentIndex) {
38✔
220
                if ('' === $this->configuration['header'] || $possibleLocation !== $location) {
28✔
221
                    continue;
11✔
222
                }
223

224
                $this->insertHeader($tokens, $headerAsComment, $headerNewIndex);
26✔
225

226
                continue;
26✔
227
            }
228

229
            $currentHeaderComment = $tokens[$headerCurrentIndex]->getContent();
38✔
230
            $sameComment = $headerAsComment === $currentHeaderComment;
38✔
231
            $expectedLocation = $possibleLocation === $location;
38✔
232

233
            if (!$sameComment || !$expectedLocation) {
38✔
234
                if ($expectedLocation xor $sameComment) {
14✔
235
                    $this->removeHeader($tokens, $headerCurrentIndex);
13✔
236
                }
237

238
                if ('' === $this->configuration['header']) {
14✔
239
                    continue;
2✔
240
                }
241

242
                if ($possibleLocation === $location) {
12✔
243
                    $this->insertHeader($tokens, $headerAsComment, $headerNewIndex);
8✔
244
                }
245

246
                continue;
12✔
247
            }
248

249
            $this->fixWhiteSpaceAroundHeader($tokens, $headerCurrentIndex);
37✔
250
        }
251
    }
252

253
    protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
254
    {
255
        $fixerName = $this->getName();
65✔
256

257
        return new FixerConfigurationResolver([
65✔
258
            (new FixerOptionBuilder('header', 'Proper header content.'))
65✔
259
                ->setAllowedTypes(['string'])
65✔
260
                ->setNormalizer(static function (Options $options, string $value) use ($fixerName): string {
65✔
261
                    if ('' === trim($value)) {
50✔
262
                        return '';
10✔
263
                    }
264

265
                    if (str_contains($value, '*/')) {
41✔
266
                        throw new InvalidFixerConfigurationException($fixerName, 'Cannot use \'*/\' in header.');
1✔
267
                    }
268

269
                    return $value;
40✔
270
                })
65✔
271
                ->getOption(),
65✔
272
            (new FixerOptionBuilder('validator', 'RegEx validator for header content.'))
65✔
273
                ->setAllowedTypes(['string', 'null'])
65✔
274
                ->setNormalizer(static function (Options $options, ?string $value) use ($fixerName): ?string {
65✔
275
                    if (null !== $value) {
52✔
276
                        try {
277
                            Preg::match($value, '');
5✔
278
                        } catch (PregException $exception) {
1✔
279
                            throw new InvalidFixerConfigurationException($fixerName, 'Provided RegEx is not valid.');
1✔
280
                        }
281
                    }
282

283
                    return $value;
51✔
284
                })
65✔
285
                ->setDefault(null)
65✔
286
                ->getOption(),
65✔
287
            (new FixerOptionBuilder('comment_type', 'Comment syntax type.'))
65✔
288
                ->setAllowedValues([self::HEADER_PHPDOC, self::HEADER_COMMENT])
65✔
289
                ->setDefault(self::HEADER_COMMENT)
65✔
290
                ->getOption(),
65✔
291
            (new FixerOptionBuilder('location', 'The location of the inserted header.'))
65✔
292
                ->setAllowedValues(['after_open', 'after_declare_strict'])
65✔
293
                ->setDefault('after_declare_strict')
65✔
294
                ->getOption(),
65✔
295
            (new FixerOptionBuilder('separate', 'Whether the header should be separated from the file content with a new line.'))
65✔
296
                ->setAllowedValues(['both', 'top', 'bottom', 'none'])
65✔
297
                ->setDefault('both')
65✔
298
                ->getOption(),
65✔
299
        ]);
65✔
300
    }
301

302
    private function doesTokenFulfillValidator(Token $token): bool
303
    {
304
        if (null === $this->configuration['validator']) {
4✔
UNCOV
305
            throw new \LogicException(\sprintf("Cannot call '%s' method while missing config:validator.", __METHOD__));
×
306
        }
307
        $currentHeaderComment = $token->getContent();
4✔
308

309
        $lines = implode("\n", array_map(
4✔
310
            static fn (string $line): string => ' *' === $line ? '' : (str_starts_with($line, ' * ') ? substr($line, 3) : $line),
4✔
311
            \array_slice(explode("\n", $currentHeaderComment), 1, -1),
4✔
312
        ));
4✔
313

314
        return Preg::match($this->configuration['validator'], $lines);
4✔
315
    }
316

317
    /**
318
     * Enclose the given text in a comment block.
319
     */
320
    private function getHeaderAsComment(): string
321
    {
322
        $lineEnding = $this->whitespacesConfig->getLineEnding();
38✔
323
        $comment = (self::HEADER_COMMENT === $this->configuration['comment_type'] ? '/*' : '/**').$lineEnding;
38✔
324
        $lines = explode("\n", str_replace("\r", '', $this->configuration['header']));
38✔
325

326
        foreach ($lines as $line) {
38✔
327
            $comment .= rtrim(' * '.$line).$lineEnding;
38✔
328
        }
329

330
        return $comment.' */';
38✔
331
    }
332

333
    private function findHeaderCommentCurrentIndex(Tokens $tokens, string $headerAsComment, int $headerNewIndex): ?int
334
    {
335
        $index = $tokens->getNextNonWhitespace($headerNewIndex);
38✔
336

337
        if (null === $index || !$tokens[$index]->isComment()) {
38✔
338
            return null;
25✔
339
        }
340

341
        $next = $index + 1;
38✔
342

343
        if (!isset($tokens[$next]) || \in_array($this->configuration['separate'], ['top', 'none'], true) || !$tokens[$index]->isGivenKind(T_DOC_COMMENT)) {
38✔
344
            return $index;
27✔
345
        }
346

347
        if ($tokens[$next]->isWhitespace()) {
15✔
348
            if (!Preg::match('/^\h*\R\h*$/D', $tokens[$next]->getContent())) {
15✔
349
                return $index;
9✔
350
            }
351

352
            ++$next;
9✔
353
        }
354

355
        if (!isset($tokens[$next]) || !$tokens[$next]->isClassy() && !$tokens[$next]->isGivenKind(T_FUNCTION)) {
9✔
356
            return $index;
4✔
357
        }
358

359
        if (
360
            $headerAsComment === $tokens[$index]->getContent()
5✔
361
            || (null !== $this->configuration['validator'] && $this->doesTokenFulfillValidator($tokens[$index]))
5✔
362
        ) {
363
            return $index;
2✔
364
        }
365

366
        return null;
3✔
367
    }
368

369
    /**
370
     * Find the index where the header comment must be inserted.
371
     */
372
    private function findHeaderCommentInsertionIndex(Tokens $tokens, string $location): int
373
    {
374
        $openTagIndex = $tokens[0]->isGivenKind(T_INLINE_HTML) ? 1 : 0;
38✔
375

376
        if ('after_open' === $location) {
38✔
377
            return $openTagIndex + 1;
38✔
378
        }
379

380
        $index = $tokens->getNextMeaningfulToken($openTagIndex);
38✔
381

382
        if (null === $index) {
38✔
383
            return $openTagIndex + 1; // file without meaningful tokens but an open tag, comment should always be placed directly after the open tag
3✔
384
        }
385

386
        if (!$tokens[$index]->isGivenKind(T_DECLARE)) {
35✔
387
            return $openTagIndex + 1;
20✔
388
        }
389

390
        $next = $tokens->getNextMeaningfulToken($index);
15✔
391

392
        if (null === $next || !$tokens[$next]->equals('(')) {
15✔
UNCOV
393
            return $openTagIndex + 1;
×
394
        }
395

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

398
        if (null === $next || !$tokens[$next]->equals([T_STRING, 'strict_types'], false)) {
15✔
399
            return $openTagIndex + 1;
1✔
400
        }
401

402
        $next = $tokens->getNextMeaningfulToken($next);
14✔
403

404
        if (null === $next || !$tokens[$next]->equals('=')) {
14✔
UNCOV
405
            return $openTagIndex + 1;
×
406
        }
407

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

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

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

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

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

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

426
        return $next + 1;
13✔
427
    }
428

429
    private function fixWhiteSpaceAroundHeader(Tokens $tokens, int $headerIndex): void
430
    {
431
        $lineEnding = $this->whitespacesConfig->getLineEnding();
37✔
432

433
        // fix lines after header comment
434
        if (
435
            ('both' === $this->configuration['separate'] || 'bottom' === $this->configuration['separate'])
37✔
436
            && null !== $tokens->getNextMeaningfulToken($headerIndex)
37✔
437
        ) {
438
            $expectedLineCount = 2;
30✔
439
        } else {
440
            $expectedLineCount = 1;
7✔
441
        }
442

443
        if ($headerIndex === \count($tokens) - 1) {
37✔
444
            $tokens->insertAt($headerIndex + 1, new Token([T_WHITESPACE, str_repeat($lineEnding, $expectedLineCount)]));
1✔
445
        } else {
446
            $lineBreakCount = $this->getLineBreakCount($tokens, $headerIndex, 1);
37✔
447

448
            if ($lineBreakCount < $expectedLineCount) {
37✔
449
                $missing = str_repeat($lineEnding, $expectedLineCount - $lineBreakCount);
28✔
450

451
                if ($tokens[$headerIndex + 1]->isWhitespace()) {
28✔
452
                    $tokens[$headerIndex + 1] = new Token([T_WHITESPACE, $missing.$tokens[$headerIndex + 1]->getContent()]);
15✔
453
                } else {
454
                    $tokens->insertAt($headerIndex + 1, new Token([T_WHITESPACE, $missing]));
14✔
455
                }
456
            } elseif ($lineBreakCount > $expectedLineCount && $tokens[$headerIndex + 1]->isWhitespace()) {
37✔
457
                $newLinesToRemove = $lineBreakCount - $expectedLineCount;
2✔
458
                $tokens[$headerIndex + 1] = new Token([
2✔
459
                    T_WHITESPACE,
2✔
460
                    Preg::replace("/^\\R{{$newLinesToRemove}}/", '', $tokens[$headerIndex + 1]->getContent()),
2✔
461
                ]);
2✔
462
            }
463
        }
464

465
        // fix lines before header comment
466
        $expectedLineCount = 'both' === $this->configuration['separate'] || 'top' === $this->configuration['separate'] ? 2 : 1;
37✔
467
        $prev = $tokens->getPrevNonWhitespace($headerIndex);
37✔
468

469
        $regex = '/\h$/';
37✔
470

471
        if ($tokens[$prev]->isGivenKind(T_OPEN_TAG) && Preg::match($regex, $tokens[$prev]->getContent())) {
37✔
472
            $tokens[$prev] = new Token([T_OPEN_TAG, Preg::replace($regex, $lineEnding, $tokens[$prev]->getContent())]);
1✔
473
        }
474

475
        $lineBreakCount = $this->getLineBreakCount($tokens, $headerIndex, -1);
37✔
476

477
        if ($lineBreakCount < $expectedLineCount) {
37✔
478
            // because of the way the insert index was determined for header comment there cannot be an empty token here
479
            $tokens->insertAt($headerIndex, new Token([T_WHITESPACE, str_repeat($lineEnding, $expectedLineCount - $lineBreakCount)]));
31✔
480
        }
481
    }
482

483
    private function getLineBreakCount(Tokens $tokens, int $index, int $direction): int
484
    {
485
        $whitespace = '';
37✔
486

487
        for ($index += $direction; isset($tokens[$index]); $index += $direction) {
37✔
488
            $token = $tokens[$index];
37✔
489

490
            if ($token->isWhitespace()) {
37✔
491
                $whitespace .= $token->getContent();
37✔
492

493
                continue;
37✔
494
            }
495

496
            if (-1 === $direction && $token->isGivenKind(T_OPEN_TAG)) {
37✔
497
                $whitespace .= $token->getContent();
32✔
498
            }
499

500
            if ('' !== $token->getContent()) {
37✔
501
                break;
37✔
502
            }
503
        }
504

505
        return substr_count($whitespace, "\n");
37✔
506
    }
507

508
    private function removeHeader(Tokens $tokens, int $index): void
509
    {
510
        $prevIndex = $index - 1;
13✔
511
        $prevToken = $tokens[$prevIndex];
13✔
512
        $newlineRemoved = false;
13✔
513

514
        if ($prevToken->isWhitespace()) {
13✔
515
            $content = $prevToken->getContent();
11✔
516

517
            if (Preg::match('/\R/', $content)) {
11✔
518
                $newlineRemoved = true;
9✔
519
            }
520

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

523
            $tokens->ensureWhitespaceAtIndex($prevIndex, 0, $content);
11✔
524
        }
525

526
        $nextIndex = $index + 1;
13✔
527
        $nextToken = $tokens[$nextIndex] ?? null;
13✔
528

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

532
            $tokens->ensureWhitespaceAtIndex($nextIndex, 0, $content);
4✔
533
        }
534

535
        $tokens->clearTokenAndMergeSurroundingWhitespace($index);
13✔
536
    }
537

538
    private function insertHeader(Tokens $tokens, string $headerAsComment, int $index): void
539
    {
540
        $tokens->insertAt($index, new Token([self::HEADER_COMMENT === $this->configuration['comment_type'] ? T_COMMENT : T_DOC_COMMENT, $headerAsComment]));
34✔
541
        $this->fixWhiteSpaceAroundHeader($tokens, $index);
34✔
542
    }
543
}
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