<?php
/**
* Parses and verifies the doc comments for files.
*
* @author Greg Sherwood <gsherwood@squiz.net>
* @copyright 2006-2015 Squiz Pty Ltd (ABN 77 084 670 600)
* @license https://github.com/squizlabs/PHP_CodeSniffer/blob/master/licence.txt BSD Licence
*/
namespace PHP_CodeSniffer\Standards\PEAR\Sniffs\Commenting;
use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Sniffs\Sniff;
use PHP_CodeSniffer\Util\Common;
class FileCommentSniff implements Sniff
{
/**
* Tags in correct order and related info.
*
* @var array
*/
protected $tags = [
'@category' => [
'required' => true,
'allow_multiple' => false,
],
'@package' => [
'required' => true,
'allow_multiple' => false,
],
'@subpackage' => [
'required' => false,
'allow_multiple' => false,
],
'@author' => [
'required' => true,
'allow_multiple' => true,
],
'@copyright' => [
'required' => false,
'allow_multiple' => true,
],
'@license' => [
'required' => true,
'allow_multiple' => false,
],
'@version' => [
'required' => false,
'allow_multiple' => false,
],
'@link' => [
'required' => true,
'allow_multiple' => true,
],
'@see' => [
'required' => false,
'allow_multiple' => true,
],
'@since' => [
'required' => false,
'allow_multiple' => false,
],
'@deprecated' => [
'required' => false,
'allow_multiple' => false,
],
];
/**
* Returns an array of tokens this test wants to listen for.
*
* @return array
*/
public function register()
{
return [T_OPEN_TAG];
}//end register()
/**
* Processes this test, when one of its tokens is encountered.
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
* @param int $stackPtr The position of the current token
* in the stack passed in $tokens.
*
* @return int
*/
public function process(File $phpcsFile, $stackPtr)
{
$tokens = $phpcsFile->getTokens();
// Find the next non whitespace token.
$commentStart = $phpcsFile->findNext(T_WHITESPACE, ($stackPtr + 1), null, true);
// Allow declare() statements at the top of the file.
if ($tokens[$commentStart]['code'] === T_DECLARE) {
$semicolon = $phpcsFile->findNext(T_SEMICOLON, ($commentStart + 1));
$commentStart = $phpcsFile->findNext(T_WHITESPACE, ($semicolon + 1), null, true);
}
// Ignore vim header.
if ($tokens[$commentStart]['code'] === T_COMMENT) {
if (strstr($tokens[$commentStart]['content'], 'vim:') !== false) {
$commentStart = $phpcsFile->findNext(
T_WHITESPACE,
($commentStart + 1),
null,
true
);
}
}
$errorToken = ($stackPtr + 1);
if (isset($tokens[$errorToken]) === false) {
$errorToken--;
}
if ($tokens[$commentStart]['code'] === T_CLOSE_TAG) {
// We are only interested if this is the first open tag.
return ($phpcsFile->numTokens + 1);
} else if ($tokens[$commentStart]['code'] === T_COMMENT) {
$error = 'You must use "/**" style comments for a file comment';
$phpcsFile->addError($error, $errorToken, 'WrongStyle');
$phpcsFile->recordMetric($stackPtr, 'File has doc comment', 'yes');
return ($phpcsFile->numTokens + 1);
} else if ($commentStart === false
|| $tokens[$commentStart]['code'] !== T_DOC_COMMENT_OPEN_TAG
) {
$phpcsFile->addError('Missing file doc comment', $errorToken, 'Missing');
$phpcsFile->recordMetric($stackPtr, 'File has doc comment', 'no');
return ($phpcsFile->numTokens + 1);
}
$commentEnd = $tokens[$commentStart]['comment_closer'];
$nextToken = $phpcsFile->findNext(
T_WHITESPACE,
($commentEnd + 1),
null,
true
);
$ignore = [
T_CLASS,
T_INTERFACE,
T_TRAIT,
T_FUNCTION,
T_CLOSURE,
T_PUBLIC,
T_PRIVATE,
T_PROTECTED,
T_FINAL,
T_STATIC,
T_ABSTRACT,
T_CONST,
T_PROPERTY,
];
if (in_array($tokens[$nextToken]['code'], $ignore, true) === true) {
$phpcsFile->addError('Missing file doc comment', $stackPtr, 'Missing');
$phpcsFile->recordMetric($stackPtr, 'File has doc comment', 'no');
return ($phpcsFile->numTokens + 1);
}
$phpcsFile->recordMetric($stackPtr, 'File has doc comment', 'yes');
// Check the PHP Version, which should be in some text before the first tag.
$found = false;
for ($i = ($commentStart + 1); $i < $commentEnd; $i++) {
if ($tokens[$i]['code'] === T_DOC_COMMENT_TAG) {
break;
} else if ($tokens[$i]['code'] === T_DOC_COMMENT_STRING
&& strstr(strtolower($tokens[$i]['content']), 'php version') !== false
) {
$found = true;
break;
}
}
if ($found === false) {
$error = 'PHP version not specified';
$phpcsFile->addWarning($error, $commentEnd, 'MissingVersion');
}
// Check each tag.
$this->processTags($phpcsFile, $stackPtr, $commentStart);
// Ignore the rest of the file.
return ($phpcsFile->numTokens + 1);
}//end process()
/**
* Processes each required or optional tag.
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
* @param int $stackPtr The position of the current token
* in the stack passed in $tokens.
* @param int $commentStart Position in the stack where the comment started.
*
* @return void
*/
protected function processTags($phpcsFile, $stackPtr, $commentStart)
{
$tokens = $phpcsFile->getTokens();
if (get_class($this) === 'PHP_CodeSniffer\Standards\PEAR\Sniffs\Commenting\FileCommentSniff') {
$docBlock = 'file';
} else {
$docBlock = 'class';
}
$commentEnd = $tokens[$commentStart]['comment_closer'];
$foundTags = [];
$tagTokens = [];
foreach ($tokens[$commentStart]['comment_tags'] as $tag) {
$name = $tokens[$tag]['content'];
if (isset($this->tags[$name]) === false) {
continue;
}
if ($this->tags[$name]['allow_multiple'] === false && isset($tagTokens[$name]) === true) {
$error = 'Only one %s tag is allowed in a %s comment';
$data = [
$name,
$docBlock,
];
$phpcsFile->addError($error, $tag, 'Duplicate'.ucfirst(substr($name, 1)).'Tag', $data);
}
$foundTags[] = $name;
$tagTokens[$name][] = $tag;
$string = $phpcsFile->findNext(T_DOC_COMMENT_STRING, $tag, $commentEnd);
if ($string === false || $tokens[$string]['line'] !== $tokens[$tag]['line']) {
$error = 'Content missing for %s tag in %s comment';
$data = [
$name,
$docBlock,
];
$phpcsFile->addError($error, $tag, 'Empty'.ucfirst(substr($name, 1)).'Tag', $data);
continue;
}
}//end foreach
// Check if the tags are in the correct position.
$pos = 0;
foreach ($this->tags as $tag => $tagData) {
if (isset($tagTokens[$tag]) === false) {
if ($tagData['required'] === true) {
$error = 'Missing %s tag in %s comment';
$data = [
$tag,
$docBlock,
];
$phpcsFile->addError($error, $commentEnd, 'Missing'.ucfirst(substr($tag, 1)).'Tag', $data);
}
continue;
} else {
$method = 'process'.substr($tag, 1);
if (method_exists($this, $method) === true) {
// Process each tag if a method is defined.
call_user_func([$this, $method], $phpcsFile, $tagTokens[$tag]);
}
}
if (isset($foundTags[$pos]) === false) {
break;
}
if ($foundTags[$pos] !== $tag) {
$error = 'The tag in position %s should be the %s tag';
$data = [
($pos + 1),
$tag,
];
$phpcsFile->addError($error, $tokens[$commentStart]['comment_tags'][$pos], ucfirst(substr($tag, 1)).'TagOrder', $data);
}
// Account for multiple tags.
$pos++;
while (isset($foundTags[$pos]) === true && $foundTags[$pos] === $tag) {
$pos++;
}
}//end foreach
}//end processTags()
/**
* Process the category tag.
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
* @param array $tags The tokens for these tags.
*
* @return void
*/
protected function processCategory($phpcsFile, array $tags)
{
$tokens = $phpcsFile->getTokens();
foreach ($tags as $tag) {
if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
// No content.
continue;
}
$content = $tokens[($tag + 2)]['content'];
if (Common::isUnderscoreName($content) !== true) {
$newContent = str_replace(' ', '_', $content);
$nameBits = explode('_', $newContent);
$firstBit = array_shift($nameBits);
$newName = ucfirst($firstBit).'_';
foreach ($nameBits as $bit) {
if ($bit !== '') {
$newName .= ucfirst($bit).'_';
}
}
$error = 'Category name "%s" is not valid; consider "%s" instead';
$validName = trim($newName, '_');
$data = [
$content,
$validName,
];
$phpcsFile->addError($error, $tag, 'InvalidCategory', $data);
}
}//end foreach
}//end processCategory()
/**
* Process the package tag.
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
* @param array $tags The tokens for these tags.
*
* @return void
*/
protected function processPackage($phpcsFile, array $tags)
{
$tokens = $phpcsFile->getTokens();
foreach ($tags as $tag) {
if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
// No content.
continue;
}
$content = $tokens[($tag + 2)]['content'];
if (Common::isUnderscoreName($content) === true) {
continue;
}
$newContent = str_replace(' ', '_', $content);
$newContent = trim($newContent, '_');
$newContent = preg_replace('/[^A-Za-z_]/', '', $newContent);
if ($newContent === '') {
$error = 'Package name "%s" is not valid';
$data = [$content];
$phpcsFile->addError($error, $tag, 'InvalidPackageValue', $data);
} else {
$nameBits = explode('_', $newContent);
$firstBit = array_shift($nameBits);
$newName = strtoupper($firstBit[0]).substr($firstBit, 1).'_';
foreach ($nameBits as $bit) {
if ($bit !== '') {
$newName .= strtoupper($bit[0]).substr($bit, 1).'_';
}
}
$error = 'Package name "%s" is not valid; consider "%s" instead';
$validName = trim($newName, '_');
$data = [
$content,
$validName,
];
$phpcsFile->addError($error, $tag, 'InvalidPackage', $data);
}//end if
}//end foreach
}//end processPackage()
/**
* Process the subpackage tag.
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
* @param array $tags The tokens for these tags.
*
* @return void
*/
protected function processSubpackage($phpcsFile, array $tags)
{
$tokens = $phpcsFile->getTokens();
foreach ($tags as $tag) {
if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
// No content.
continue;
}
$content = $tokens[($tag + 2)]['content'];
if (Common::isUnderscoreName($content) === true) {
continue;
}
$newContent = str_replace(' ', '_', $content);
$nameBits = explode('_', $newContent);
$firstBit = array_shift($nameBits);
$newName = strtoupper($firstBit[0]).substr($firstBit, 1).'_';
foreach ($nameBits as $bit) {
if ($bit !== '') {
$newName .= strtoupper($bit[0]).substr($bit, 1).'_';
}
}
$error = 'Subpackage name "%s" is not valid; consider "%s" instead';
$validName = trim($newName, '_');
$data = [
$content,
$validName,
];
$phpcsFile->addError($error, $tag, 'InvalidSubpackage', $data);
}//end foreach
}//end processSubpackage()
/**
* Process the author tag(s) that this header comment has.
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
* @param array $tags The tokens for these tags.
*
* @return void
*/
protected function processAuthor($phpcsFile, array $tags)
{
$tokens = $phpcsFile->getTokens();
foreach ($tags as $tag) {
if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
// No content.
continue;
}
$content = $tokens[($tag + 2)]['content'];
$local = '\da-zA-Z-_+';
// Dot character cannot be the first or last character in the local-part.
$localMiddle = $local.'.\w';
if (preg_match('/^([^<]*)\s+<(['.$local.'](['.$localMiddle.']*['.$local.'])*@[\da-zA-Z][-.\w]*[\da-zA-Z]\.[a-zA-Z]{2,})>$/', $content) === 0) {
$error = 'Content of the @author tag must be in the form "Display Name <username@example.com>"';
$phpcsFile->addError($error, $tag, 'InvalidAuthors');
}
}
}//end processAuthor()
/**
* Process the copyright tags.
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
* @param array $tags The tokens for these tags.
*
* @return void
*/
protected function processCopyright($phpcsFile, array $tags)
{
$tokens = $phpcsFile->getTokens();
foreach ($tags as $tag) {
if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
// No content.
continue;
}
$content = $tokens[($tag + 2)]['content'];
$matches = [];
if (preg_match('/^([0-9]{4})((.{1})([0-9]{4}))? (.+)$/', $content, $matches) !== 0) {
// Check earliest-latest year order.
if ($matches[3] !== '' && $matches[3] !== null) {
if ($matches[3] !== '-') {
$error = 'A hyphen must be used between the earliest and latest year';
$phpcsFile->addError($error, $tag, 'CopyrightHyphen');
}
if ($matches[4] !== '' && $matches[4] !== null && $matches[4] < $matches[1]) {
$error = "Invalid year span \"$matches[1]$matches[3]$matches[4]\" found; consider \"$matches[4]-$matches[1]\" instead";
$phpcsFile->addWarning($error, $tag, 'InvalidCopyright');
}
}
} else {
$error = '@copyright tag must contain a year and the name of the copyright holder';
$phpcsFile->addError($error, $tag, 'IncompleteCopyright');
}
}//end foreach
}//end processCopyright()
/**
* Process the license tag.
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
* @param array $tags The tokens for these tags.
*
* @return void
*/
protected function processLicense($phpcsFile, array $tags)
{
$tokens = $phpcsFile->getTokens();
foreach ($tags as $tag) {
if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
// No content.
continue;
}
$content = $tokens[($tag + 2)]['content'];
$matches = [];
preg_match('/^([^\s]+)\s+(.*)/', $content, $matches);
if (count($matches) !== 3) {
$error = '@license tag must contain a URL and a license name';
$phpcsFile->addError($error, $tag, 'IncompleteLicense');
}
}
}//end processLicense()
/**
* Process the version tag.
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
* @param array $tags The tokens for these tags.
*
* @return void
*/
protected function processVersion($phpcsFile, array $tags)
{
$tokens = $phpcsFile->getTokens();
foreach ($tags as $tag) {
if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) {
// No content.
continue;
}
$content = $tokens[($tag + 2)]['content'];
if (strstr($content, 'CVS:') === false
&& strstr($content, 'SVN:') === false
&& strstr($content, 'GIT:') === false
&& strstr($content, 'HG:') === false
) {
$error = 'Invalid version "%s" in file comment; consider "CVS: <cvs_id>" or "SVN: <svn_id>" or "GIT: <git_id>" or "HG: <hg_id>" instead';
$data = [$content];
$phpcsFile->addWarning($error, $tag, 'InvalidVersion', $data);
}
}
}//end processVersion()
}//end class