1<?php
2/**
3 * Parses and verifies the doc comments for files.
4 *
5 * PHP version 5
6 *
7 * @category  PHP
8 * @package   PHP_CodeSniffer
9 * @author    Greg Sherwood <gsherwood@squiz.net>
10 * @author    Marc McIntyre <mmcintyre@squiz.net>
11 * @copyright 2006-2014 Squiz Pty Ltd (ABN 77 084 670 600)
12 * @license   https://github.com/squizlabs/PHP_CodeSniffer/blob/master/licence.txt BSD Licence
13 * @link      http://pear.php.net/package/PHP_CodeSniffer
14 */
15
16/**
17 * Parses and verifies the doc comments for files.
18 *
19 * @category  PHP
20 * @package   PHP_CodeSniffer
21 * @author    Greg Sherwood <gsherwood@squiz.net>
22 * @author    Marc McIntyre <mmcintyre@squiz.net>
23 * @copyright 2006-2014 Squiz Pty Ltd (ABN 77 084 670 600)
24 * @license   https://github.com/squizlabs/PHP_CodeSniffer/blob/master/licence.txt BSD Licence
25 * @version   Release: @package_version@
26 * @link      http://pear.php.net/package/PHP_CodeSniffer
27 */
28
29class PEAR_Sniffs_Commenting_FileCommentSniff implements PHP_CodeSniffer_Sniff
30{
31
32    /**
33     * Tags in correct order and related info.
34     *
35     * @var array
36     */
37    protected $tags = array(
38                       '@category'   => array(
39                                         'required'       => true,
40                                         'allow_multiple' => false,
41                                        ),
42                       '@package'    => array(
43                                         'required'       => true,
44                                         'allow_multiple' => false,
45                                        ),
46                       '@subpackage' => array(
47                                         'required'       => false,
48                                         'allow_multiple' => false,
49                                        ),
50                       '@author'     => array(
51                                         'required'       => true,
52                                         'allow_multiple' => true,
53                                        ),
54                       '@copyright'  => array(
55                                         'required'       => false,
56                                         'allow_multiple' => true,
57                                        ),
58                       '@license'    => array(
59                                         'required'       => true,
60                                         'allow_multiple' => false,
61                                        ),
62                       '@version'    => array(
63                                         'required'       => false,
64                                         'allow_multiple' => false,
65                                        ),
66                       '@link'       => array(
67                                         'required'       => true,
68                                         'allow_multiple' => true,
69                                        ),
70                       '@see'        => array(
71                                         'required'       => false,
72                                         'allow_multiple' => true,
73                                        ),
74                       '@since'      => array(
75                                         'required'       => false,
76                                         'allow_multiple' => false,
77                                        ),
78                       '@deprecated' => array(
79                                         'required'       => false,
80                                         'allow_multiple' => false,
81                                        ),
82                      );
83
84
85    /**
86     * Returns an array of tokens this test wants to listen for.
87     *
88     * @return array
89     */
90    public function register()
91    {
92        return array(T_OPEN_TAG);
93
94    }//end register()
95
96
97    /**
98     * Processes this test, when one of its tokens is encountered.
99     *
100     * @param PHP_CodeSniffer_File $phpcsFile The file being scanned.
101     * @param int                  $stackPtr  The position of the current token
102     *                                        in the stack passed in $tokens.
103     *
104     * @return int
105     */
106    public function process(PHP_CodeSniffer_File $phpcsFile, $stackPtr)
107    {
108        $tokens = $phpcsFile->getTokens();
109
110        // Find the next non whitespace token.
111        $commentStart = $phpcsFile->findNext(T_WHITESPACE, ($stackPtr + 1), null, true);
112
113        // Allow declare() statements at the top of the file.
114        if ($tokens[$commentStart]['code'] === T_DECLARE) {
115            $semicolon    = $phpcsFile->findNext(T_SEMICOLON, ($commentStart + 1));
116            $commentStart = $phpcsFile->findNext(T_WHITESPACE, ($semicolon + 1), null, true);
117        }
118
119        // Ignore vim header.
120        if ($tokens[$commentStart]['code'] === T_COMMENT) {
121            if (strstr($tokens[$commentStart]['content'], 'vim:') !== false) {
122                $commentStart = $phpcsFile->findNext(
123                    T_WHITESPACE,
124                    ($commentStart + 1),
125                    null,
126                    true
127                );
128            }
129        }
130
131        $errorToken = ($stackPtr + 1);
132        if (isset($tokens[$errorToken]) === false) {
133            $errorToken--;
134        }
135
136        if ($tokens[$commentStart]['code'] === T_CLOSE_TAG) {
137            // We are only interested if this is the first open tag.
138            return ($phpcsFile->numTokens + 1);
139        } else if ($tokens[$commentStart]['code'] === T_COMMENT) {
140            $error = 'You must use "/**" style comments for a file comment';
141            $phpcsFile->addError($error, $errorToken, 'WrongStyle');
142            $phpcsFile->recordMetric($stackPtr, 'File has doc comment', 'yes');
143            return ($phpcsFile->numTokens + 1);
144        } else if ($commentStart === false
145            || $tokens[$commentStart]['code'] !== T_DOC_COMMENT_OPEN_TAG
146        ) {
147            $phpcsFile->addError('Missing file doc comment', $errorToken, 'Missing');
148            $phpcsFile->recordMetric($stackPtr, 'File has doc comment', 'no');
149            return ($phpcsFile->numTokens + 1);
150        }
151
152        $commentEnd = $tokens[$commentStart]['comment_closer'];
153
154        $nextToken = $phpcsFile->findNext(
155            T_WHITESPACE,
156            ($commentEnd + 1),
157            null,
158            true
159        );
160
161        $ignore = array(
162                   T_CLASS,
163                   T_INTERFACE,
164                   T_TRAIT,
165                   T_FUNCTION,
166                   T_CLOSURE,
167                   T_PUBLIC,
168                   T_PRIVATE,
169                   T_PROTECTED,
170                   T_FINAL,
171                   T_STATIC,
172                   T_ABSTRACT,
173                   T_CONST,
174                   T_PROPERTY,
175                  );
176
177        if (in_array($tokens[$nextToken]['code'], $ignore) === true) {
178            $phpcsFile->addError('Missing file doc comment', $stackPtr, 'Missing');
179            $phpcsFile->recordMetric($stackPtr, 'File has doc comment', 'no');
180            return ($phpcsFile->numTokens + 1);
181        }
182
183        $phpcsFile->recordMetric($stackPtr, 'File has doc comment', 'yes');
184
185        // Check the PHP Version, which should be in some text before the first tag.
186        $found = false;
187        for ($i = ($commentStart + 1); $i < $commentEnd; $i++) {
188            if ($tokens[$i]['code'] === T_DOC_COMMENT_TAG) {
189                break;
190            } else if ($tokens[$i]['code'] === T_DOC_COMMENT_STRING
191                && strstr(strtolower($tokens[$i]['content']), 'php version') !== false
192            ) {
193                $found = true;
194                break;
195            }
196        }
197
198        if ($found === false) {
199            $error = 'PHP version not specified';
200            $phpcsFile->addWarning($error, $commentEnd, 'MissingVersion');
201        }
202
203        // Check each tag.
204        $this->processTags($phpcsFile, $stackPtr, $commentStart);
205
206        // Ignore the rest of the file.
207        return ($phpcsFile->numTokens + 1);
208
209    }//end process()
210
211
212    /**
213     * Processes each required or optional tag.
214     *
215     * @param PHP_CodeSniffer_File $phpcsFile    The file being scanned.
216     * @param int                  $stackPtr     The position of the current token
217     *                                           in the stack passed in $tokens.
218     * @param int                  $commentStart Position in the stack where the comment started.
219     *
220     * @return void
221     */
222    protected function processTags(PHP_CodeSniffer_File $phpcsFile, $stackPtr, $commentStart)
223    {
224        $tokens = $phpcsFile->getTokens();
225
226        if (get_class($this) === 'PEAR_Sniffs_Commenting_FileCommentSniff') {
227            $docBlock = 'file';
228        } else {
229            $docBlock = 'class';
230        }
231
232        $commentEnd = $tokens[$commentStart]['comment_closer'];
233
234        $foundTags = array();
235        $tagTokens = array();
236        foreach ($tokens[$commentStart]['comment_tags'] as $tag) {
237            $name = $tokens[$tag]['content'];
238            if (isset($this->tags[$name]) === false) {
239                continue;
240            }
241
242            if ($this->tags[$name]['allow_multiple'] === false && isset($tagTokens[$name]) === true) {
243                $error = 'Only one %s tag is allowed in a %s comment';
244                $data  = array(
245                          $name,
246                          $docBlock,
247                         );
248                $phpcsFile->addError($error, $tag, 'Duplicate'.ucfirst(substr($name, 1)).'Tag', $data);
249            }
250
251            $foundTags[]        = $name;
252            $tagTokens[$name][] = $tag;
253
254            $string = $phpcsFile->findNext(T_DOC_COMMENT_STRING, $tag, $commentEnd);
255            if ($string === false || $tokens[$string]['line'] !== $tokens[$tag]['line']) {
256                $error = 'Content missing for %s tag in %s comment';
257                $data  = array(
258                          $name,
259                          $docBlock,
260                         );
261                $phpcsFile->addError($error, $tag, 'Empty'.ucfirst(substr($name, 1)).'Tag', $data);
262                continue;
263            }
264        }//end foreach
265
266        // Check if the tags are in the correct position.
267        $pos = 0;
268        foreach ($this->tags as $tag => $tagData) {
269            if (isset($tagTokens[$tag]) === false) {
270                if ($tagData['required'] === true) {
271                    $error = 'Missing %s tag in %s comment';
272                    $data  = array(
273                              $tag,
274                              $docBlock,
275                             );
276                    $phpcsFile->addError($error, $commentEnd, 'Missing'.ucfirst(substr($tag, 1)).'Tag', $data);
277                }
278
279                continue;
280            } else {
281                $method = 'process'.substr($tag, 1);
282                if (method_exists($this, $method) === true) {
283                    // Process each tag if a method is defined.
284                    call_user_func(array($this, $method), $phpcsFile, $tagTokens[$tag]);
285                }
286            }
287
288            if (isset($foundTags[$pos]) === false) {
289                break;
290            }
291
292            if ($foundTags[$pos] !== $tag) {
293                $error = 'The tag in position %s should be the %s tag';
294                $data  = array(
295                          ($pos + 1),
296                          $tag,
297                         );
298                $phpcsFile->addError($error, $tokens[$commentStart]['comment_tags'][$pos], ucfirst(substr($tag, 1)).'TagOrder', $data);
299            }
300
301            // Account for multiple tags.
302            $pos++;
303            while (isset($foundTags[$pos]) === true && $foundTags[$pos] === $tag) {
304                $pos++;
305            }
306        }//end foreach
307
308    }//end processTags()
309
310
311    /**
312     * Process the category tag.
313     *
314     * @param PHP_CodeSniffer_File $phpcsFile The file being scanned.
315     * @param array                $tags      The tokens for these tags.
316     *
317     * @return void
318     */
319    protected function processCategory(PHP_CodeSniffer_File $phpcsFile, array $tags)
320    {
321        $tokens = $phpcsFile->getTokens();
322        foreach ($tags as $tag) {
323            if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
324                // No content.
325                continue;
326            }
327
328            $content = $tokens[($tag + 2)]['content'];
329            if (PHP_CodeSniffer::isUnderscoreName($content) !== true) {
330                $newContent = str_replace(' ', '_', $content);
331                $nameBits   = explode('_', $newContent);
332                $firstBit   = array_shift($nameBits);
333                $newName    = ucfirst($firstBit).'_';
334                foreach ($nameBits as $bit) {
335                    if ($bit !== '') {
336                        $newName .= ucfirst($bit).'_';
337                    }
338                }
339
340                $error     = 'Category name "%s" is not valid; consider "%s" instead';
341                $validName = trim($newName, '_');
342                $data      = array(
343                              $content,
344                              $validName,
345                             );
346                $phpcsFile->addError($error, $tag, 'InvalidCategory', $data);
347            }
348        }//end foreach
349
350    }//end processCategory()
351
352
353    /**
354     * Process the package tag.
355     *
356     * @param PHP_CodeSniffer_File $phpcsFile The file being scanned.
357     * @param array                $tags      The tokens for these tags.
358     *
359     * @return void
360     */
361    protected function processPackage(PHP_CodeSniffer_File $phpcsFile, array $tags)
362    {
363        $tokens = $phpcsFile->getTokens();
364        foreach ($tags as $tag) {
365            if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
366                // No content.
367                continue;
368            }
369
370            $content = $tokens[($tag + 2)]['content'];
371            if (PHP_CodeSniffer::isUnderscoreName($content) === true) {
372                continue;
373            }
374
375            $newContent = str_replace(' ', '_', $content);
376            $newContent = trim($newContent, '_');
377            $newContent = preg_replace('/[^A-Za-z_]/', '', $newContent);
378
379            if ($newContent === '') {
380                $error = 'Package name "%s" is not valid';
381                $data  = array($content);
382                $phpcsFile->addError($error, $tag, 'InvalidPackageValue', $data);
383            } else {
384                $nameBits = explode('_', $newContent);
385                $firstBit = array_shift($nameBits);
386                $newName  = strtoupper($firstBit{0}).substr($firstBit, 1).'_';
387                foreach ($nameBits as $bit) {
388                    if ($bit !== '') {
389                        $newName .= strtoupper($bit{0}).substr($bit, 1).'_';
390                    }
391                }
392
393                $error     = 'Package name "%s" is not valid; consider "%s" instead';
394                $validName = trim($newName, '_');
395                $data      = array(
396                              $content,
397                              $validName,
398                             );
399                $phpcsFile->addError($error, $tag, 'InvalidPackage', $data);
400            }//end if
401        }//end foreach
402
403    }//end processPackage()
404
405
406    /**
407     * Process the subpackage tag.
408     *
409     * @param PHP_CodeSniffer_File $phpcsFile The file being scanned.
410     * @param array                $tags      The tokens for these tags.
411     *
412     * @return void
413     */
414    protected function processSubpackage(PHP_CodeSniffer_File $phpcsFile, array $tags)
415    {
416        $tokens = $phpcsFile->getTokens();
417        foreach ($tags as $tag) {
418            if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
419                // No content.
420                continue;
421            }
422
423            $content = $tokens[($tag + 2)]['content'];
424            if (PHP_CodeSniffer::isUnderscoreName($content) === true) {
425                continue;
426            }
427
428            $newContent = str_replace(' ', '_', $content);
429            $nameBits   = explode('_', $newContent);
430            $firstBit   = array_shift($nameBits);
431            $newName    = strtoupper($firstBit{0}).substr($firstBit, 1).'_';
432            foreach ($nameBits as $bit) {
433                if ($bit !== '') {
434                    $newName .= strtoupper($bit{0}).substr($bit, 1).'_';
435                }
436            }
437
438            $error     = 'Subpackage name "%s" is not valid; consider "%s" instead';
439            $validName = trim($newName, '_');
440            $data      = array(
441                          $content,
442                          $validName,
443                         );
444            $phpcsFile->addError($error, $tag, 'InvalidSubpackage', $data);
445        }//end foreach
446
447    }//end processSubpackage()
448
449
450    /**
451     * Process the author tag(s) that this header comment has.
452     *
453     * @param PHP_CodeSniffer_File $phpcsFile The file being scanned.
454     * @param array                $tags      The tokens for these tags.
455     *
456     * @return void
457     */
458    protected function processAuthor(PHP_CodeSniffer_File $phpcsFile, array $tags)
459    {
460        $tokens = $phpcsFile->getTokens();
461        foreach ($tags as $tag) {
462            if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
463                // No content.
464                continue;
465            }
466
467            $content = $tokens[($tag + 2)]['content'];
468            $local   = '\da-zA-Z-_+';
469            // Dot character cannot be the first or last character in the local-part.
470            $localMiddle = $local.'.\w';
471            if (preg_match('/^([^<]*)\s+<(['.$local.'](['.$localMiddle.']*['.$local.'])*@[\da-zA-Z][-.\w]*[\da-zA-Z]\.[a-zA-Z]{2,7})>$/', $content) === 0) {
472                $error = 'Content of the @author tag must be in the form "Display Name <username@example.com>"';
473                $phpcsFile->addError($error, $tag, 'InvalidAuthors');
474            }
475        }
476
477    }//end processAuthor()
478
479
480    /**
481     * Process the copyright tags.
482     *
483     * @param PHP_CodeSniffer_File $phpcsFile The file being scanned.
484     * @param array                $tags      The tokens for these tags.
485     *
486     * @return void
487     */
488    protected function processCopyright(PHP_CodeSniffer_File $phpcsFile, array $tags)
489    {
490        $tokens = $phpcsFile->getTokens();
491        foreach ($tags as $tag) {
492            if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
493                // No content.
494                continue;
495            }
496
497            $content = $tokens[($tag + 2)]['content'];
498            $matches = array();
499            if (preg_match('/^([0-9]{4})((.{1})([0-9]{4}))? (.+)$/', $content, $matches) !== 0) {
500                // Check earliest-latest year order.
501                if ($matches[3] !== '' && $matches[3] !== null) {
502                    if ($matches[3] !== '-') {
503                        $error = 'A hyphen must be used between the earliest and latest year';
504                        $phpcsFile->addError($error, $tag, 'CopyrightHyphen');
505                    }
506
507                    if ($matches[4] !== '' && $matches[4] !== null && $matches[4] < $matches[1]) {
508                        $error = "Invalid year span \"$matches[1]$matches[3]$matches[4]\" found; consider \"$matches[4]-$matches[1]\" instead";
509                        $phpcsFile->addWarning($error, $tag, 'InvalidCopyright');
510                    }
511                }
512            } else {
513                $error = '@copyright tag must contain a year and the name of the copyright holder';
514                $phpcsFile->addError($error, $tag, 'IncompleteCopyright');
515            }
516        }//end foreach
517
518    }//end processCopyright()
519
520
521    /**
522     * Process the license tag.
523     *
524     * @param PHP_CodeSniffer_File $phpcsFile The file being scanned.
525     * @param array                $tags      The tokens for these tags.
526     *
527     * @return void
528     */
529    protected function processLicense(PHP_CodeSniffer_File $phpcsFile, array $tags)
530    {
531        $tokens = $phpcsFile->getTokens();
532        foreach ($tags as $tag) {
533            if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
534                // No content.
535                continue;
536            }
537
538            $content = $tokens[($tag + 2)]['content'];
539            $matches = array();
540            preg_match('/^([^\s]+)\s+(.*)/', $content, $matches);
541            if (count($matches) !== 3) {
542                $error = '@license tag must contain a URL and a license name';
543                $phpcsFile->addError($error, $tag, 'IncompleteLicense');
544            }
545        }
546
547    }//end processLicense()
548
549
550    /**
551     * Process the version tag.
552     *
553     * @param PHP_CodeSniffer_File $phpcsFile The file being scanned.
554     * @param array                $tags      The tokens for these tags.
555     *
556     * @return void
557     */
558    protected function processVersion(PHP_CodeSniffer_File $phpcsFile, array $tags)
559    {
560        $tokens = $phpcsFile->getTokens();
561        foreach ($tags as $tag) {
562            if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
563                // No content.
564                continue;
565            }
566
567            $content = $tokens[($tag + 2)]['content'];
568            if (strstr($content, 'CVS:') === false
569                && strstr($content, 'SVN:') === false
570                && strstr($content, 'GIT:') === false
571                && strstr($content, 'HG:') === false
572            ) {
573                $error = 'Invalid version "%s" in file comment; consider "CVS: <cvs_id>" or "SVN: <svn_id>" or "GIT: <git_id>" or "HG: <hg_id>" instead';
574                $data  = array($content);
575                $phpcsFile->addWarning($error, $tag, 'InvalidVersion', $data);
576            }
577        }
578
579    }//end processVersion()
580
581
582}//end class
583