1<?php
2/**
3 * Ensures doc blocks follow basic formatting.
4 *
5 * PHP version 5
6 *
7 * @category  PHP
8 * @package   PHP_CodeSniffer
9 * @author    Greg Sherwood <gsherwood@squiz.net>
10 * @copyright 2006-2012 Squiz Pty Ltd (ABN 77 084 670 600)
11 * @license   https://github.com/squizlabs/PHP_CodeSniffer/blob/master/licence.txt BSD Licence
12 * @link      http://pear.php.net/package/PHP_CodeSniffer
13 */
14
15/**
16 * Ensures doc blocks follow basic formatting.
17 *
18 * @category  PHP
19 * @package   PHP_CodeSniffer
20 * @author    Greg Sherwood <gsherwood@squiz.net>
21 * @copyright 2006-2012 Squiz Pty Ltd (ABN 77 084 670 600)
22 * @license   https://github.com/squizlabs/PHP_CodeSniffer/blob/master/licence.txt BSD Licence
23 * @version   Release: @package_version@
24 * @link      http://pear.php.net/package/PHP_CodeSniffer
25 */
26class Generic_Sniffs_Commenting_DocCommentSniff implements PHP_CodeSniffer_Sniff
27{
28
29    /**
30     * A list of tokenizers this sniff supports.
31     *
32     * @var array
33     */
34    public $supportedTokenizers = array(
35                                   'PHP',
36                                   'JS',
37                                  );
38
39
40    /**
41     * Returns an array of tokens this test wants to listen for.
42     *
43     * @return array
44     */
45    public function register()
46    {
47        return array(T_DOC_COMMENT_OPEN_TAG);
48
49    }//end register()
50
51
52    /**
53     * Processes this test, when one of its tokens is encountered.
54     *
55     * @param PHP_CodeSniffer_File $phpcsFile The file being scanned.
56     * @param int                  $stackPtr  The position of the current token
57     *                                        in the stack passed in $tokens.
58     *
59     * @return void
60     */
61    public function process(PHP_CodeSniffer_File $phpcsFile, $stackPtr)
62    {
63        $tokens       = $phpcsFile->getTokens();
64        $commentStart = $stackPtr;
65        $commentEnd   = $tokens[$stackPtr]['comment_closer'];
66
67        $empty = array(
68                  T_DOC_COMMENT_WHITESPACE,
69                  T_DOC_COMMENT_STAR,
70                 );
71
72        $short = $phpcsFile->findNext($empty, ($stackPtr + 1), $commentEnd, true);
73        if ($short === false) {
74            // No content at all.
75            $error = 'Doc comment is empty';
76            $phpcsFile->addError($error, $stackPtr, 'Empty');
77            return;
78        }
79
80        // The first line of the comment should just be the /** code.
81        if ($tokens[$short]['line'] === $tokens[$stackPtr]['line']) {
82            $error = 'The open comment tag must be the only content on the line';
83            $fix   = $phpcsFile->addFixableError($error, $stackPtr, 'ContentAfterOpen');
84            if ($fix === true) {
85                $phpcsFile->fixer->beginChangeset();
86                $phpcsFile->fixer->addNewline($stackPtr);
87                $phpcsFile->fixer->addContentBefore($short, '* ');
88                $phpcsFile->fixer->endChangeset();
89            }
90        }
91
92        // The last line of the comment should just be the */ code.
93        $prev = $phpcsFile->findPrevious($empty, ($commentEnd - 1), $stackPtr, true);
94        if ($tokens[$prev]['line'] === $tokens[$commentEnd]['line']) {
95            $error = 'The close comment tag must be the only content on the line';
96            $fix   = $phpcsFile->addFixableError($error, $commentEnd, 'ContentBeforeClose');
97            if ($fix === true) {
98                $phpcsFile->fixer->addNewlineBefore($commentEnd);
99            }
100        }
101
102        // Check for additional blank lines at the end of the comment.
103        if ($tokens[$prev]['line'] < ($tokens[$commentEnd]['line'] - 1)) {
104            $error = 'Additional blank lines found at end of doc comment';
105            $fix   = $phpcsFile->addFixableError($error, $commentEnd, 'SpacingAfter');
106            if ($fix === true) {
107                $phpcsFile->fixer->beginChangeset();
108                for ($i = ($prev + 1); $i < $commentEnd; $i++) {
109                    if ($tokens[($i + 1)]['line'] === $tokens[$commentEnd]['line']) {
110                        break;
111                    }
112
113                    $phpcsFile->fixer->replaceToken($i, '');
114                }
115
116                $phpcsFile->fixer->endChangeset();
117            }
118        }
119
120        // Check for a comment description.
121        if ($tokens[$short]['code'] !== T_DOC_COMMENT_STRING) {
122            $error = 'Missing short description in doc comment';
123            $phpcsFile->addError($error, $stackPtr, 'MissingShort');
124            return;
125        }
126
127        // No extra newline before short description.
128        if ($tokens[$short]['line'] !== ($tokens[$stackPtr]['line'] + 1)) {
129            $error = 'Doc comment short description must be on the first line';
130            $fix   = $phpcsFile->addFixableError($error, $short, 'SpacingBeforeShort');
131            if ($fix === true) {
132                $phpcsFile->fixer->beginChangeset();
133                for ($i = $stackPtr; $i < $short; $i++) {
134                    if ($tokens[$i]['line'] === $tokens[$stackPtr]['line']) {
135                        continue;
136                    } else if ($tokens[$i]['line'] === $tokens[$short]['line']) {
137                        break;
138                    }
139
140                    $phpcsFile->fixer->replaceToken($i, '');
141                }
142
143                $phpcsFile->fixer->endChangeset();
144            }
145        }
146
147        // Account for the fact that a short description might cover
148        // multiple lines.
149        $shortContent = $tokens[$short]['content'];
150        $shortEnd     = $short;
151        for ($i = ($short + 1); $i < $commentEnd; $i++) {
152            if ($tokens[$i]['code'] === T_DOC_COMMENT_STRING) {
153                if ($tokens[$i]['line'] === ($tokens[$shortEnd]['line'] + 1)) {
154                    $shortContent .= $tokens[$i]['content'];
155                    $shortEnd      = $i;
156                } else {
157                    break;
158                }
159            }
160        }
161
162        if (preg_match('/^\p{Ll}/u', $shortContent) === 1) {
163            $error = 'Doc comment short description must start with a capital letter';
164            $phpcsFile->addError($error, $short, 'ShortNotCapital');
165        }
166
167        $long = $phpcsFile->findNext($empty, ($shortEnd + 1), ($commentEnd - 1), true);
168        if ($long !== false && $tokens[$long]['code'] === T_DOC_COMMENT_STRING) {
169            if ($tokens[$long]['line'] !== ($tokens[$shortEnd]['line'] + 2)) {
170                $error = 'There must be exactly one blank line between descriptions in a doc comment';
171                $fix   = $phpcsFile->addFixableError($error, $long, 'SpacingBetween');
172                if ($fix === true) {
173                    $phpcsFile->fixer->beginChangeset();
174                    for ($i = ($shortEnd + 1); $i < $long; $i++) {
175                        if ($tokens[$i]['line'] === $tokens[$shortEnd]['line']) {
176                            continue;
177                        } else if ($tokens[$i]['line'] === ($tokens[$long]['line'] - 1)) {
178                            break;
179                        }
180
181                        $phpcsFile->fixer->replaceToken($i, '');
182                    }
183
184                    $phpcsFile->fixer->endChangeset();
185                }
186            }
187
188            if (preg_match('/^\p{Ll}/u', $tokens[$long]['content']) === 1) {
189                $error = 'Doc comment long description must start with a capital letter';
190                $phpcsFile->addError($error, $long, 'LongNotCapital');
191            }
192        }//end if
193
194        if (empty($tokens[$commentStart]['comment_tags']) === true) {
195            // No tags in the comment.
196            return;
197        }
198
199        $firstTag = $tokens[$commentStart]['comment_tags'][0];
200        $prev     = $phpcsFile->findPrevious($empty, ($firstTag - 1), $stackPtr, true);
201        if ($tokens[$firstTag]['line'] !== ($tokens[$prev]['line'] + 2)) {
202            $error = 'There must be exactly one blank line before the tags in a doc comment';
203            $fix   = $phpcsFile->addFixableError($error, $firstTag, 'SpacingBeforeTags');
204            if ($fix === true) {
205                $phpcsFile->fixer->beginChangeset();
206                for ($i = ($prev + 1); $i < $firstTag; $i++) {
207                    if ($tokens[$i]['line'] === $tokens[$firstTag]['line']) {
208                        break;
209                    }
210
211                    $phpcsFile->fixer->replaceToken($i, '');
212                }
213
214                $indent = str_repeat(' ', $tokens[$stackPtr]['column']);
215                $phpcsFile->fixer->addContent($prev, $phpcsFile->eolChar.$indent.'*'.$phpcsFile->eolChar);
216                $phpcsFile->fixer->endChangeset();
217            }
218        }
219
220        // Break out the tags into groups and check alignment within each.
221        // A tag group is one where there are no blank lines between tags.
222        // The param tag group is special as it requires all @param tags to be inside.
223        $tagGroups    = array();
224        $groupid      = 0;
225        $paramGroupid = null;
226        foreach ($tokens[$commentStart]['comment_tags'] as $pos => $tag) {
227            if ($pos > 0) {
228                $prev = $phpcsFile->findPrevious(
229                    T_DOC_COMMENT_STRING,
230                    ($tag - 1),
231                    $tokens[$commentStart]['comment_tags'][($pos - 1)]
232                );
233
234                if ($prev === false) {
235                    $prev = $tokens[$commentStart]['comment_tags'][($pos - 1)];
236                }
237
238                if ($tokens[$prev]['line'] !== ($tokens[$tag]['line'] - 1)) {
239                    $groupid++;
240                }
241            }
242
243            if ($tokens[$tag]['content'] === '@param') {
244                if (($paramGroupid === null
245                    && empty($tagGroups[$groupid]) === false)
246                    || ($paramGroupid !== null
247                    && $paramGroupid !== $groupid)
248                ) {
249                    $error = 'Parameter tags must be grouped together in a doc comment';
250                    $phpcsFile->addError($error, $tag, 'ParamGroup');
251                }
252
253                if ($paramGroupid === null) {
254                    $paramGroupid = $groupid;
255                }
256            } else if ($groupid === $paramGroupid) {
257                $error = 'Tag cannot be grouped with parameter tags in a doc comment';
258                $phpcsFile->addError($error, $tag, 'NonParamGroup');
259            }//end if
260
261            $tagGroups[$groupid][] = $tag;
262        }//end foreach
263
264        foreach ($tagGroups as $group) {
265            $maxLength = 0;
266            $paddings  = array();
267            foreach ($group as $pos => $tag) {
268                $tagLength = strlen($tokens[$tag]['content']);
269                if ($tagLength > $maxLength) {
270                    $maxLength = $tagLength;
271                }
272
273                // Check for a value. No value means no padding needed.
274                $string = $phpcsFile->findNext(T_DOC_COMMENT_STRING, $tag, $commentEnd);
275                if ($string !== false && $tokens[$string]['line'] === $tokens[$tag]['line']) {
276                    $paddings[$tag] = strlen($tokens[($tag + 1)]['content']);
277                }
278            }
279
280            // Check that there was single blank line after the tag block
281            // but account for a multi-line tag comments.
282            $lastTag = $group[$pos];
283            $next    = $phpcsFile->findNext(T_DOC_COMMENT_TAG, ($lastTag + 3), $commentEnd);
284            if ($next !== false) {
285                $prev = $phpcsFile->findPrevious(array(T_DOC_COMMENT_TAG, T_DOC_COMMENT_STRING), ($next - 1), $commentStart);
286                if ($tokens[$next]['line'] !== ($tokens[$prev]['line'] + 2)) {
287                    $error = 'There must be a single blank line after a tag group';
288                    $fix   = $phpcsFile->addFixableError($error, $lastTag, 'SpacingAfterTagGroup');
289                    if ($fix === true) {
290                        $phpcsFile->fixer->beginChangeset();
291                        for ($i = ($prev + 1); $i < $next; $i++) {
292                            if ($tokens[$i]['line'] === $tokens[$next]['line']) {
293                                break;
294                            }
295
296                            $phpcsFile->fixer->replaceToken($i, '');
297                        }
298
299                        $indent = str_repeat(' ', $tokens[$stackPtr]['column']);
300                        $phpcsFile->fixer->addContent($prev, $phpcsFile->eolChar.$indent.'*'.$phpcsFile->eolChar);
301                        $phpcsFile->fixer->endChangeset();
302                    }
303                }
304            }//end if
305
306            // Now check paddings.
307            foreach ($paddings as $tag => $padding) {
308                $required = ($maxLength - strlen($tokens[$tag]['content']) + 1);
309
310                if ($padding !== $required) {
311                    $error = 'Tag value indented incorrectly; expected %s spaces but found %s';
312                    $data  = array(
313                              $required,
314                              $padding,
315                             );
316
317                    $fix = $phpcsFile->addFixableError($error, ($tag + 1), 'TagValueIndent', $data);
318                    if ($fix === true) {
319                        $phpcsFile->fixer->replaceToken(($tag + 1), str_repeat(' ', $required));
320                    }
321                }
322            }
323        }//end foreach
324
325        // If there is a param group, it needs to be first.
326        if ($paramGroupid !== null && $paramGroupid !== 0) {
327            $error = 'Parameter tags must be defined first in a doc comment';
328            $phpcsFile->addError($error, $tagGroups[$paramGroupid][0], 'ParamNotFirst');
329        }
330
331        $foundTags = array();
332        foreach ($tokens[$stackPtr]['comment_tags'] as $pos => $tag) {
333            $tagName = $tokens[$tag]['content'];
334            if (isset($foundTags[$tagName]) === true) {
335                $lastTag = $tokens[$stackPtr]['comment_tags'][($pos - 1)];
336                if ($tokens[$lastTag]['content'] !== $tagName) {
337                    $error = 'Tags must be grouped together in a doc comment';
338                    $phpcsFile->addError($error, $tag, 'TagsNotGrouped');
339                }
340
341                continue;
342            }
343
344            $foundTags[$tagName] = true;
345        }
346
347    }//end process()
348
349
350}//end class
351