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