1<?php
2/**
3 * Parses and verifies the doc comments for functions.
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 functions.
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 */
28class PEAR_Sniffs_Commenting_FunctionCommentSniff implements PHP_CodeSniffer_Sniff
29{
30
31
32    /**
33     * Returns an array of tokens this test wants to listen for.
34     *
35     * @return array
36     */
37    public function register()
38    {
39        return array(T_FUNCTION);
40
41    }//end register()
42
43
44    /**
45     * Processes this test, when one of its tokens is encountered.
46     *
47     * @param PHP_CodeSniffer_File $phpcsFile The file being scanned.
48     * @param int                  $stackPtr  The position of the current token
49     *                                        in the stack passed in $tokens.
50     *
51     * @return void
52     */
53    public function process(PHP_CodeSniffer_File $phpcsFile, $stackPtr)
54    {
55        $tokens = $phpcsFile->getTokens();
56        $find   = PHP_CodeSniffer_Tokens::$methodPrefixes;
57        $find[] = T_WHITESPACE;
58
59        $commentEnd = $phpcsFile->findPrevious($find, ($stackPtr - 1), null, true);
60        if ($tokens[$commentEnd]['code'] === T_COMMENT) {
61            // Inline comments might just be closing comments for
62            // control structures or functions instead of function comments
63            // using the wrong comment type. If there is other code on the line,
64            // assume they relate to that code.
65            $prev = $phpcsFile->findPrevious($find, ($commentEnd - 1), null, true);
66            if ($prev !== false && $tokens[$prev]['line'] === $tokens[$commentEnd]['line']) {
67                $commentEnd = $prev;
68            }
69        }
70
71        if ($tokens[$commentEnd]['code'] !== T_DOC_COMMENT_CLOSE_TAG
72            && $tokens[$commentEnd]['code'] !== T_COMMENT
73        ) {
74            $phpcsFile->addError('Missing function doc comment', $stackPtr, 'Missing');
75            $phpcsFile->recordMetric($stackPtr, 'Function has doc comment', 'no');
76            return;
77        } else {
78            $phpcsFile->recordMetric($stackPtr, 'Function has doc comment', 'yes');
79        }
80
81        if ($tokens[$commentEnd]['code'] === T_COMMENT) {
82            $phpcsFile->addError('You must use "/**" style comments for a function comment', $stackPtr, 'WrongStyle');
83            return;
84        }
85
86        if ($tokens[$commentEnd]['line'] !== ($tokens[$stackPtr]['line'] - 1)) {
87            $error = 'There must be no blank lines after the function comment';
88            $phpcsFile->addError($error, $commentEnd, 'SpacingAfter');
89        }
90
91        $commentStart = $tokens[$commentEnd]['comment_opener'];
92        foreach ($tokens[$commentStart]['comment_tags'] as $tag) {
93            if ($tokens[$tag]['content'] === '@see') {
94                // Make sure the tag isn't empty.
95                $string = $phpcsFile->findNext(T_DOC_COMMENT_STRING, $tag, $commentEnd);
96                if ($string === false || $tokens[$string]['line'] !== $tokens[$tag]['line']) {
97                    $error = 'Content missing for @see tag in function comment';
98                    $phpcsFile->addError($error, $tag, 'EmptySees');
99                }
100            }
101        }
102
103        $this->processReturn($phpcsFile, $stackPtr, $commentStart);
104        $this->processThrows($phpcsFile, $stackPtr, $commentStart);
105        $this->processParams($phpcsFile, $stackPtr, $commentStart);
106
107    }//end process()
108
109
110    /**
111     * Process the return comment of this function comment.
112     *
113     * @param PHP_CodeSniffer_File $phpcsFile    The file being scanned.
114     * @param int                  $stackPtr     The position of the current token
115     *                                           in the stack passed in $tokens.
116     * @param int                  $commentStart The position in the stack where the comment started.
117     *
118     * @return void
119     */
120    protected function processReturn(PHP_CodeSniffer_File $phpcsFile, $stackPtr, $commentStart)
121    {
122        $tokens = $phpcsFile->getTokens();
123
124        // Skip constructor and destructor.
125        $methodName      = $phpcsFile->getDeclarationName($stackPtr);
126        $isSpecialMethod = ($methodName === '__construct' || $methodName === '__destruct');
127
128        $return = null;
129        foreach ($tokens[$commentStart]['comment_tags'] as $tag) {
130            if ($tokens[$tag]['content'] === '@return') {
131                if ($return !== null) {
132                    $error = 'Only 1 @return tag is allowed in a function comment';
133                    $phpcsFile->addError($error, $tag, 'DuplicateReturn');
134                    return;
135                }
136
137                $return = $tag;
138            }
139        }
140
141        if ($isSpecialMethod === true) {
142            return;
143        }
144
145        if ($return !== null) {
146            $content = $tokens[($return + 2)]['content'];
147            if (empty($content) === true || $tokens[($return + 2)]['code'] !== T_DOC_COMMENT_STRING) {
148                $error = 'Return type missing for @return tag in function comment';
149                $phpcsFile->addError($error, $return, 'MissingReturnType');
150            }
151        } else {
152            $error = 'Missing @return tag in function comment';
153            $phpcsFile->addError($error, $tokens[$commentStart]['comment_closer'], 'MissingReturn');
154        }//end if
155
156    }//end processReturn()
157
158
159    /**
160     * Process any throw tags that this function comment has.
161     *
162     * @param PHP_CodeSniffer_File $phpcsFile    The file being scanned.
163     * @param int                  $stackPtr     The position of the current token
164     *                                           in the stack passed in $tokens.
165     * @param int                  $commentStart The position in the stack where the comment started.
166     *
167     * @return void
168     */
169    protected function processThrows(PHP_CodeSniffer_File $phpcsFile, $stackPtr, $commentStart)
170    {
171        $tokens = $phpcsFile->getTokens();
172
173        $throws = array();
174        foreach ($tokens[$commentStart]['comment_tags'] as $tag) {
175            if ($tokens[$tag]['content'] !== '@throws') {
176                continue;
177            }
178
179            $exception = null;
180            $comment   = null;
181            if ($tokens[($tag + 2)]['code'] === T_DOC_COMMENT_STRING) {
182                $matches = array();
183                preg_match('/([^\s]+)(?:\s+(.*))?/', $tokens[($tag + 2)]['content'], $matches);
184                $exception = $matches[1];
185                if (isset($matches[2]) === true) {
186                    $comment = $matches[2];
187                }
188            }
189
190            if ($exception === null) {
191                $error = 'Exception type missing for @throws tag in function comment';
192                $phpcsFile->addError($error, $tag, 'InvalidThrows');
193            }
194        }//end foreach
195
196    }//end processThrows()
197
198
199    /**
200     * Process the function parameter comments.
201     *
202     * @param PHP_CodeSniffer_File $phpcsFile    The file being scanned.
203     * @param int                  $stackPtr     The position of the current token
204     *                                           in the stack passed in $tokens.
205     * @param int                  $commentStart The position in the stack where the comment started.
206     *
207     * @return void
208     */
209    protected function processParams(PHP_CodeSniffer_File $phpcsFile, $stackPtr, $commentStart)
210    {
211        $tokens = $phpcsFile->getTokens();
212
213        $params  = array();
214        $maxType = 0;
215        $maxVar  = 0;
216        foreach ($tokens[$commentStart]['comment_tags'] as $pos => $tag) {
217            if ($tokens[$tag]['content'] !== '@param') {
218                continue;
219            }
220
221            $type       = '';
222            $typeSpace  = 0;
223            $var        = '';
224            $varSpace   = 0;
225            $comment    = '';
226            $commentEnd = 0;
227            if ($tokens[($tag + 2)]['code'] === T_DOC_COMMENT_STRING) {
228                $matches = array();
229                preg_match('/([^$&.]+)(?:((?:\.\.\.)?(?:\$|&)[^\s]+)(?:(\s+)(.*))?)?/', $tokens[($tag + 2)]['content'], $matches);
230
231                if (empty($matches) === false) {
232                    $typeLen   = strlen($matches[1]);
233                    $type      = trim($matches[1]);
234                    $typeSpace = ($typeLen - strlen($type));
235                    $typeLen   = strlen($type);
236                    if ($typeLen > $maxType) {
237                        $maxType = $typeLen;
238                    }
239                }
240
241                if (isset($matches[2]) === true) {
242                    $var    = $matches[2];
243                    $varLen = strlen($var);
244                    if ($varLen > $maxVar) {
245                        $maxVar = $varLen;
246                    }
247
248                    if (isset($matches[4]) === true) {
249                        $varSpace = strlen($matches[3]);
250                        $comment  = $matches[4];
251
252                        // Any strings until the next tag belong to this comment.
253                        if (isset($tokens[$commentStart]['comment_tags'][($pos + 1)]) === true) {
254                            $end = $tokens[$commentStart]['comment_tags'][($pos + 1)];
255                        } else {
256                            $end = $tokens[$commentStart]['comment_closer'];
257                        }
258
259                        for ($i = ($tag + 3); $i < $end; $i++) {
260                            if ($tokens[$i]['code'] === T_DOC_COMMENT_STRING) {
261                                $comment   .= ' '.$tokens[$i]['content'];
262                                $commentEnd = $i;
263                            }
264                        }
265                    } else {
266                        $error = 'Missing parameter comment';
267                        $phpcsFile->addError($error, $tag, 'MissingParamComment');
268                    }//end if
269                } else {
270                    $error = 'Missing parameter name';
271                    $phpcsFile->addError($error, $tag, 'MissingParamName');
272                }//end if
273            } else {
274                $error = 'Missing parameter type';
275                $phpcsFile->addError($error, $tag, 'MissingParamType');
276            }//end if
277
278            $params[] = array(
279                         'tag'         => $tag,
280                         'type'        => $type,
281                         'var'         => $var,
282                         'comment'     => $comment,
283                         'comment_end' => $commentEnd,
284                         'type_space'  => $typeSpace,
285                         'var_space'   => $varSpace,
286                        );
287        }//end foreach
288
289        $realParams  = $phpcsFile->getMethodParameters($stackPtr);
290        $foundParams = array();
291
292        // We want to use ... for all variable length arguments, so added
293        // this prefix to the variable name so comparisons are easier.
294        foreach ($realParams as $pos => $param) {
295            if ($param['variable_length'] === true) {
296                $realParams[$pos]['name'] = '...'.$realParams[$pos]['name'];
297            }
298        }
299
300        foreach ($params as $pos => $param) {
301            if ($param['var'] === '') {
302                continue;
303            }
304
305            $foundParams[] = $param['var'];
306
307            // Check number of spaces after the type.
308            $spaces = ($maxType - strlen($param['type']) + 1);
309            if ($param['type_space'] !== $spaces) {
310                $error = 'Expected %s spaces after parameter type; %s found';
311                $data  = array(
312                          $spaces,
313                          $param['type_space'],
314                         );
315
316                $fix = $phpcsFile->addFixableError($error, $param['tag'], 'SpacingAfterParamType', $data);
317                if ($fix === true) {
318                    $commentToken = ($param['tag'] + 2);
319
320                    $content  = $param['type'];
321                    $content .= str_repeat(' ', $spaces);
322                    $content .= $param['var'];
323                    $content .= str_repeat(' ', $param['var_space']);
324
325                    $wrapLength = ($tokens[$commentToken]['length'] - $param['type_space'] - $param['var_space'] - strlen($param['type']) - strlen($param['var'])
326                    );
327
328                    $star        = $phpcsFile->findPrevious(T_DOC_COMMENT_STAR, $param['tag']);
329                    $spaceLength = (strlen($content) + $tokens[($commentToken - 1)]['length'] + $tokens[($commentToken - 2)]['length']
330                    );
331
332                    $padding  = str_repeat(' ', ($tokens[$star]['column'] - 1));
333                    $padding .= '* ';
334                    $padding .= str_repeat(' ', $spaceLength);
335
336                    $content .= wordwrap(
337                        $param['comment'],
338                        $wrapLength,
339                        $phpcsFile->eolChar.$padding
340                    );
341
342                    $phpcsFile->fixer->replaceToken($commentToken, $content);
343                    for ($i = ($commentToken + 1); $i <= $param['comment_end']; $i++) {
344                        $phpcsFile->fixer->replaceToken($i, '');
345                    }
346                }//end if
347            }//end if
348
349            // Make sure the param name is correct.
350            if (isset($realParams[$pos]) === true) {
351                $realName = $realParams[$pos]['name'];
352                if ($realName !== $param['var']) {
353                    $code = 'ParamNameNoMatch';
354                    $data = array(
355                             $param['var'],
356                             $realName,
357                            );
358
359                    $error = 'Doc comment for parameter %s does not match ';
360                    if (strtolower($param['var']) === strtolower($realName)) {
361                        $error .= 'case of ';
362                        $code   = 'ParamNameNoCaseMatch';
363                    }
364
365                    $error .= 'actual variable name %s';
366
367                    $phpcsFile->addError($error, $param['tag'], $code, $data);
368                }
369            } else if (substr($param['var'], -4) !== ',...') {
370                // We must have an extra parameter comment.
371                $error = 'Superfluous parameter comment';
372                $phpcsFile->addError($error, $param['tag'], 'ExtraParamComment');
373            }//end if
374
375            if ($param['comment'] === '') {
376                continue;
377            }
378
379            // Check number of spaces after the var name.
380            $spaces = ($maxVar - strlen($param['var']) + 1);
381            if ($param['var_space'] !== $spaces) {
382                $error = 'Expected %s spaces after parameter name; %s found';
383                $data  = array(
384                          $spaces,
385                          $param['var_space'],
386                         );
387
388                $fix = $phpcsFile->addFixableError($error, $param['tag'], 'SpacingAfterParamName', $data);
389                if ($fix === true) {
390                    $commentToken = ($param['tag'] + 2);
391
392                    $content  = $param['type'];
393                    $content .= str_repeat(' ', $param['type_space']);
394                    $content .= $param['var'];
395                    $content .= str_repeat(' ', $spaces);
396
397                    $wrapLength = ($tokens[$commentToken]['length'] - $param['type_space'] - $param['var_space'] - strlen($param['type']) - strlen($param['var'])
398                    );
399
400                    $star        = $phpcsFile->findPrevious(T_DOC_COMMENT_STAR, $param['tag']);
401                    $spaceLength = (strlen($content) + $tokens[($commentToken - 1)]['length'] + $tokens[($commentToken - 2)]['length']
402                    );
403
404                    $padding  = str_repeat(' ', ($tokens[$star]['column'] - 1));
405                    $padding .= '* ';
406                    $padding .= str_repeat(' ', $spaceLength);
407
408                    $content .= wordwrap(
409                        $param['comment'],
410                        $wrapLength,
411                        $phpcsFile->eolChar.$padding
412                    );
413
414                    $phpcsFile->fixer->replaceToken($commentToken, $content);
415                    for ($i = ($commentToken + 1); $i <= $param['comment_end']; $i++) {
416                        $phpcsFile->fixer->replaceToken($i, '');
417                    }
418                }//end if
419            }//end if
420        }//end foreach
421
422        $realNames = array();
423        foreach ($realParams as $realParam) {
424            $realNames[] = $realParam['name'];
425        }
426
427        // Report missing comments.
428        $diff = array_diff($realNames, $foundParams);
429        foreach ($diff as $neededParam) {
430            $error = 'Doc comment for parameter "%s" missing';
431            $data  = array($neededParam);
432            $phpcsFile->addError($error, $commentStart, 'MissingParamTag', $data);
433        }
434
435    }//end processParams()
436
437
438}//end class
439