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

PHPCSStandards / PHP_CodeSniffer / 15253296250

26 May 2025 11:55AM UTC coverage: 78.632% (+0.3%) from 78.375%
15253296250

Pull #1105

github

web-flow
Merge d9441d98f into caf806050
Pull Request #1105: Skip tests when 'git' command is not available

19665 of 25009 relevant lines covered (78.63%)

88.67 hits per line

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

91.25
/src/Standards/PEAR/Sniffs/Commenting/FileCommentSniff.php
1
<?php
2
/**
3
 * Parses and verifies the doc comments for files.
4
 *
5
 * @author    Greg Sherwood <gsherwood@squiz.net>
6
 * @copyright 2006-2015 Squiz Pty Ltd (ABN 77 084 670 600)
7
 * @license   https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence
8
 */
9

10
namespace PHP_CodeSniffer\Standards\PEAR\Sniffs\Commenting;
11

12
use PHP_CodeSniffer\Files\File;
13
use PHP_CodeSniffer\Sniffs\Sniff;
14
use PHP_CodeSniffer\Util\Common;
15

16
class FileCommentSniff implements Sniff
17
{
18

19
    /**
20
     * Tags in correct order and related info.
21
     *
22
     * @var array<string, array<string, bool>>
23
     */
24
    protected const EXPECTED_TAGS = [
25
        '@category'   => [
26
            'required'       => true,
27
            'allow_multiple' => false,
28
        ],
29
        '@package'    => [
30
            'required'       => true,
31
            'allow_multiple' => false,
32
        ],
33
        '@subpackage' => [
34
            'required'       => false,
35
            'allow_multiple' => false,
36
        ],
37
        '@author'     => [
38
            'required'       => true,
39
            'allow_multiple' => true,
40
        ],
41
        '@copyright'  => [
42
            'required'       => false,
43
            'allow_multiple' => true,
44
        ],
45
        '@license'    => [
46
            'required'       => true,
47
            'allow_multiple' => false,
48
        ],
49
        '@version'    => [
50
            'required'       => false,
51
            'allow_multiple' => false,
52
        ],
53
        '@link'       => [
54
            'required'       => true,
55
            'allow_multiple' => true,
56
        ],
57
        '@see'        => [
58
            'required'       => false,
59
            'allow_multiple' => true,
60
        ],
61
        '@since'      => [
62
            'required'       => false,
63
            'allow_multiple' => false,
64
        ],
65
        '@deprecated' => [
66
            'required'       => false,
67
            'allow_multiple' => false,
68
        ],
69
    ];
70

71
    /**
72
     * Tags in correct order and related info.
73
     *
74
     * @var array<string, array<string, bool>>
75
     *
76
     * @deprecated 4.0.0 Use the FileCommentSniff::EXPECTED_TAGS constant instead.
77
     */
78
    protected $tags = self::EXPECTED_TAGS;
79

80

81
    /**
82
     * Returns an array of tokens this test wants to listen for.
83
     *
84
     * @return array<int|string>
85
     */
86
    public function register()
3✔
87
    {
88
        return [T_OPEN_TAG];
3✔
89

90
    }//end register()
91

92

93
    /**
94
     * Processes this test, when one of its tokens is encountered.
95
     *
96
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
97
     * @param int                         $stackPtr  The position of the current token
98
     *                                               in the stack passed in $tokens.
99
     *
100
     * @return int|void
101
     */
102
    public function process(File $phpcsFile, $stackPtr)
3✔
103
    {
104
        $tokens = $phpcsFile->getTokens();
3✔
105

106
        // Find the next non whitespace token.
107
        $commentStart = $phpcsFile->findNext(T_WHITESPACE, ($stackPtr + 1), null, true);
3✔
108

109
        // Allow declare() statements at the top of the file.
110
        if ($tokens[$commentStart]['code'] === T_DECLARE) {
3✔
111
            $semicolon    = $phpcsFile->findNext(T_SEMICOLON, ($commentStart + 1));
3✔
112
            $commentStart = $phpcsFile->findNext(T_WHITESPACE, ($semicolon + 1), null, true);
3✔
113
        }
114

115
        // Ignore vim header.
116
        if ($tokens[$commentStart]['code'] === T_COMMENT) {
3✔
117
            if (strstr($tokens[$commentStart]['content'], 'vim:') !== false) {
3✔
118
                $commentStart = $phpcsFile->findNext(
3✔
119
                    T_WHITESPACE,
3✔
120
                    ($commentStart + 1),
3✔
121
                    null,
3✔
122
                    true
3✔
123
                );
2✔
124
            }
125
        }
126

127
        $errorToken = ($stackPtr + 1);
3✔
128
        if (isset($tokens[$errorToken]) === false) {
3✔
129
            $errorToken--;
×
130
        }
131

132
        if ($tokens[$commentStart]['code'] === T_CLOSE_TAG) {
3✔
133
            // We are only interested if this is the first open tag.
134
            return $phpcsFile->numTokens;
×
135
        } else if ($tokens[$commentStart]['code'] === T_COMMENT) {
3✔
136
            $error = 'You must use "/**" style comments for a file comment';
×
137
            $phpcsFile->addError($error, $errorToken, 'WrongStyle');
×
138
            $phpcsFile->recordMetric($stackPtr, 'File has doc comment', 'yes');
×
139
            return $phpcsFile->numTokens;
×
140
        } else if ($commentStart === false
3✔
141
            || $tokens[$commentStart]['code'] !== T_DOC_COMMENT_OPEN_TAG
3✔
142
        ) {
143
            $phpcsFile->addError('Missing file doc comment', $errorToken, 'Missing');
×
144
            $phpcsFile->recordMetric($stackPtr, 'File has doc comment', 'no');
×
145
            return $phpcsFile->numTokens;
×
146
        }
147

148
        $commentEnd = $tokens[$commentStart]['comment_closer'];
3✔
149

150
        for ($nextToken = ($commentEnd + 1); $nextToken < $phpcsFile->numTokens; $nextToken++) {
3✔
151
            if ($tokens[$nextToken]['code'] === T_WHITESPACE) {
3✔
152
                continue;
3✔
153
            }
154

155
            if ($tokens[$nextToken]['code'] === T_ATTRIBUTE
3✔
156
                && isset($tokens[$nextToken]['attribute_closer']) === true
3✔
157
            ) {
158
                $nextToken = $tokens[$nextToken]['attribute_closer'];
3✔
159
                continue;
3✔
160
            }
161

162
            break;
3✔
163
        }
164

165
        if ($nextToken === $phpcsFile->numTokens) {
3✔
166
            $nextToken--;
×
167
        }
168

169
        $ignore = [
2✔
170
            T_CLASS,
3✔
171
            T_INTERFACE,
3✔
172
            T_TRAIT,
3✔
173
            T_ENUM,
3✔
174
            T_FUNCTION,
3✔
175
            T_CLOSURE,
3✔
176
            T_PUBLIC,
3✔
177
            T_PRIVATE,
3✔
178
            T_PROTECTED,
3✔
179
            T_FINAL,
3✔
180
            T_STATIC,
3✔
181
            T_ABSTRACT,
3✔
182
            T_READONLY,
3✔
183
            T_CONST,
3✔
184
        ];
2✔
185

186
        if (in_array($tokens[$nextToken]['code'], $ignore, true) === true) {
3✔
187
            $phpcsFile->addError('Missing file doc comment', $stackPtr, 'Missing');
3✔
188
            $phpcsFile->recordMetric($stackPtr, 'File has doc comment', 'no');
3✔
189
            return $phpcsFile->numTokens;
3✔
190
        }
191

192
        $phpcsFile->recordMetric($stackPtr, 'File has doc comment', 'yes');
3✔
193

194
        // Check the PHP Version, which should be in some text before the first tag.
195
        $found = false;
3✔
196
        for ($i = ($commentStart + 1); $i < $commentEnd; $i++) {
3✔
197
            if ($tokens[$i]['code'] === T_DOC_COMMENT_TAG) {
3✔
198
                break;
3✔
199
            } else if ($tokens[$i]['code'] === T_DOC_COMMENT_STRING
3✔
200
                && strstr(strtolower($tokens[$i]['content']), 'php version') !== false
3✔
201
            ) {
202
                $found = true;
×
203
                break;
×
204
            }
205
        }
206

207
        if ($found === false) {
3✔
208
            $error = 'PHP version not specified';
3✔
209
            $phpcsFile->addWarning($error, $commentEnd, 'MissingVersion');
3✔
210
        }
211

212
        // Check each tag.
213
        $this->processTags($phpcsFile, $stackPtr, $commentStart);
3✔
214

215
        // Ignore the rest of the file.
216
        return $phpcsFile->numTokens;
3✔
217

218
    }//end process()
219

220

221
    /**
222
     * Processes each required or optional tag.
223
     *
224
     * @param \PHP_CodeSniffer\Files\File $phpcsFile    The file being scanned.
225
     * @param int                         $stackPtr     The position of the current token
226
     *                                                  in the stack passed in $tokens.
227
     * @param int                         $commentStart Position in the stack where the comment started.
228
     *
229
     * @return void
230
     */
231
    protected function processTags($phpcsFile, $stackPtr, $commentStart)
3✔
232
    {
233
        $tokens = $phpcsFile->getTokens();
3✔
234

235
        if (get_class($this) === 'PHP_CodeSniffer\Standards\PEAR\Sniffs\Commenting\FileCommentSniff') {
3✔
236
            $docBlock = 'file';
3✔
237
        } else {
238
            $docBlock = 'class';
×
239
        }
240

241
        $commentEnd = $tokens[$commentStart]['comment_closer'];
3✔
242

243
        $foundTags = [];
3✔
244
        $tagTokens = [];
3✔
245
        foreach ($tokens[$commentStart]['comment_tags'] as $tag) {
3✔
246
            $name = $tokens[$tag]['content'];
3✔
247
            if (isset(static::EXPECTED_TAGS[$name]) === false) {
3✔
248
                continue;
3✔
249
            }
250

251
            if (static::EXPECTED_TAGS[$name]['allow_multiple'] === false && isset($tagTokens[$name]) === true) {
3✔
252
                $error = 'Only one %s tag is allowed in a %s comment';
3✔
253
                $data  = [
2✔
254
                    $name,
3✔
255
                    $docBlock,
3✔
256
                ];
2✔
257
                $phpcsFile->addError($error, $tag, 'Duplicate'.ucfirst(substr($name, 1)).'Tag', $data);
3✔
258
            }
259

260
            $foundTags[]        = $name;
3✔
261
            $tagTokens[$name][] = $tag;
3✔
262

263
            $string = $phpcsFile->findNext(T_DOC_COMMENT_STRING, $tag, $commentEnd);
3✔
264
            if ($string === false || $tokens[$string]['line'] !== $tokens[$tag]['line']) {
3✔
265
                $error = 'Content missing for %s tag in %s comment';
3✔
266
                $data  = [
2✔
267
                    $name,
3✔
268
                    $docBlock,
3✔
269
                ];
2✔
270
                $phpcsFile->addError($error, $tag, 'Empty'.ucfirst(substr($name, 1)).'Tag', $data);
3✔
271
                continue;
3✔
272
            }
273
        }//end foreach
274

275
        // Check if the tags are in the correct position.
276
        $pos = 0;
3✔
277
        foreach (static::EXPECTED_TAGS as $tag => $tagData) {
3✔
278
            if (isset($tagTokens[$tag]) === false) {
3✔
279
                if ($tagData['required'] === true) {
3✔
280
                    $error = 'Missing %s tag in %s comment';
3✔
281
                    $data  = [
2✔
282
                        $tag,
3✔
283
                        $docBlock,
3✔
284
                    ];
2✔
285
                    $phpcsFile->addError($error, $commentEnd, 'Missing'.ucfirst(substr($tag, 1)).'Tag', $data);
3✔
286
                }
287

288
                continue;
3✔
289
            } else {
290
                $method = 'process'.substr($tag, 1);
3✔
291
                if (method_exists($this, $method) === true) {
3✔
292
                    // Process each tag if a method is defined.
293
                    call_user_func([$this, $method], $phpcsFile, $tagTokens[$tag]);
3✔
294
                }
295
            }
296

297
            if (isset($foundTags[$pos]) === false) {
3✔
298
                break;
×
299
            }
300

301
            if ($foundTags[$pos] !== $tag) {
3✔
302
                $error = 'The tag in position %s should be the %s tag';
3✔
303
                $data  = [
2✔
304
                    ($pos + 1),
3✔
305
                    $tag,
3✔
306
                ];
2✔
307
                $phpcsFile->addError($error, $tokens[$commentStart]['comment_tags'][$pos], ucfirst(substr($tag, 1)).'TagOrder', $data);
3✔
308
            }
309

310
            // Account for multiple tags.
311
            $pos++;
3✔
312
            while (isset($foundTags[$pos]) === true && $foundTags[$pos] === $tag) {
3✔
313
                $pos++;
3✔
314
            }
315
        }//end foreach
316

317
    }//end processTags()
1✔
318

319

320
    /**
321
     * Process the category tag.
322
     *
323
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
324
     * @param array                       $tags      The tokens for these tags.
325
     *
326
     * @return void
327
     */
328
    protected function processCategory($phpcsFile, array $tags)
3✔
329
    {
330
        $tokens = $phpcsFile->getTokens();
3✔
331
        foreach ($tags as $tag) {
3✔
332
            if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
3✔
333
                // No content.
334
                continue;
×
335
            }
336

337
            $content = $tokens[($tag + 2)]['content'];
3✔
338
            if (Common::isUnderscoreName($content) !== true) {
3✔
339
                $newContent = str_replace(' ', '_', $content);
3✔
340
                $nameBits   = explode('_', $newContent);
3✔
341
                $firstBit   = array_shift($nameBits);
3✔
342
                $newName    = ucfirst($firstBit).'_';
3✔
343
                foreach ($nameBits as $bit) {
3✔
344
                    if ($bit !== '') {
3✔
345
                        $newName .= ucfirst($bit).'_';
3✔
346
                    }
347
                }
348

349
                $error     = 'Category name "%s" is not valid; consider "%s" instead';
3✔
350
                $validName = trim($newName, '_');
3✔
351
                $data      = [
2✔
352
                    $content,
3✔
353
                    $validName,
3✔
354
                ];
2✔
355
                $phpcsFile->addError($error, $tag, 'InvalidCategory', $data);
3✔
356
            }
357
        }//end foreach
358

359
    }//end processCategory()
1✔
360

361

362
    /**
363
     * Process the package tag.
364
     *
365
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
366
     * @param array                       $tags      The tokens for these tags.
367
     *
368
     * @return void
369
     */
370
    protected function processPackage($phpcsFile, array $tags)
3✔
371
    {
372
        $tokens = $phpcsFile->getTokens();
3✔
373
        foreach ($tags as $tag) {
3✔
374
            if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
3✔
375
                // No content.
376
                continue;
×
377
            }
378

379
            $content = $tokens[($tag + 2)]['content'];
3✔
380
            if (Common::isUnderscoreName($content) === true) {
3✔
381
                continue;
3✔
382
            }
383

384
            $newContent = str_replace(' ', '_', $content);
3✔
385
            $newContent = trim($newContent, '_');
3✔
386
            $newContent = preg_replace('/[^A-Za-z_]/', '', $newContent);
3✔
387

388
            if ($newContent === '') {
3✔
389
                $error = 'Package name "%s" is not valid';
3✔
390
                $data  = [$content];
3✔
391
                $phpcsFile->addError($error, $tag, 'InvalidPackageValue', $data);
3✔
392
            } else {
393
                $nameBits = explode('_', $newContent);
3✔
394
                $firstBit = array_shift($nameBits);
3✔
395
                $newName  = strtoupper($firstBit[0]).substr($firstBit, 1).'_';
3✔
396
                foreach ($nameBits as $bit) {
3✔
397
                    if ($bit !== '') {
3✔
398
                        $newName .= strtoupper($bit[0]).substr($bit, 1).'_';
3✔
399
                    }
400
                }
401

402
                $error     = 'Package name "%s" is not valid; consider "%s" instead';
3✔
403
                $validName = trim($newName, '_');
3✔
404
                $data      = [
2✔
405
                    $content,
3✔
406
                    $validName,
3✔
407
                ];
2✔
408
                $phpcsFile->addError($error, $tag, 'InvalidPackage', $data);
3✔
409
            }//end if
410
        }//end foreach
411

412
    }//end processPackage()
1✔
413

414

415
    /**
416
     * Process the subpackage tag.
417
     *
418
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
419
     * @param array                       $tags      The tokens for these tags.
420
     *
421
     * @return void
422
     */
423
    protected function processSubpackage($phpcsFile, array $tags)
3✔
424
    {
425
        $tokens = $phpcsFile->getTokens();
3✔
426
        foreach ($tags as $tag) {
3✔
427
            if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
3✔
428
                // No content.
429
                continue;
×
430
            }
431

432
            $content = $tokens[($tag + 2)]['content'];
3✔
433
            if (Common::isUnderscoreName($content) === true) {
3✔
434
                continue;
×
435
            }
436

437
            $newContent = str_replace(' ', '_', $content);
3✔
438
            $nameBits   = explode('_', $newContent);
3✔
439
            $firstBit   = array_shift($nameBits);
3✔
440
            $newName    = strtoupper($firstBit[0]).substr($firstBit, 1).'_';
3✔
441
            foreach ($nameBits as $bit) {
3✔
442
                if ($bit !== '') {
3✔
443
                    $newName .= strtoupper($bit[0]).substr($bit, 1).'_';
3✔
444
                }
445
            }
446

447
            $error     = 'Subpackage name "%s" is not valid; consider "%s" instead';
3✔
448
            $validName = trim($newName, '_');
3✔
449
            $data      = [
2✔
450
                $content,
3✔
451
                $validName,
3✔
452
            ];
2✔
453
            $phpcsFile->addError($error, $tag, 'InvalidSubpackage', $data);
3✔
454
        }//end foreach
455

456
    }//end processSubpackage()
1✔
457

458

459
    /**
460
     * Process the author tag(s) that this header comment has.
461
     *
462
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
463
     * @param array                       $tags      The tokens for these tags.
464
     *
465
     * @return void
466
     */
467
    protected function processAuthor($phpcsFile, array $tags)
3✔
468
    {
469
        $tokens = $phpcsFile->getTokens();
3✔
470
        foreach ($tags as $tag) {
3✔
471
            if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
3✔
472
                // No content.
473
                continue;
3✔
474
            }
475

476
            $content = $tokens[($tag + 2)]['content'];
3✔
477
            $local   = '\da-zA-Z-_+';
3✔
478
            // Dot character cannot be the first or last character in the local-part.
479
            $localMiddle = $local.'.\w';
3✔
480
            if (preg_match('/^([^<]*)\s+<(['.$local.'](['.$localMiddle.']*['.$local.'])*@[\da-zA-Z][-.\w]*[\da-zA-Z]\.[a-zA-Z]{2,})>$/', $content) === 0) {
3✔
481
                $error = 'Content of the @author tag must be in the form "Display Name <username@example.com>"';
3✔
482
                $phpcsFile->addError($error, $tag, 'InvalidAuthors');
3✔
483
            }
484
        }
485

486
    }//end processAuthor()
1✔
487

488

489
    /**
490
     * Process the copyright tags.
491
     *
492
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
493
     * @param array                       $tags      The tokens for these tags.
494
     *
495
     * @return void
496
     */
497
    protected function processCopyright($phpcsFile, array $tags)
3✔
498
    {
499
        $tokens = $phpcsFile->getTokens();
3✔
500
        foreach ($tags as $tag) {
3✔
501
            if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
3✔
502
                // No content.
503
                continue;
×
504
            }
505

506
            $content = $tokens[($tag + 2)]['content'];
3✔
507
            $matches = [];
3✔
508
            if (preg_match('/^([0-9]{4})((.{1})([0-9]{4}))? (.+)$/', $content, $matches) !== 0) {
3✔
509
                // Check earliest-latest year order.
510
                if ($matches[3] !== '' && $matches[3] !== null) {
3✔
511
                    if ($matches[3] !== '-') {
3✔
512
                        $error = 'A hyphen must be used between the earliest and latest year';
3✔
513
                        $phpcsFile->addError($error, $tag, 'CopyrightHyphen');
3✔
514
                    }
515

516
                    if ($matches[4] !== '' && $matches[4] !== null && $matches[4] < $matches[1]) {
3✔
517
                        $error = "Invalid year span \"$matches[1]$matches[3]$matches[4]\" found; consider \"$matches[4]-$matches[1]\" instead";
3✔
518
                        $phpcsFile->addWarning($error, $tag, 'InvalidCopyright');
3✔
519
                    }
520
                }
521
            } else {
522
                $error = '@copyright tag must contain a year and the name of the copyright holder';
×
523
                $phpcsFile->addError($error, $tag, 'IncompleteCopyright');
×
524
            }
525
        }//end foreach
526

527
    }//end processCopyright()
1✔
528

529

530
    /**
531
     * Process the license tag.
532
     *
533
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
534
     * @param array                       $tags      The tokens for these tags.
535
     *
536
     * @return void
537
     */
538
    protected function processLicense($phpcsFile, array $tags)
3✔
539
    {
540
        $tokens = $phpcsFile->getTokens();
3✔
541
        foreach ($tags as $tag) {
3✔
542
            if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
3✔
543
                // No content.
544
                continue;
×
545
            }
546

547
            $content = $tokens[($tag + 2)]['content'];
3✔
548
            $matches = [];
3✔
549
            preg_match('/^([^\s]+)\s+(.*)/', $content, $matches);
3✔
550
            if (count($matches) !== 3) {
3✔
551
                $error = '@license tag must contain a URL and a license name';
3✔
552
                $phpcsFile->addError($error, $tag, 'IncompleteLicense');
3✔
553
            }
554
        }
555

556
    }//end processLicense()
1✔
557

558

559
    /**
560
     * Process the version tag.
561
     *
562
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
563
     * @param array                       $tags      The tokens for these tags.
564
     *
565
     * @return void
566
     */
567
    protected function processVersion($phpcsFile, array $tags)
3✔
568
    {
569
        $tokens = $phpcsFile->getTokens();
3✔
570
        foreach ($tags as $tag) {
3✔
571
            if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
3✔
572
                // No content.
573
                continue;
×
574
            }
575

576
            $content = $tokens[($tag + 2)]['content'];
3✔
577
            if (strstr($content, 'CVS:') === false
3✔
578
                && strstr($content, 'SVN:') === false
3✔
579
                && strstr($content, 'GIT:') === false
3✔
580
                && strstr($content, 'HG:') === false
3✔
581
            ) {
582
                $error = 'Invalid version "%s" in file comment; consider "CVS: <cvs_id>" or "SVN: <svn_id>" or "GIT: <git_id>" or "HG: <hg_id>" instead';
3✔
583
                $data  = [$content];
3✔
584
                $phpcsFile->addWarning($error, $tag, 'InvalidVersion', $data);
3✔
585
            }
586
        }
587

588
    }//end processVersion()
1✔
589

590

591
}//end class
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

© 2025 Coveralls, Inc