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