Files
archived-phpruntests/cs/documentation/Sniffs/FunctionCommentSniff.php
2009-04-15 16:30:27 +00:00

611 lines
26 KiB
PHP

<?php
class documentation_Sniffs_FunctionCommentSniff extends Squiz_Sniffs_Commenting_FunctionCommentSniff
{
public function process(PHP_CodeSniffer_File $phpcsFile, $stackPtr)
{
$this->currentFile = $phpcsFile;
$tokens = $phpcsFile->getTokens();
$find = array(
T_COMMENT,
T_DOC_COMMENT,
T_CLASS,
T_FUNCTION,
T_OPEN_TAG,
);
$commentEnd = $phpcsFile->findPrevious($find, ($stackPtr - 1));
if ($commentEnd === false) {
return;
}
// If the token that we found was a class or a function, then this
// function has no doc comment.
$code = $tokens[$commentEnd]['code'];
if ($code === T_COMMENT) {
$error = 'You must use "/**" style comments for a function comment';
$phpcsFile->addError($error, $stackPtr);
return;
} else if ($code !== T_DOC_COMMENT) {
$error = 'Missing function doc comment';
$phpcsFile->addError($error, $stackPtr);
return;
}
// If there is any code between the function keyword and the doc block
// then the doc block is not for us.
$ignore = PHP_CodeSniffer_Tokens::$scopeModifiers;
$ignore[] = T_STATIC;
$ignore[] = T_WHITESPACE;
$ignore[] = T_ABSTRACT;
$ignore[] = T_FINAL;
$prevToken = $phpcsFile->findPrevious($ignore, ($stackPtr - 1), null, true);
if ($prevToken !== $commentEnd) {
$phpcsFile->addError('Missing function doc comment', $stackPtr);
return;
}
$this->_functionToken = $stackPtr;
foreach ($tokens[$stackPtr]['conditions'] as $condPtr => $condition) {
if ($condition === T_CLASS || $condition === T_INTERFACE) {
$this->_classToken = $condPtr;
break;
}
}
// Find the first doc comment.
$commentStart = ($phpcsFile->findPrevious(T_DOC_COMMENT, ($commentEnd - 1), null, true) + 1);
$comment = $phpcsFile->getTokensAsString($commentStart, ($commentEnd - $commentStart + 1));
$this->_methodName = $phpcsFile->getDeclarationName($stackPtr);
try {
$this->commentParser = new PHP_CodeSniffer_CommentParser_FunctionCommentParser($comment, $phpcsFile);
$this->commentParser->parse();
} catch (PHP_CodeSniffer_CommentParser_ParserException $e) {
$line = ($e->getLineWithinComment() + $commentStart);
$phpcsFile->addError($e->getMessage(), $line);
return;
}
$comment = $this->commentParser->getComment();
if (is_null($comment) === true) {
$error = 'Function doc comment is empty';
$phpcsFile->addError($error, $commentStart);
return;
}
$this->processParams($commentStart, $commentEnd);
// $this->processSince($commentStart, $commentEnd);
$this->processSees($commentStart);
$this->processReturn($commentStart, $commentEnd);
$this->processThrows($commentStart);
// Check for a comment description.
$short = $comment->getShortComment();
// No extra newline before short description.
$newlineCount = 0;
$newlineSpan = strspn($short, $phpcsFile->eolChar);
$newlineCount = (substr_count($short, $phpcsFile->eolChar) + 1);
// Exactly one blank line between short and long description.
$long = $comment->getLongComment();
if (empty($long) === false) {
$between = $comment->getWhiteSpaceBetween();
$newlineBetween = substr_count($between, $phpcsFile->eolChar);
$newlineCount += $newlineBetween;
$testLong = trim($long);
if (preg_match('|[A-Z]|', $testLong[0]) === 0) {
$error = 'Function comment long description must start with a capital letter';
$phpcsFile->addError($error, ($commentStart + $newlineCount));
}
}//end if
// Exactly one blank line before tags.
$params = $this->commentParser->getTagOrders();
if (count($params) > 1) {
$newlineSpan = $comment->getNewlineAfter();
if ($newlineSpan !== 2) {
$error = 'There must be exactly one blank line before the tags in function comment';
if ($long !== '') {
$newlineCount += (substr_count($long, $phpcsFile->eolChar) - $newlineSpan + 1);
}
$phpcsFile->addError($error, ($commentStart + $newlineCount));
$short = rtrim($short, $phpcsFile->eolChar.' ');
}
}
$testShort = trim($short);
$lastChar = $testShort[(strlen($testShort) - 1)];
// Check for unknown/deprecated tags.
$unknownTags = $this->commentParser->getUnknown();
foreach ($unknownTags as $errorTag) {
$error = "@$errorTag[tag] tag is not allowed in function comment";
$phpcsFile->addWarning($error, ($commentStart + $errorTag['line']));
}
}//end process()
/**
* Process the since tag.
*
* @param int $commentStart The position in the stack where the comment started.
* @param int $commentEnd The position in the stack where the comment ended.
*
* @return void
*/
protected function processSince($commentStart, $commentEnd)
{
$since = $this->commentParser->getSince();
if ($since !== null) {
$errorPos = ($commentStart + $since->getLine());
$tagOrder = $this->commentParser->getTagOrders();
$firstTag = 0;
while ($tagOrder[$firstTag] === 'comment' || $tagOrder[$firstTag] === 'param') {
$firstTag++;
}
$this->_tagIndex = $firstTag;
$index = array_keys($this->commentParser->getTagOrders(), 'since');
if (count($index) > 1) {
$error = 'Only 1 @since tag is allowed in function comment';
$this->currentFile->addError($error, $errorPos);
return;
}
if ($index[0] !== $firstTag) {
$error = 'The @since tag is in the wrong order; the tag preceds @see (if used) or @return';
$this->currentFile->addError($error, $errorPos);
}
$content = $since->getContent();
if (empty($content) === true) {
$error = 'Version number missing for @since tag in function comment';
$this->currentFile->addError($error, $errorPos);
return;
} else if ($content !== '%release_version%') {
if (preg_match('/^([0-9]+)\.([0-9]+)\.([0-9]+)/', $content) === 0) {
$error = 'Expected version number to be in the form x.x.x in @since tag';
$this->currentFile->addError($error, $errorPos);
}
}
$spacing = substr_count($since->getWhitespaceBeforeContent(), ' ');
$return = $this->commentParser->getReturn();
$throws = $this->commentParser->getThrows();
$correctSpacing = ($return !== null || empty($throws) === false) ? 2 : 1;
if ($spacing !== $correctSpacing) {
$error = '@since tag indented incorrectly; ';
$error .= "expected $correctSpacing spaces but found $spacing.";
$this->currentFile->addError($error, $errorPos);
}
} else {
$error = 'Missing @since tag in function comment';
$this->currentFile->addError($error, $commentEnd);
}//end if
}//end processSince()
/**
* Process the see tags.
*
* @param int $commentStart The position in the stack where the comment started.
*
* @return void
*/
protected function processSees($commentStart)
{
$sees = $this->commentParser->getSees();
if (empty($sees) === false) {
$tagOrder = $this->commentParser->getTagOrders();
$index = array_keys($this->commentParser->getTagOrders(), 'see');
foreach ($sees as $i => $see) {
$errorPos = ($commentStart + $see->getLine());
$since = array_keys($tagOrder, 'since');
if (count($since) === 1 && $this->_tagIndex !== 0) {
$this->_tagIndex++;
if ($index[$i] !== $this->_tagIndex) {
$error = 'The @see tag is in the wrong order; the tag follows @since';
$this->currentFile->addError($error, $errorPos);
}
}
$content = $see->getContent();
if (empty($content) === true) {
$error = 'Content missing for @see tag in function comment';
$this->currentFile->addError($error, $errorPos);
continue;
}
$spacing = substr_count($see->getWhitespaceBeforeContent(), ' ');
if ($spacing !== 4) {
$error = '@see tag indented incorrectly; ';
$error .= "expected 4 spaces but found $spacing";
$this->currentFile->addError($error, $errorPos);
}
}//end foreach
}//end if
}//end processSees()
/**
* Process the return comment of this function comment.
*
* @param int $commentStart The position in the stack where the comment started.
* @param int $commentEnd The position in the stack where the comment ended.
*
* @return void
*/
protected function processReturn($commentStart, $commentEnd)
{
// Skip constructor and destructor.
$className = '';
if ($this->_classToken !== null) {
$className = $this->currentFile->getDeclarationName($this->_classToken);
$className = strtolower(ltrim($className, '_'));
}
$methodName = strtolower(ltrim($this->_methodName, '_'));
$isSpecialMethod = ($this->_methodName === '__construct' || $this->_methodName === '__destruct');
$return = $this->commentParser->getReturn();
if ($isSpecialMethod === false && $methodName !== $className) {
if ($return !== null) {
$tagOrder = $this->commentParser->getTagOrders();
$index = array_keys($tagOrder, 'return');
$errorPos = ($commentStart + $return->getLine());
$content = trim($return->getRawContent());
if (count($index) > 1) {
$error = 'Only 1 @return tag is allowed in function comment';
$this->currentFile->addError($error, $errorPos);
return;
}
$since = array_keys($tagOrder, 'since');
if (count($since) === 1 && $this->_tagIndex !== 0) {
$this->_tagIndex++;
if ($index[0] !== $this->_tagIndex) {
$error = 'The @return tag is in the wrong order; the tag follows @see (if used) or @since';
$this->currentFile->addError($error, $errorPos);
}
}
if (empty($content) === true) {
$error = 'Return type missing for @return tag in function comment';
$this->currentFile->addError($error, $errorPos);
} else {
// Check return type (can be multiple, separated by '|').
$typeNames = explode('|', $content);
$suggestedNames = array();
foreach ($typeNames as $i => $typeName) {
$suggestedName = PHP_CodeSniffer::suggestType($typeName);
if (in_array($suggestedName, $suggestedNames) === false) {
$suggestedNames[] = $suggestedName;
}
}
$suggestedType = implode('|', $suggestedNames);
if ($content !== $suggestedType) {
$error = "Function return type \"$content\" is invalid";
$this->currentFile->addError($error, $errorPos);
}
$tokens = $this->currentFile->getTokens();
// If the return type is void, make sure there is
// no return statement in the function.
if ($content === 'null') {
if (isset($tokens[$this->_functionToken]['scope_closer']) === true) {
$endToken = $tokens[$this->_functionToken]['scope_closer'];
$return = $this->currentFile->findNext(T_RETURN, $this->_functionToken, $endToken);
if ($return !== false) {
// If the function is not returning anything, just
// exiting, then there is no problem.
$semicolon = $this->currentFile->findNext(T_WHITESPACE, ($return + 1), null, true);
if ($tokens[$semicolon]['code'] !== T_SEMICOLON) {
$error = 'Function return type is null, but function contains return statement';
$this->currentFile->addError($error, $errorPos);
}
}
}
} else if ($content !== 'mixed') {
// If return type is not void, there needs to be a
// returns statement somewhere in the function that
// returns something.
if (isset($tokens[$this->_functionToken]['scope_closer']) === true) {
$endToken = $tokens[$this->_functionToken]['scope_closer'];
$return = $this->currentFile->findNext(T_RETURN, $this->_functionToken, $endToken);
if ($return === false) {
$error = 'Function return type is not null, but function has no return statement';
$this->currentFile->addError($error, $errorPos);
} else {
$semicolon = $this->currentFile->findNext(T_WHITESPACE, ($return + 1), null, true);
if ($tokens[$semicolon]['code'] === T_SEMICOLON) {
$error = 'Function return type is not null, but function is returning void here';
$this->currentFile->addError($error, $return);
}
}
}
}//end if
}//end if
} else {
$error = 'Missing @return tag in function comment';
$this->currentFile->addError($error, $commentEnd);
}//end if
} else {
// No return tag for constructor and destructor.
if ($return !== null) {
$errorPos = ($commentStart + $return->getLine());
$error = '@return tag is not required for constructor and destructor';
$this->currentFile->addError($error, $errorPos);
}
}//end if
}//end processReturn()
/**
* Process any throw tags that this function comment has.
*
* @param int $commentStart The position in the stack where the comment started.
*
* @return void
*/
protected function processThrows($commentStart)
{
if (count($this->commentParser->getThrows()) === 0) {
return;
}
$tagOrder = $this->commentParser->getTagOrders();
$index = array_keys($this->commentParser->getTagOrders(), 'throws');
foreach ($this->commentParser->getThrows() as $i => $throw) {
$exception = $throw->getValue();
$content = trim($throw->getComment());
$errorPos = ($commentStart + $throw->getLine());
if (empty($exception) === true) {
$error = 'Exception type and comment missing for @throws tag in function comment';
$this->currentFile->addError($error, $errorPos);
} else if (empty($content) === true) {
$error = 'Comment missing for @throws tag in function comment';
$this->currentFile->addError($error, $errorPos);
} else {
// Starts with a capital letter and ends with a fullstop.
$firstChar = $content{0};
if (strtoupper($firstChar) !== $firstChar) {
$error = '@throws tag comment must start with a capital letter';
$this->currentFile->addError($error, $errorPos);
}
$lastChar = $content[(strlen($content) - 1)];
}
$since = array_keys($tagOrder, 'since');
if (count($since) === 1 && $this->_tagIndex !== 0) {
$this->_tagIndex++;
if ($index[$i] !== $this->_tagIndex) {
$error = 'The @throws tag is in the wrong order; the tag follows @return';
$this->currentFile->addError($error, $errorPos);
}
}
}//end foreach
}//end processThrows()
/**
* Process the function parameter comments.
*
* @param int $commentStart The position in the stack where
* the comment started.
* @param int $commentEnd The position in the stack where
* the comment ended.
*
* @return void
*/
protected function processParams($commentStart, $commentEnd)
{
$realParams = $this->currentFile->getMethodParameters($this->_functionToken);
$params = $this->commentParser->getParams();
$foundParams = array();
if (empty($params) === false) {
// Parameters must appear immediately after the comment.
if ($params[0]->getOrder() !== 2) {
$error = 'Parameters must appear immediately after the comment';
$errorPos = ($params[0]->getLine() + $commentStart);
$this->currentFile->addError($error, $errorPos);
}
$previousParam = null;
$spaceBeforeVar = 10000;
$spaceBeforeComment = 10000;
$longestType = 0;
$longestVar = 0;
foreach ($params as $param) {
$paramComment = trim($param->getComment());
$errorPos = ($param->getLine() + $commentStart);
// Make sure that there is only one space before the var type.
if ($param->getWhitespaceBeforeType() !== ' ') {
$error = 'Expected 1 space before variable type';
$this->currentFile->addError($error, $errorPos);
}
$spaceCount = substr_count($param->getWhitespaceBeforeVarName(), ' ');
if ($spaceCount < $spaceBeforeVar) {
$spaceBeforeVar = $spaceCount;
$longestType = $errorPos;
}
$spaceCount = substr_count($param->getWhitespaceBeforeComment(), ' ');
if ($spaceCount < $spaceBeforeComment && $paramComment !== '') {
$spaceBeforeComment = $spaceCount;
$longestVar = $errorPos;
}
// Make sure they are in the correct order, and have the correct name.
$pos = $param->getPosition();
$paramName = ($param->getVarName() !== '') ? $param->getVarName() : '[ UNKNOWN ]';
if ($previousParam !== null) {
$previousName = ($previousParam->getVarName() !== '') ? $previousParam->getVarName() : 'UNKNOWN';
// Check to see if the parameters align properly.
if ($param->alignsVariableWith($previousParam) === false) {
$error = 'The variable names for parameters '.$previousName.' ('.($pos - 1).') and '.$paramName.' ('.$pos.') do not align';
$this->currentFile->addError($error, $errorPos);
}
if ($param->alignsCommentWith($previousParam) === false) {
$error = 'The comments for parameters '.$previousName.' ('.($pos - 1).') and '.$paramName.' ('.$pos.') do not align';
$this->currentFile->addError($error, $errorPos);
}
}
// Variable must be one of the supported standard type.
$typeNames = explode('|', $param->getType());
foreach ($typeNames as $typeName) {
$suggestedName = PHP_CodeSniffer::suggestType($typeName);
if ($typeName !== $suggestedName) {
$error = "Expected \"$suggestedName\"; found \"$typeName\" for $paramName at position $pos";
$this->currentFile->addError($error, $errorPos);
} else if (count($typeNames) === 1) {
// Check type hint for array and custom type.
$suggestedTypeHint = '';
if (strpos($suggestedName, 'array') !== false) {
$suggestedTypeHint = 'array';
} else if (in_array($typeName, PHP_CodeSniffer::$allowedTypes) === false) {
$suggestedTypeHint = $suggestedName;
}
if ($suggestedTypeHint !== '' && isset($realParams[($pos - 1)]) === true) {
$typeHint = $realParams[($pos - 1)]['type_hint'];
if ($typeHint === '') {
$error = "Type hint \"$suggestedTypeHint\" missing for $paramName at position $pos";
$this->currentFile->addError($error, ($commentEnd + 2));
} else if ($typeHint !== $suggestedTypeHint) {
$error = "Expected type hint \"$suggestedTypeHint\"; found \"$typeHint\" for $paramName at position $pos";
$this->currentFile->addError($error, ($commentEnd + 2));
}
} else if ($suggestedTypeHint === '' && isset($realParams[($pos - 1)]) === true) {
$typeHint = $realParams[($pos - 1)]['type_hint'];
if ($typeHint !== '') {
$error = "Unknown type hint \"$typeHint\" found for $paramName at position $pos";
$this->currentFile->addError($error, ($commentEnd + 2));
}
}
}//end if
}//end foreach
// Make sure the names of the parameter comment matches the
// actual parameter.
if (isset($realParams[($pos - 1)]) === true) {
$realName = $realParams[($pos - 1)]['name'];
$foundParams[] = $realName;
// Append ampersand to name if passing by reference.
if ($realParams[($pos - 1)]['pass_by_reference'] === true) {
$realName = '&'.$realName;
}
if ($realName !== $param->getVarName()) {
$error = 'Doc comment var "'.$paramName;
$error .= '" does not match actual variable name "'.$realName;
$error .= '" at position '.$pos;
$this->currentFile->addError($error, $errorPos);
}
} else {
// We must have an extra parameter comment.
$error = 'Superfluous doc comment at position '.$pos;
$this->currentFile->addError($error, $errorPos);
}
if ($param->getVarName() === '') {
$error = 'Missing parameter name at position '.$pos;
$this->currentFile->addError($error, $errorPos);
}
if ($param->getType() === '') {
$error = 'Missing type at position '.$pos;
$this->currentFile->addError($error, $errorPos);
}
if ($paramComment === '') {
$error = 'Missing comment for param "'.$paramName.'" at position '.$pos;
$this->currentFile->addError($error, $errorPos);
} else {
// Param comments must start with a capital letter
$firstChar = $paramComment{0};
if (preg_match('|[A-Z]|', $firstChar) === 0) {
$error = 'Param comment must start with a capital letter';
$this->currentFile->addError($error, $errorPos);
}
$lastChar = $paramComment[(strlen($paramComment) - 1)];
}
$previousParam = $param;
}//end foreach
if ($spaceBeforeVar !== 1 && $spaceBeforeVar !== 10000 && $spaceBeforeComment !== 10000) {
$error = 'Expected 1 space after the longest type';
$this->currentFile->addError($error, $longestType);
}
if ($spaceBeforeComment !== 1 && $spaceBeforeComment !== 10000) {
$error = 'Expected 1 space after the longest variable name';
$this->currentFile->addError($error, $longestVar);
}
}//end if
$realNames = array();
foreach ($realParams as $realParam) {
$realNames[] = $realParam['name'];
}
// Report missing comments.
$diff = array_diff($realNames, $foundParams);
foreach ($diff as $neededParam) {
if (count($params) !== 0) {
$errorPos = ($params[(count($params) - 1)]->getLine() + $commentStart);
} else {
$errorPos = $commentStart;
}
$error = 'Doc comment for "'.$neededParam.'" missing';
$this->currentFile->addError($error, $errorPos);
}
}//end processParams()
}//end class
?>