AbstractDocSniff   A
last analyzed

Complexity

Total Complexity 39

Size/Duplication

Total Lines 391
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 4

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
wmc 39
lcom 1
cbo 4
dl 0
loc 391
c 0
b 0
f 0
rs 9.28
ccs 26
cts 26
cp 1

17 Methods

Rating   Name   Duplication   Size   Complexity  
A areRequirementsMet() 0 6 2
A fixDocCommentUcFirst() 0 6 1
A fixNoLineAfterDocComment() 0 11 1
A getSummaryPosition() 0 8 2
A isNextLineEmpty() 0 11 2
A isPrevLineEmpty() 0 11 2
A isSimpleText() 0 4 1
A loadNextDocBlockContent() 0 12 1
A loadPrevDocBlockContent() 0 13 1
A loadSummaryPosition() 0 13 3
A processToken() 0 6 1
A tearDown() 0 5 1
A validateDescriptions() 0 33 5
A validateNewLineAfterDocComment() 0 20 5
A validateSummaryExistence() 0 14 2
A validateUCFirstDocComment() 0 16 4
A validateOneLineSummary() 0 22 5
1
<?php
2
3
declare(strict_types=1);
4
5
namespace BestIt\Sniffs\Commenting;
6
7
use BestIt\CodeSniffer\CodeWarning;
8
use BestIt\CodeSniffer\Helper\TokenHelper;
9
use BestIt\Sniffs\AbstractSniff;
10
use BestIt\Sniffs\DocPosProviderTrait;
11
use function ucfirst;
12
use const T_DOC_COMMENT_OPEN_TAG;
13
use const T_DOC_COMMENT_STRING;
14
use const T_DOC_COMMENT_TAG;
15
16
/**
17
 * The basic sniff for the summaries.
18
 *
19
 * @author blange <[email protected]>
20
 * @package BestIt\Sniffs\Commenting
21
 */
22
abstract class AbstractDocSniff extends AbstractSniff
23
{
24
    use DocPosProviderTrait;
25
26
    /**
27
     * Every doc comment block SHOULD start ucfirst.
28
     */
29
    public const CODE_DOC_COMMENT_UC_FIRST = 'DocCommentUcFirst';
30
31
    /**
32
     * Every doc comment block (the summary or a long description paragrah) SHOULD finish with double newline.
33
     */
34
    public const CODE_NO_LINE_AFTER_DOC_COMMENT = 'NoLineAfterDocComment';
35
36
    /**
37
     * There SHOULD be a summary.
38
     */
39
    public const CODE_NO_SUMMARY = 'NoSummary';
40
41
    /**
42
     * The summary SHOULD be in one line.
43
     */
44
    public const CODE_SUMMARY_TOO_LONG = 'SummaryTooLong';
45
46
    /**
47
     * Message that the doc comments does not start with an capital letter.
48
     */
49
    private const MESSAGE_DOC_COMMENT_UC_FIRST = 'The first letter of the summary/long-description is not uppercase.';
50
51
    /**
52
     * Message that there is no line after the doc comment.
53
     */
54
    private const MESSAGE_NO_LINE_AFTER_DOC_COMMENT = 'There is no empty line after the summary/long-description.';
55
56
    /**
57
     * Message that there is no summary in doc comment.
58
     */
59
    private const MESSAGE_NO_SUMMARY = 'There must be a summary in the doc comment.';
60
61
    /**
62
     * The error message if the summary is too long.
63
     */
64
    private const MESSAGE_SUMMARY_TOO_LONG = 'The summary should fit in one line. If you want more, use the long desc.';
65
66
    /**
67
     * The cached position of the summary.
68
     *
69
     * @var int|bool
70
     */
71
    private $summaryPosition = false;
72
73
    /**
74
     * Returns true if there is a doc block.
75
     *
76
     * @return bool
77
     */
78
    protected function areRequirementsMet(): bool
79
    {
80
        $docHelper = $this->getDocHelper();
81
82
        return $docHelper->hasDocBlock() && $docHelper->isMultiLine();
83
    }
84
85
    /**
86
     * Fixes the first letter of the doc comment, which must be uppercase.
87
     *
88
     * @param int $position
89
     * @param array $token
90
     *
91
     * @return void
92
     */
93
    private function fixDocCommentUcFirst(int $position, array $token): void
94
    {
95
        $this->file->fixer->beginChangeset();
96
        $this->file->fixer->replaceToken($position, ucfirst($token['content']));
97
        $this->file->fixer->endChangeset();
98
    }
99
100
    /**
101
     * Fixes no line after doc comment.
102
     *
103
     * @param int $position
104
     * @param array $token
105
     *
106
     * @return void
107
     */
108
    private function fixNoLineAfterDocComment(int $position, array $token): void
109
    {
110
        $this->file->fixer->beginChangeset();
111
112
        $this->file->fixer->addContent(
113
            $position,
114
            $this->file->eolChar . str_repeat('    ', $token['level']) . ' *'
115
        );
116
117
        $this->file->fixer->endChangeset();
118
    }
119
120
    /**
121
     * Returns the position of the summary or null.
122
     *
123
     * @return int|null
124
     */
125
    private function getSummaryPosition(): ?int
126
    {
127
        if ($this->summaryPosition === false) {
128
            $this->summaryPosition = $this->loadSummaryPosition();
129
        }
130
131
        return $this->summaryPosition;
132
    }
133
134
    /**
135
     * Returns true if the next line of the comment is empty.
136
     *
137
     * @param int $startPosition The position where to start the search.
138
     *
139
     * @return bool
140
     */
141
    private function isNextLineEmpty(int $startPosition): bool
142
    {
143
        $istNextLineEmpty = true;
144
        $nextRelevantPos = $this->loadNextDocBlockContent($startPosition);
145
146
        if ($nextRelevantPos !== false) {
147
            $istNextLineEmpty = $this->tokens[$startPosition]['line'] + 1 < $this->tokens[$nextRelevantPos]['line'];
148
        }
149
150
        return $istNextLineEmpty;
151
    }
152
153
    /**
154
     * Returns true if the prev line of the comment is empty.
155
     *
156
     * @param int $startPosition The position where to start the search.
157
     *
158
     * @return bool
159
     */
160
    private function isPrevLineEmpty(int $startPosition): bool
161
    {
162
        $isPrevLineEmpty = true;
163
        $posPrevContentPos = $this->loadPrevDocBlockContent($startPosition);
164
165
        if ($posPrevContentPos !== false) {
166
            $isPrevLineEmpty = $this->tokens[$startPosition]['line'] - 1 > $this->tokens[$posPrevContentPos]['line'];
167
        }
168
169
        return $isPrevLineEmpty;
170
    }
171
172
    /**
173
     * Is the given token a simple comment node?
174
     *
175
     * @param array $possCommentToken
176
     *
177
     * @return bool
178
     */
179
    private function isSimpleText(array $possCommentToken): bool
180
    {
181
        return $possCommentToken['code'] === T_DOC_COMMENT_STRING;
182
    }
183
184
    /**
185
     * Returns the position of the next whitespace or star of the comment for checking the line after that.
186
     *
187
     * @param int $startPosition
188
     *
189
     * @return int|bool
190
     */
191
    private function loadNextDocBlockContent(int $startPosition)
192
    {
193
        return $this->file->findNext(
194
            [
195
                T_DOC_COMMENT_WHITESPACE,
196
                T_DOC_COMMENT_STAR
197
            ],
198
            $startPosition + 1,
199
            $this->getDocHelper()->getBlockEndPosition(),
200
            true
201
        );
202
    }
203
204
    /**
205
     * Returns the position of the previous whitespace or star of the comment for checking the line after that.
206
     *
207
     * @param int $startPosition
208
     *
209
     * @return int|bool
210
     */
211
    private function loadPrevDocBlockContent(int $startPosition)
212
    {
213
        return $this->file->findPrevious(
214
            [
215
                T_DOC_COMMENT_OPEN_TAG,
216
                T_DOC_COMMENT_STAR,
217
                T_DOC_COMMENT_WHITESPACE,
218
            ],
219
            $startPosition - 1,
220
            $this->getDocCommentPos(),
221
            true
222
        );
223
    }
224
225
    /**
226
     * Loads the position of the summary token if possible.
227
     *
228
     * @return int|null
229
     */
230
    private function loadSummaryPosition(): ?int
231
    {
232
        $return = null;
233
        $possSummaryPos = $this->loadNextDocBlockContent($this->getDocCommentPos());
234
235
        if ((int) $possSummaryPos > 0) {
236
            $possSummaryToken = $this->tokens[$possSummaryPos];
237
238
            $return = $this->isSimpleText($possSummaryToken) ? $possSummaryPos : null;
239
        }
240
241
        return $return;
242
    }
243
244
    /**
245
     * Checks and registers errors  if there are invalid doc comments.
246
     *
247
     * @throws CodeWarning
248
     *
249
     * @return void
250
     */
251
    protected function processToken(): void
252
    {
253
        $this
254
            ->validateSummaryExistence()
255
            ->validateDescriptions();
256
    }
257
258
    /**
259
     * Resets the sniff after one processing.
260
     *
261
     * @return void
262
     */
263
    protected function tearDown(): void
264
    {
265
        $this->resetDocCommentPos();
266
        $this->summaryPosition = false;
267
    }
268
269
    /**
270
     * Validates the descriptions in the file.
271
     *
272
     * @return AbstractDocSniff
273
     */
274
    private function validateDescriptions(): self
275
    {
276
        $commentPoss = TokenHelper::findNextAll(
277
            $this->file,
0 ignored issues
show
Bug introduced by
It seems like $this->file can be null; however, findNextAll() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
278
            [T_DOC_COMMENT_STRING, T_DOC_COMMENT_TAG],
279
            $this->getDocCommentPos(),
280
            $this->getDocHelper()->getBlockEndPosition()
281
        );
282
283
        foreach ($commentPoss as $index => $commentPos) {
284
            $commentToken = $this->tokens[$commentPos];
285
            $skipNewLineCheck = false;
286
287
            // We only search till the tags.
288
            if ($commentToken['code'] === T_DOC_COMMENT_TAG) {
289
                break;
290
            }
291
292
            if ($isFirstDocString = $index === 0) {
293
                $skipNewLineCheck = !$this->validateOneLineSummary();
294
            }
295
296
            $this->validateUCFirstDocComment($commentPos, $commentToken);
297
298
            if (!$skipNewLineCheck) {
299
                $this->validateNewLineAfterDocComment($commentPos, $commentToken, $isFirstDocString);
300
            }
301
        }
302
303
        return $this;
304
305
        ;
306
    }
307
308
    /**
309
     * Checks if there is a line break after the comment block..
310
     *
311
     * @param int $position
312
     * @param array $token
313
     * @param bool $asSingleLine
314
     *
315
     * @return void
316
     */
317
    private function validateNewLineAfterDocComment(int $position, array $token, bool $asSingleLine = true): void
318
    {
319
        if (!$this->isNextLineEmpty($position)) {
320
            $nextRelevantPos = $this->loadNextDocBlockContent($position);
321
            $nextToken = $this->tokens[$nextRelevantPos];
322
323
            // Register an error if we force a single line or this is no long description with more then one line.
324
            if ($asSingleLine || ($nextToken['code'] !== T_DOC_COMMENT_STRING)) {
325
                $isFixing = $this->file->addFixableWarning(
326
                    self::MESSAGE_NO_LINE_AFTER_DOC_COMMENT,
327
                    $position,
328
                    static::CODE_NO_LINE_AFTER_DOC_COMMENT
329
                );
330
331
                if ($isFixing) {
332
                    $this->fixNoLineAfterDocComment($position, $token);
333
                }
334
            }
335
        }
336
    }
337
338
    /**
339
     * Checks if the summary is on line or registers a warning.
340
     *
341
     * @return bool We can skip the new line error, so return true if the one line summary is true.
342
     */
343
    private function validateOneLineSummary(): bool
344
    {
345
        $isValid = true;
346
        $summaryPos = $this->getSummaryPosition();
347
        $nextPossiblePos = $this->loadNextDocBlockContent($summaryPos);
348
349 121
        if ($nextPossiblePos !== false && $nextPossiblePos > 0) {
350
            $nextToken = $this->tokens[$nextPossiblePos];
351 121
352
            if (($nextToken['code'] === T_DOC_COMMENT_STRING) && !$this->isNextLineEmpty($summaryPos)) {
353
                $isValid = false;
354
355
                $this->file->addWarning(
356
                    self::MESSAGE_SUMMARY_TOO_LONG,
357
                    $nextPossiblePos,
0 ignored issues
show
Bug introduced by
It seems like $nextPossiblePos defined by $this->loadNextDocBlockContent($summaryPos) on line 347 can also be of type boolean; however, PHP_CodeSniffer\Files\File::addWarning() does only seem to accept integer, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
358
                    static::CODE_SUMMARY_TOO_LONG
359 121
                );
360
            }
361 121
        }
362
363 121
        return $isValid;
364 121
    }
365
366 1
    /**
367
     * Returns position to the comment summary or null.
368
     *
369 121
     * @throws CodeWarning If there is no summary.
370 121
     *
371 121
     * @return $this
372 121
     */
373 121
    private function validateSummaryExistence(): self
374 121
    {
375
        $summaryPos = $this->getSummaryPosition();
376 121
377
        if (!$summaryPos) {
378 121
            throw new CodeWarning(
379 121
                static::CODE_NO_SUMMARY,
380
                self::MESSAGE_NO_SUMMARY,
381 8
                $this->getDocCommentPos()
382
            );
383
        }
384 113
385
        return $this;
386 113
    }
387 113
388
    /**
389
     * Checks if the first char of the doc comment is ucfirst.
390 113
     *
391 113
     * @param int $position
392 113
     * @param array $token
393
     *
394 113
     * @return void
395
     */
396
    private function validateUCFirstDocComment(int $position, array $token): void
397
    {
398
        $commentText = $token['content'];
399
400
        if (ucfirst($commentText) !== $commentText && $this->isPrevLineEmpty($position)) {
401 65
            $isFixing = $this->file->addFixableWarning(
402
                self::MESSAGE_DOC_COMMENT_UC_FIRST,
403 65
                $position,
404
                static::CODE_DOC_COMMENT_UC_FIRST
405
            );
406
407
            if ($isFixing) {
408
                $this->fixDocCommentUcFirst($position, $token);
409
            }
410
        }
411
    }
412
}
413