HeaderCommentFixer::getLineBreakCount()   A
last analyzed

Complexity

Conditions 6
Paths 6

Size

Total Lines 23
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 11
c 1
b 0
f 0
dl 0
loc 23
rs 9.2222
cc 6
nc 6
nop 3
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of PHP CS Fixer.
7
 *
8
 * (c) Fabien Potencier <[email protected]>
9
 *     Dariusz Rumiński <[email protected]>
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\WhitespacesAwareFixerInterface;
21
use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver;
22
use PhpCsFixer\FixerConfiguration\FixerConfigurationResolverInterface;
23
use PhpCsFixer\FixerConfiguration\FixerOptionBuilder;
24
use PhpCsFixer\FixerDefinition\CodeSample;
25
use PhpCsFixer\FixerDefinition\FixerDefinition;
26
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
27
use PhpCsFixer\Preg;
28
use PhpCsFixer\Tokenizer\Token;
29
use PhpCsFixer\Tokenizer\Tokens;
30
use Symfony\Component\OptionsResolver\Options;
31
32
/**
33
 * @author Antonio J. García Lagar <[email protected]>
34
 * @author SpacePossum
35
 */
36
final class HeaderCommentFixer extends AbstractFixer implements ConfigurableFixerInterface, WhitespacesAwareFixerInterface
37
{
38
    /**
39
     * @internal
40
     */
41
    public const HEADER_PHPDOC = 'PHPDoc';
42
43
    /**
44
     * @internal
45
     */
46
    public const HEADER_COMMENT = 'comment';
47
48
    /**
49
     * {@inheritdoc}
50
     */
51
    public function getDefinition(): FixerDefinitionInterface
52
    {
53
        return new FixerDefinition(
54
            'Add, replace or remove header comment.',
55
            [
56
                new CodeSample(
57
                    '<?php
58
declare(strict_types=1);
59
60
namespace A\B;
61
62
echo 1;
63
',
64
                    [
65
                        'header' => 'Made with love.',
66
                    ]
67
                ),
68
                new CodeSample(
69
                    '<?php
70
declare(strict_types=1);
71
72
namespace A\B;
73
74
echo 1;
75
',
76
                    [
77
                        'header' => 'Made with love.',
78
                        'comment_type' => 'PHPDoc',
79
                        'location' => 'after_open',
80
                        'separate' => 'bottom',
81
                    ]
82
                ),
83
                new CodeSample(
84
                    '<?php
85
declare(strict_types=1);
86
87
namespace A\B;
88
89
echo 1;
90
',
91
                    [
92
                        'header' => 'Made with love.',
93
                        'comment_type' => 'comment',
94
                        'location' => 'after_declare_strict',
95
                    ]
96
                ),
97
                new CodeSample(
98
                    '<?php
99
declare(strict_types=1);
100
101
/*
102
 * Comment is not wanted here.
103
 */
104
105
namespace A\B;
106
107
echo 1;
108
',
109
                    [
110
                        'header' => '',
111
                    ]
112
                ),
113
            ]
114
        );
115
    }
116
117
    /**
118
     * {@inheritdoc}
119
     */
120
    public function isCandidate(Tokens $tokens): bool
121
    {
122
        return isset($tokens[0]) && $tokens[0]->isGivenKind(T_OPEN_TAG) && $tokens->isMonolithicPhp();
123
    }
124
125
    /**
126
     * {@inheritdoc}
127
     *
128
     * Must run before SingleLineCommentStyleFixer.
129
     * Must run after DeclareStrictTypesFixer, NoBlankLinesAfterPhpdocFixer.
130
     */
131
    public function getPriority(): int
132
    {
133
        // When this fixer is configured with ["separate" => "bottom", "comment_type" => "PHPDoc"]
134
        // and the target file has no namespace or declare() construct,
135
        // the fixed header comment gets trimmed by NoBlankLinesAfterPhpdocFixer if we run before it.
136
        return -30;
137
    }
138
139
    /**
140
     * {@inheritdoc}
141
     */
142
    protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
143
    {
144
        $location = $this->configuration['location'];
145
        $locationIndexes = [];
146
147
        foreach (['after_open', 'after_declare_strict'] as $possibleLocation) {
148
            $locationIndex = $this->findHeaderCommentInsertionIndex($tokens, $possibleLocation);
149
150
            if (!isset($locationIndexes[$locationIndex]) || $possibleLocation === $location) {
151
                $locationIndexes[$locationIndex] = $possibleLocation;
152
153
                continue;
154
            }
155
        }
156
157
        foreach (array_values($locationIndexes) as $possibleLocation) {
158
            // figure out where the comment should be placed
159
            $headerNewIndex = $this->findHeaderCommentInsertionIndex($tokens, $possibleLocation);
160
161
            // check if there is already a comment
162
            $headerCurrentIndex = $this->findHeaderCommentCurrentIndex($tokens, $headerNewIndex - 1);
163
164
            if (null === $headerCurrentIndex) {
165
                if ('' === $this->configuration['header'] || $possibleLocation !== $location) {
166
                    continue;
167
                }
168
169
                $this->insertHeader($tokens, $headerNewIndex);
170
171
                continue;
172
            }
173
174
            $sameComment = $this->getHeaderAsComment() === $tokens[$headerCurrentIndex]->getContent();
175
            $expectedLocation = $possibleLocation === $location;
176
177
            if (!$sameComment || !$expectedLocation) {
178
                if ($expectedLocation ^ $sameComment) {
179
                    $this->removeHeader($tokens, $headerCurrentIndex);
180
                }
181
182
                if ('' === $this->configuration['header']) {
183
                    continue;
184
                }
185
186
                if ($possibleLocation === $location) {
187
                    $this->insertHeader($tokens, $headerNewIndex);
188
                }
189
190
                continue;
191
            }
192
193
            $this->fixWhiteSpaceAroundHeader($tokens, $headerCurrentIndex);
194
        }
195
    }
196
197
    /**
198
     * {@inheritdoc}
199
     */
200
    protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
201
    {
202
        $fixerName = $this->getName();
203
204
        return new FixerConfigurationResolver([
205
            (new FixerOptionBuilder('header', 'Proper header content.'))
206
                ->setAllowedTypes(['string'])
207
                ->setNormalizer(static function (Options $options, $value) use ($fixerName) {
208
                    if ('' === trim($value)) {
209
                        return '';
210
                    }
211
212
                    if (false !== strpos($value, '*/')) {
213
                        throw new InvalidFixerConfigurationException($fixerName, 'Cannot use \'*/\' in header.');
214
                    }
215
216
                    return $value;
217
                })
218
                ->getOption(),
219
            (new FixerOptionBuilder('comment_type', 'Comment syntax type.'))
220
                ->setAllowedValues([self::HEADER_PHPDOC, self::HEADER_COMMENT])
221
                ->setDefault(self::HEADER_COMMENT)
222
                ->getOption(),
223
            (new FixerOptionBuilder('location', 'The location of the inserted header.'))
224
                ->setAllowedValues(['after_open', 'after_declare_strict'])
225
                ->setDefault('after_declare_strict')
226
                ->getOption(),
227
            (new FixerOptionBuilder('separate', 'Whether the header should be separated from the file content with a new line.'))
228
                ->setAllowedValues(['both', 'top', 'bottom', 'none'])
229
                ->setDefault('both')
230
                ->getOption(),
231
        ]);
232
    }
233
234
    /**
235
     * Enclose the given text in a comment block.
236
     */
237
    private function getHeaderAsComment(): string
238
    {
239
        $lineEnding = $this->whitespacesConfig->getLineEnding();
240
        $comment = (self::HEADER_COMMENT === $this->configuration['comment_type'] ? '/*' : '/**').$lineEnding;
241
        $lines = explode("\n", str_replace("\r", '', $this->configuration['header']));
242
243
        foreach ($lines as $line) {
244
            $comment .= rtrim(' * '.$line).$lineEnding;
245
        }
246
247
        return $comment.' */';
248
    }
249
250
    private function findHeaderCommentCurrentIndex(Tokens $tokens, int $headerNewIndex): ?int
251
    {
252
        $index = $tokens->getNextNonWhitespace($headerNewIndex);
253
254
        if (null === $index || !$tokens[$index]->isComment()) {
255
            return null;
256
        }
257
258
        $next = $index + 1;
259
260
        if (!isset($tokens[$next]) || \in_array($this->configuration['separate'], ['top', 'none'], true) || !$tokens[$index]->isGivenKind(T_DOC_COMMENT)) {
261
            return $index;
262
        }
263
264
        if ($tokens[$next]->isWhitespace()) {
265
            if (!Preg::match('/^\h*\R\h*$/D', $tokens[$next]->getContent())) {
266
                return $index;
267
            }
268
269
            ++$next;
270
        }
271
272
        if (!isset($tokens[$next]) || !$tokens[$next]->isClassy() && !$tokens[$next]->isGivenKind(T_FUNCTION)) {
273
            return $index;
274
        }
275
276
        return $this->getHeaderAsComment() === $tokens[$index]->getContent() ? $index : null;
277
    }
278
279
    /**
280
     * Find the index where the header comment must be inserted.
281
     */
282
    private function findHeaderCommentInsertionIndex(Tokens $tokens, string $location): int
283
    {
284
        if ('after_open' === $location) {
285
            return 1;
286
        }
287
288
        $index = $tokens->getNextMeaningfulToken(0);
289
290
        if (null === $index) {
291
            return 1; // file without meaningful tokens but an open tag, comment should always be placed directly after the open tag
292
        }
293
294
        if (!$tokens[$index]->isGivenKind(T_DECLARE)) {
295
            return 1;
296
        }
297
298
        $next = $tokens->getNextMeaningfulToken($index);
299
300
        if (null === $next || !$tokens[$next]->equals('(')) {
301
            return 1;
302
        }
303
304
        $next = $tokens->getNextMeaningfulToken($next);
305
306
        if (null === $next || !$tokens[$next]->equals([T_STRING, 'strict_types'], false)) {
307
            return 1;
308
        }
309
310
        $next = $tokens->getNextMeaningfulToken($next);
311
312
        if (null === $next || !$tokens[$next]->equals('=')) {
313
            return 1;
314
        }
315
316
        $next = $tokens->getNextMeaningfulToken($next);
317
318
        if (null === $next || !$tokens[$next]->isGivenKind(T_LNUMBER)) {
319
            return 1;
320
        }
321
322
        $next = $tokens->getNextMeaningfulToken($next);
323
324
        if (null === $next || !$tokens[$next]->equals(')')) {
325
            return 1;
326
        }
327
328
        $next = $tokens->getNextMeaningfulToken($next);
329
330
        if (null === $next || !$tokens[$next]->equals(';')) { // don't insert after close tag
331
            return 1;
332
        }
333
334
        return $next + 1;
335
    }
336
337
    private function fixWhiteSpaceAroundHeader(Tokens $tokens, int $headerIndex): void
338
    {
339
        $lineEnding = $this->whitespacesConfig->getLineEnding();
340
341
        // fix lines after header comment
342
        if (
343
            ('both' === $this->configuration['separate'] || 'bottom' === $this->configuration['separate'])
344
            && null !== $tokens->getNextMeaningfulToken($headerIndex)
345
        ) {
346
            $expectedLineCount = 2;
347
        } else {
348
            $expectedLineCount = 1;
349
        }
350
351
        if ($headerIndex === \count($tokens) - 1) {
352
            $tokens->insertAt($headerIndex + 1, new Token([T_WHITESPACE, str_repeat($lineEnding, $expectedLineCount)]));
353
        } else {
354
            $lineBreakCount = $this->getLineBreakCount($tokens, $headerIndex, 1);
355
356
            if ($lineBreakCount < $expectedLineCount) {
357
                $missing = str_repeat($lineEnding, $expectedLineCount - $lineBreakCount);
358
359
                if ($tokens[$headerIndex + 1]->isWhitespace()) {
360
                    $tokens[$headerIndex + 1] = new Token([T_WHITESPACE, $missing.$tokens[$headerIndex + 1]->getContent()]);
361
                } else {
362
                    $tokens->insertAt($headerIndex + 1, new Token([T_WHITESPACE, $missing]));
363
                }
364
            } elseif ($lineBreakCount > $expectedLineCount && $tokens[$headerIndex + 1]->isWhitespace()) {
365
                $newLinesToRemove = $lineBreakCount - $expectedLineCount;
366
                $tokens[$headerIndex + 1] = new Token([
367
                    T_WHITESPACE,
368
                    Preg::replace("/^\\R{{$newLinesToRemove}}/", '', $tokens[$headerIndex + 1]->getContent()),
369
                ]);
370
            }
371
        }
372
373
        // fix lines before header comment
374
        $expectedLineCount = 'both' === $this->configuration['separate'] || 'top' === $this->configuration['separate'] ? 2 : 1;
375
        $prev = $tokens->getPrevNonWhitespace($headerIndex);
376
377
        $regex = '/\h$/';
378
379
        if ($tokens[$prev]->isGivenKind(T_OPEN_TAG) && Preg::match($regex, $tokens[$prev]->getContent())) {
380
            $tokens[$prev] = new Token([T_OPEN_TAG, Preg::replace($regex, $lineEnding, $tokens[$prev]->getContent())]);
381
        }
382
383
        $lineBreakCount = $this->getLineBreakCount($tokens, $headerIndex, -1);
384
385
        if ($lineBreakCount < $expectedLineCount) {
386
            // because of the way the insert index was determined for header comment there cannot be an empty token here
387
            $tokens->insertAt($headerIndex, new Token([T_WHITESPACE, str_repeat($lineEnding, $expectedLineCount - $lineBreakCount)]));
388
        }
389
    }
390
391
    private function getLineBreakCount(Tokens $tokens, int $index, int $direction): int
392
    {
393
        $whitespace = '';
394
395
        for ($index += $direction; isset($tokens[$index]); $index += $direction) {
396
            $token = $tokens[$index];
397
398
            if ($token->isWhitespace()) {
399
                $whitespace .= $token->getContent();
400
401
                continue;
402
            }
403
404
            if (-1 === $direction && $token->isGivenKind(T_OPEN_TAG)) {
405
                $whitespace .= $token->getContent();
406
            }
407
408
            if ('' !== $token->getContent()) {
409
                break;
410
            }
411
        }
412
413
        return substr_count($whitespace, "\n");
414
    }
415
416
    private function removeHeader(Tokens $tokens, int $index): void
417
    {
418
        $prevIndex = $index - 1;
419
        $prevToken = $tokens[$prevIndex];
420
        $newlineRemoved = false;
421
422
        if ($prevToken->isWhitespace()) {
423
            $content = $prevToken->getContent();
424
425
            if (Preg::match('/\R/', $content)) {
426
                $newlineRemoved = true;
427
            }
428
429
            $content = Preg::replace('/\R?\h*$/', '', $content);
430
431
            if ('' === $content) {
432
                $tokens->clearAt($prevIndex);
433
            } else {
434
                $tokens[$prevIndex] = new Token([T_WHITESPACE, $content]);
435
            }
436
        }
437
438
        $nextIndex = $index + 1;
439
        $nextToken = $tokens[$nextIndex] ?? null;
440
441
        if (!$newlineRemoved && null !== $nextToken && $nextToken->isWhitespace()) {
442
            $content = Preg::replace('/^\R/', '', $nextToken->getContent());
443
444
            if ('' === $content) {
445
                $tokens->clearAt($nextIndex);
446
            } else {
447
                $tokens[$nextIndex] = new Token([T_WHITESPACE, $content]);
448
            }
449
        }
450
451
        $tokens->clearTokenAndMergeSurroundingWhitespace($index);
452
    }
453
454
    private function insertHeader(Tokens $tokens, int $index): void
455
    {
456
        $tokens->insertAt($index, new Token([self::HEADER_COMMENT === $this->configuration['comment_type'] ? T_COMMENT : T_DOC_COMMENT, $this->getHeaderAsComment()]));
457
        $this->fixWhiteSpaceAroundHeader($tokens, $index);
458
    }
459
}
460