Chris@0: 'array', Chris@0: 'array()' => 'array', Chris@0: '[]' => 'array', Chris@0: 'boolean' => 'bool', Chris@0: 'Boolean' => 'bool', Chris@0: 'integer' => 'int', Chris@0: 'str' => 'string', Chris@0: 'stdClass' => 'object', Chris@0: 'number' => 'int', Chris@0: 'String' => 'string', Chris@0: 'type' => 'mixed', Chris@0: 'NULL' => 'null', Chris@0: 'FALSE' => 'false', Chris@0: 'TRUE' => 'true', Chris@0: 'Bool' => 'bool', Chris@0: 'Int' => 'int', Chris@0: 'Integer' => 'int', Chris@0: ); Chris@0: Chris@0: /** Chris@0: * An array of variable types for param/var we will check. Chris@0: * Chris@0: * @var array(string) Chris@0: */ Chris@0: public $allowedTypes = array( Chris@0: 'array', Chris@0: 'mixed', Chris@0: 'object', Chris@0: 'resource', Chris@0: 'callable', Chris@0: ); Chris@0: Chris@0: Chris@0: /** Chris@0: * Returns an array of tokens this test wants to listen for. Chris@0: * Chris@0: * @return array Chris@0: */ Chris@0: public function register() Chris@0: { Chris@0: return array(T_FUNCTION); Chris@0: Chris@0: }//end register() Chris@0: Chris@0: Chris@0: /** Chris@0: * Processes this test, when one of its tokens is encountered. Chris@0: * Chris@17: * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned. Chris@17: * @param int $stackPtr The position of the current token Chris@17: * in the stack passed in $tokens. Chris@0: * Chris@0: * @return void Chris@0: */ Chris@17: public function process(File $phpcsFile, $stackPtr) Chris@0: { Chris@0: $tokens = $phpcsFile->getTokens(); Chris@17: $find = Tokens::$methodPrefixes; Chris@0: $find[] = T_WHITESPACE; Chris@0: Chris@0: $commentEnd = $phpcsFile->findPrevious($find, ($stackPtr - 1), null, true); Chris@17: $beforeCommentEnd = $phpcsFile->findPrevious(Tokens::$emptyTokens, ($commentEnd - 1), null, true); Chris@0: if (($tokens[$commentEnd]['code'] !== T_DOC_COMMENT_CLOSE_TAG Chris@0: && $tokens[$commentEnd]['code'] !== T_COMMENT) Chris@0: || ($beforeCommentEnd !== false Chris@0: // If there is something more on the line than just the comment then the Chris@0: // comment does not belong to the function. Chris@0: && $tokens[$beforeCommentEnd]['line'] === $tokens[$commentEnd]['line']) Chris@0: ) { Chris@0: $fix = $phpcsFile->addFixableError('Missing function doc comment', $stackPtr, 'Missing'); Chris@0: if ($fix === true) { Chris@0: $before = $phpcsFile->findNext(T_WHITESPACE, ($commentEnd + 1), ($stackPtr + 1), true); Chris@0: $phpcsFile->fixer->addContentBefore($before, "/**\n *\n */\n"); Chris@0: } Chris@0: Chris@0: return; Chris@0: } Chris@0: Chris@0: if ($tokens[$commentEnd]['code'] === T_COMMENT) { Chris@0: $fix = $phpcsFile->addFixableError('You must use "/**" style comments for a function comment', $stackPtr, 'WrongStyle'); Chris@0: if ($fix === true) { Chris@0: // Convert the comment into a doc comment. Chris@0: $phpcsFile->fixer->beginChangeset(); Chris@0: $comment = ''; Chris@0: for ($i = $commentEnd; $tokens[$i]['code'] === T_COMMENT; $i--) { Chris@0: $comment = ' *'.ltrim($tokens[$i]['content'], '/* ').$comment; Chris@0: $phpcsFile->fixer->replaceToken($i, ''); Chris@0: } Chris@0: Chris@0: $phpcsFile->fixer->replaceToken($commentEnd, "/**\n".rtrim($comment, "*/\n")."\n */\n"); Chris@0: $phpcsFile->fixer->endChangeset(); Chris@0: } Chris@0: Chris@0: return; Chris@0: } Chris@0: Chris@0: $commentStart = $tokens[$commentEnd]['comment_opener']; Chris@0: foreach ($tokens[$commentStart]['comment_tags'] as $tag) { Chris@0: // This is a file comment, not a function comment. Chris@0: if ($tokens[$tag]['content'] === '@file') { Chris@0: $fix = $phpcsFile->addFixableError('Missing function doc comment', $stackPtr, 'Missing'); Chris@0: if ($fix === true) { Chris@0: $before = $phpcsFile->findNext(T_WHITESPACE, ($commentEnd + 1), ($stackPtr + 1), true); Chris@0: $phpcsFile->fixer->addContentBefore($before, "/**\n *\n */\n"); Chris@0: } Chris@0: Chris@0: return; Chris@0: } Chris@0: Chris@0: if ($tokens[$tag]['content'] === '@see') { Chris@0: // Make sure the tag isn't empty. Chris@0: $string = $phpcsFile->findNext(T_DOC_COMMENT_STRING, $tag, $commentEnd); Chris@0: if ($string === false || $tokens[$string]['line'] !== $tokens[$tag]['line']) { Chris@0: $error = 'Content missing for @see tag in function comment'; Chris@0: $phpcsFile->addError($error, $tag, 'EmptySees'); Chris@0: } Chris@0: } Chris@0: }//end foreach Chris@0: Chris@0: if ($tokens[$commentEnd]['line'] !== ($tokens[$stackPtr]['line'] - 1)) { Chris@0: $error = 'There must be no blank lines after the function comment'; Chris@0: $fix = $phpcsFile->addFixableError($error, $commentEnd, 'SpacingAfter'); Chris@0: if ($fix === true) { Chris@0: $phpcsFile->fixer->replaceToken(($commentEnd + 1), ''); Chris@0: } Chris@0: } Chris@0: Chris@0: $this->processReturn($phpcsFile, $stackPtr, $commentStart); Chris@0: $this->processThrows($phpcsFile, $stackPtr, $commentStart); Chris@0: $this->processParams($phpcsFile, $stackPtr, $commentStart); Chris@0: $this->processSees($phpcsFile, $stackPtr, $commentStart); Chris@0: Chris@0: }//end process() Chris@0: Chris@0: Chris@0: /** Chris@0: * Process the return comment of this function comment. Chris@0: * Chris@17: * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned. Chris@17: * @param int $stackPtr The position of the current token Chris@17: * in the stack passed in $tokens. Chris@17: * @param int $commentStart The position in the stack where the comment started. Chris@0: * Chris@0: * @return void Chris@0: */ Chris@17: protected function processReturn(File $phpcsFile, $stackPtr, $commentStart) Chris@0: { Chris@0: $tokens = $phpcsFile->getTokens(); Chris@0: Chris@0: // Skip constructor and destructor. Chris@0: $className = ''; Chris@0: foreach ($tokens[$stackPtr]['conditions'] as $condPtr => $condition) { Chris@0: if ($condition === T_CLASS || $condition === T_INTERFACE) { Chris@0: $className = $phpcsFile->getDeclarationName($condPtr); Chris@0: $className = strtolower(ltrim($className, '_')); Chris@0: } Chris@0: } Chris@0: Chris@0: $methodName = $phpcsFile->getDeclarationName($stackPtr); Chris@0: $isSpecialMethod = ($methodName === '__construct' || $methodName === '__destruct'); Chris@0: $methodName = strtolower(ltrim($methodName, '_')); Chris@0: Chris@0: $return = null; Chris@0: foreach ($tokens[$commentStart]['comment_tags'] as $pos => $tag) { Chris@0: if ($tokens[$tag]['content'] === '@return') { Chris@0: if ($return !== null) { Chris@0: $error = 'Only 1 @return tag is allowed in a function comment'; Chris@0: $phpcsFile->addError($error, $tag, 'DuplicateReturn'); Chris@0: return; Chris@0: } Chris@0: Chris@0: $return = $tag; Chris@0: // Any strings until the next tag belong to this comment. Chris@0: if (isset($tokens[$commentStart]['comment_tags'][($pos + 1)]) === true) { Chris@0: $end = $tokens[$commentStart]['comment_tags'][($pos + 1)]; Chris@0: } else { Chris@0: $end = $tokens[$commentStart]['comment_closer']; Chris@0: } Chris@0: } Chris@0: } Chris@0: Chris@0: $type = null; Chris@0: if ($isSpecialMethod === false && $methodName !== $className) { Chris@0: if ($return !== null) { Chris@0: $type = trim($tokens[($return + 2)]['content']); Chris@0: if (empty($type) === true || $tokens[($return + 2)]['code'] !== T_DOC_COMMENT_STRING) { Chris@0: $error = 'Return type missing for @return tag in function comment'; Chris@0: $phpcsFile->addError($error, $return, 'MissingReturnType'); Chris@0: } else if (strpos($type, ' ') === false) { Chris@0: // Check return type (can be multiple, separated by '|'). Chris@0: $typeNames = explode('|', $type); Chris@0: $suggestedNames = array(); Chris@0: $hasNull = false; Chris@0: $hasMultiple = false; Chris@0: if (count($typeNames) > 0) { Chris@0: $hasMultiple = true; Chris@0: } Chris@0: Chris@0: foreach ($typeNames as $i => $typeName) { Chris@0: if (strtolower($typeName) === 'null') { Chris@0: $hasNull = true; Chris@0: } Chris@0: Chris@0: $suggestedName = static::suggestType($typeName); Chris@0: if (in_array($suggestedName, $suggestedNames) === false) { Chris@0: $suggestedNames[] = $suggestedName; Chris@0: } Chris@0: } Chris@0: Chris@0: $suggestedType = implode('|', $suggestedNames); Chris@0: if ($type !== $suggestedType) { Chris@0: $error = 'Expected "%s" but found "%s" for function return type'; Chris@0: $data = array( Chris@0: $suggestedType, Chris@0: $type, Chris@0: ); Chris@0: $fix = $phpcsFile->addFixableError($error, $return, 'InvalidReturn', $data); Chris@0: if ($fix === true) { Chris@0: $content = $suggestedType; Chris@0: $phpcsFile->fixer->replaceToken(($return + 2), $content); Chris@0: } Chris@0: }//end if Chris@0: Chris@0: if ($type === 'void') { Chris@0: $error = 'If there is no return value for a function, there must not be a @return tag.'; Chris@0: $phpcsFile->addError($error, $return, 'VoidReturn'); Chris@0: } else if ($type !== 'mixed') { Chris@0: // If return type is not void, there needs to be a return statement Chris@0: // somewhere in the function that returns something. Chris@0: if (isset($tokens[$stackPtr]['scope_closer']) === true) { Chris@0: $endToken = $tokens[$stackPtr]['scope_closer']; Chris@0: $foundReturnToken = false; Chris@0: $searchStart = $stackPtr; Chris@0: $foundNonVoidReturn = false; Chris@0: do { Chris@18: $returnToken = $phpcsFile->findNext(array(T_RETURN, T_YIELD), $searchStart, $endToken); Chris@0: if ($returnToken === false && $foundReturnToken === false) { Chris@0: $error = '@return doc comment specified, but function has no return statement'; Chris@0: $phpcsFile->addError($error, $return, 'InvalidNoReturn'); Chris@0: } else { Chris@0: // Check for return token as the last loop after the last return Chris@0: // in the function will enter this else condition Chris@0: // but without the returnToken. Chris@0: if ($returnToken !== false) { Chris@0: $foundReturnToken = true; Chris@0: $semicolon = $phpcsFile->findNext(T_WHITESPACE, ($returnToken + 1), null, true); Chris@0: if ($tokens[$semicolon]['code'] === T_SEMICOLON) { Chris@0: // Void return is allowed if the @return type has null in it. Chris@0: if ($hasNull === false) { Chris@0: $error = 'Function return type is not void, but function is returning void here'; Chris@0: $phpcsFile->addError($error, $returnToken, 'InvalidReturnNotVoid'); Chris@0: } Chris@0: } else { Chris@0: $foundNonVoidReturn = true; Chris@0: }//end if Chris@0: Chris@0: $searchStart = ($returnToken + 1); Chris@0: }//end if Chris@0: }//end if Chris@0: } while ($returnToken !== false); Chris@0: Chris@0: if ($foundNonVoidReturn === false && $foundReturnToken === true) { Chris@0: $error = 'Function return type is not void, but function does not have a non-void return statement'; Chris@0: $phpcsFile->addError($error, $return, 'InvalidReturnNotVoid'); Chris@0: } Chris@0: }//end if Chris@0: }//end if Chris@0: }//end if Chris@0: Chris@0: $comment = ''; Chris@0: for ($i = ($return + 3); $i < $end; $i++) { Chris@0: if ($tokens[$i]['code'] === T_DOC_COMMENT_STRING) { Chris@0: $indent = 0; Chris@0: if ($tokens[($i - 1)]['code'] === T_DOC_COMMENT_WHITESPACE) { Chris@0: $indent = strlen($tokens[($i - 1)]['content']); Chris@0: } Chris@0: Chris@0: $comment .= ' '.$tokens[$i]['content']; Chris@0: $commentLines[] = array( Chris@0: 'comment' => $tokens[$i]['content'], Chris@0: 'token' => $i, Chris@0: 'indent' => $indent, Chris@0: ); Chris@0: if ($indent < 3) { Chris@0: $error = 'Return comment indentation must be 3 spaces, found %s spaces'; Chris@0: $fix = $phpcsFile->addFixableError($error, $i, 'ReturnCommentIndentation', array($indent)); Chris@0: if ($fix === true) { Chris@0: $phpcsFile->fixer->replaceToken(($i - 1), ' '); Chris@0: } Chris@0: } Chris@0: } Chris@0: }//end for Chris@0: Chris@0: // The first line of the comment must be indented no more than 3 Chris@0: // spaces, the following lines can be more so we only check the first Chris@0: // line. Chris@0: if (empty($commentLines[0]['indent']) === false && $commentLines[0]['indent'] > 3) { Chris@0: $error = 'Return comment indentation must be 3 spaces, found %s spaces'; Chris@0: $fix = $phpcsFile->addFixableError($error, ($commentLines[0]['token'] - 1), 'ReturnCommentIndentation', array($commentLines[0]['indent'])); Chris@0: if ($fix === true) { Chris@0: $phpcsFile->fixer->replaceToken(($commentLines[0]['token'] - 1), ' '); Chris@0: } Chris@0: } Chris@0: Chris@0: if ($comment === '' && $type !== '$this' && $type !== 'static') { Chris@0: if (strpos($type, ' ') !== false) { Chris@0: $error = 'Description for the @return value must be on the next line'; Chris@0: } else { Chris@0: $error = 'Description for the @return value is missing'; Chris@0: } Chris@0: Chris@0: $phpcsFile->addError($error, $return, 'MissingReturnComment'); Chris@0: } else if (strpos($type, ' ') !== false) { Chris@0: if (preg_match('/^([^\s]+)[\s]+(\$[^\s]+)[\s]*$/', $type, $matches) === 1) { Chris@0: $error = 'Return type must not contain variable name "%s"'; Chris@0: $data = array($matches[2]); Chris@0: $fix = $phpcsFile->addFixableError($error, ($return + 2), 'ReturnVarName', $data); Chris@0: if ($fix === true) { Chris@0: $phpcsFile->fixer->replaceToken(($return + 2), $matches[1]); Chris@0: } Chris@0: } else { Chris@0: $error = 'Return type "%s" must not contain spaces'; Chris@0: $data = array($type); Chris@0: $phpcsFile->addError($error, $return, 'ReturnTypeSpaces', $data); Chris@0: } Chris@0: }//end if Chris@0: }//end if Chris@0: } else { Chris@0: // No return tag for constructor and destructor. Chris@0: if ($return !== null) { Chris@0: $error = '@return tag is not required for constructor and destructor'; Chris@0: $phpcsFile->addError($error, $return, 'ReturnNotRequired'); Chris@0: } Chris@0: }//end if Chris@0: Chris@0: }//end processReturn() Chris@0: Chris@0: Chris@0: /** Chris@0: * Process any throw tags that this function comment has. Chris@0: * Chris@17: * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned. Chris@17: * @param int $stackPtr The position of the current token Chris@17: * in the stack passed in $tokens. Chris@17: * @param int $commentStart The position in the stack where the comment started. Chris@0: * Chris@0: * @return void Chris@0: */ Chris@17: protected function processThrows(File $phpcsFile, $stackPtr, $commentStart) Chris@0: { Chris@0: $tokens = $phpcsFile->getTokens(); Chris@0: Chris@0: foreach ($tokens[$commentStart]['comment_tags'] as $pos => $tag) { Chris@0: if ($tokens[$tag]['content'] !== '@throws') { Chris@0: continue; Chris@0: } Chris@0: Chris@0: if ($tokens[($tag + 2)]['code'] !== T_DOC_COMMENT_STRING) { Chris@0: $error = 'Exception type missing for @throws tag in function comment'; Chris@0: $phpcsFile->addError($error, $tag, 'InvalidThrows'); Chris@0: } else { Chris@0: // Any strings until the next tag belong to this comment. Chris@0: if (isset($tokens[$commentStart]['comment_tags'][($pos + 1)]) === true) { Chris@0: $end = $tokens[$commentStart]['comment_tags'][($pos + 1)]; Chris@0: } else { Chris@0: $end = $tokens[$commentStart]['comment_closer']; Chris@0: } Chris@0: Chris@0: $comment = ''; Chris@0: $throwStart = null; Chris@0: for ($i = ($tag + 3); $i < $end; $i++) { Chris@0: if ($tokens[$i]['code'] === T_DOC_COMMENT_STRING) { Chris@0: if ($throwStart === null) { Chris@0: $throwStart = $i; Chris@0: } Chris@0: Chris@0: $indent = 0; Chris@0: if ($tokens[($i - 1)]['code'] === T_DOC_COMMENT_WHITESPACE) { Chris@0: $indent = strlen($tokens[($i - 1)]['content']); Chris@0: } Chris@0: Chris@0: $comment .= ' '.$tokens[$i]['content']; Chris@0: if ($indent < 3) { Chris@0: $error = 'Throws comment indentation must be 3 spaces, found %s spaces'; Chris@0: $phpcsFile->addError($error, $i, 'TrhowsCommentIndentation', array($indent)); Chris@0: } Chris@0: } Chris@0: } Chris@0: Chris@0: $comment = trim($comment); Chris@0: Chris@0: if ($comment === '') { Chris@0: if (str_word_count($tokens[($tag + 2)]['content'], 0, '\\_') > 1) { Chris@0: $error = '@throws comment must be on the next line'; Chris@0: $phpcsFile->addError($error, $tag, 'ThrowsComment'); Chris@0: } Chris@0: Chris@0: return; Chris@0: } Chris@0: Chris@0: // Starts with a capital letter and ends with a fullstop. Chris@0: $firstChar = $comment{0}; Chris@0: if (strtoupper($firstChar) !== $firstChar) { Chris@0: $error = '@throws tag comment must start with a capital letter'; Chris@0: $phpcsFile->addError($error, $throwStart, 'ThrowsNotCapital'); Chris@0: } Chris@0: Chris@0: $lastChar = substr($comment, -1); Chris@0: if (in_array($lastChar, array('.', '!', '?')) === false) { Chris@0: $error = '@throws tag comment must end with a full stop'; Chris@0: $phpcsFile->addError($error, $throwStart, 'ThrowsNoFullStop'); Chris@0: } Chris@0: }//end if Chris@0: }//end foreach Chris@0: Chris@0: }//end processThrows() Chris@0: Chris@0: Chris@0: /** Chris@0: * Process the function parameter comments. Chris@0: * Chris@17: * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned. Chris@17: * @param int $stackPtr The position of the current token Chris@17: * in the stack passed in $tokens. Chris@17: * @param int $commentStart The position in the stack where the comment started. Chris@0: * Chris@0: * @return void Chris@0: */ Chris@17: protected function processParams(File $phpcsFile, $stackPtr, $commentStart) Chris@0: { Chris@0: $tokens = $phpcsFile->getTokens(); Chris@0: Chris@0: $params = array(); Chris@0: $maxType = 0; Chris@0: $maxVar = 0; Chris@0: foreach ($tokens[$commentStart]['comment_tags'] as $pos => $tag) { Chris@0: if ($tokens[$tag]['content'] !== '@param') { Chris@0: continue; Chris@0: } Chris@0: Chris@0: $type = ''; Chris@0: $typeSpace = 0; Chris@0: $var = ''; Chris@0: $varSpace = 0; Chris@0: $comment = ''; Chris@0: $commentLines = array(); Chris@0: if ($tokens[($tag + 2)]['code'] === T_DOC_COMMENT_STRING) { Chris@0: $matches = array(); Chris@0: preg_match('/([^$&]*)(?:((?:\$|&)[^\s]+)(?:(\s+)(.*))?)?/', $tokens[($tag + 2)]['content'], $matches); Chris@0: Chris@0: $typeLen = strlen($matches[1]); Chris@0: $type = trim($matches[1]); Chris@0: $typeSpace = ($typeLen - strlen($type)); Chris@0: $typeLen = strlen($type); Chris@0: if ($typeLen > $maxType) { Chris@0: $maxType = $typeLen; Chris@0: } Chris@0: Chris@0: // If there is more than one word then it is a comment that should be Chris@0: // on the next line. Chris@0: if (isset($matches[4]) === true && ($typeLen > 0 Chris@0: || preg_match('/[^\s]+[\s]+[^\s]+/', $matches[4]) === 1) Chris@0: ) { Chris@0: $comment = $matches[4]; Chris@0: $error = 'Parameter comment must be on the next line'; Chris@0: $fix = $phpcsFile->addFixableError($error, ($tag + 2), 'ParamCommentNewLine'); Chris@0: if ($fix === true) { Chris@0: $parts = $matches; Chris@0: unset($parts[0]); Chris@0: $parts[3] = "\n * "; Chris@0: $phpcsFile->fixer->replaceToken(($tag + 2), implode('', $parts)); Chris@0: } Chris@0: } Chris@0: Chris@0: if (isset($matches[2]) === true) { Chris@0: $var = $matches[2]; Chris@0: } else { Chris@0: $var = ''; Chris@0: } Chris@0: Chris@0: if (substr($var, -1) === '.') { Chris@0: $error = 'Doc comment parameter name "%s" must not end with a dot'; Chris@0: $fix = $phpcsFile->addFixableError($error, ($tag + 2), 'ParamNameDot', [$var]); Chris@0: if ($fix === true) { Chris@0: $content = $type.' '.substr($var, 0, -1); Chris@0: $phpcsFile->fixer->replaceToken(($tag + 2), $content); Chris@0: } Chris@0: Chris@0: // Continue with the next parameter to avoid confusing Chris@0: // overlapping errors further down. Chris@0: continue; Chris@0: } Chris@0: Chris@0: $varLen = strlen($var); Chris@0: if ($varLen > $maxVar) { Chris@0: $maxVar = $varLen; Chris@0: } Chris@0: Chris@0: // Any strings until the next tag belong to this comment. Chris@0: if (isset($tokens[$commentStart]['comment_tags'][($pos + 1)]) === true) { Chris@0: $end = $tokens[$commentStart]['comment_tags'][($pos + 1)]; Chris@0: } else { Chris@0: $end = $tokens[$commentStart]['comment_closer']; Chris@0: } Chris@0: Chris@0: for ($i = ($tag + 3); $i < $end; $i++) { Chris@0: if ($tokens[$i]['code'] === T_DOC_COMMENT_STRING) { Chris@0: $indent = 0; Chris@0: if ($tokens[($i - 1)]['code'] === T_DOC_COMMENT_WHITESPACE) { Chris@0: $indent = strlen($tokens[($i - 1)]['content']); Chris@0: } Chris@0: Chris@0: $comment .= ' '.$tokens[$i]['content']; Chris@0: $commentLines[] = array( Chris@0: 'comment' => $tokens[$i]['content'], Chris@0: 'token' => $i, Chris@0: 'indent' => $indent, Chris@0: ); Chris@0: if ($indent < 3) { Chris@0: $error = 'Parameter comment indentation must be 3 spaces, found %s spaces'; Chris@0: $fix = $phpcsFile->addFixableError($error, $i, 'ParamCommentIndentation', array($indent)); Chris@0: if ($fix === true) { Chris@0: $phpcsFile->fixer->replaceToken(($i - 1), ' '); Chris@0: } Chris@0: } Chris@0: } Chris@0: }//end for Chris@0: Chris@0: // The first line of the comment must be indented no more than 3 Chris@0: // spaces, the following lines can be more so we only check the first Chris@0: // line. Chris@0: if (empty($commentLines[0]['indent']) === false && $commentLines[0]['indent'] > 3) { Chris@0: $error = 'Parameter comment indentation must be 3 spaces, found %s spaces'; Chris@0: $fix = $phpcsFile->addFixableError($error, ($commentLines[0]['token'] - 1), 'ParamCommentIndentation', array($commentLines[0]['indent'])); Chris@0: if ($fix === true) { Chris@0: $phpcsFile->fixer->replaceToken(($commentLines[0]['token'] - 1), ' '); Chris@0: } Chris@0: } Chris@0: Chris@0: if ($comment === '') { Chris@0: $error = 'Missing parameter comment'; Chris@0: $phpcsFile->addError($error, $tag, 'MissingParamComment'); Chris@0: $commentLines[] = array('comment' => ''); Chris@0: }//end if Chris@17: Chris@0: $variableArguments = false; Chris@0: // Allow the "..." @param doc for a variable number of parameters. Chris@0: // This could happen with type defined as @param array ... or Chris@0: // without type defined as @param ... Chris@0: if ($tokens[($tag + 2)]['content'] === '...' Chris@0: || (substr($tokens[($tag + 2)]['content'], -3) === '...' Chris@0: && count(explode(' ', $tokens[($tag + 2)]['content'])) === 2) Chris@0: ) { Chris@0: $variableArguments = true; Chris@0: } Chris@0: Chris@0: if ($typeLen === 0) { Chris@0: $error = 'Missing parameter type'; Chris@0: // If there is just one word as comment at the end of the line Chris@0: // then this is probably the data type. Move it before the Chris@0: // variable name. Chris@0: if (isset($matches[4]) === true && preg_match('/[^\s]+[\s]+[^\s]+/', $matches[4]) === 0) { Chris@0: $fix = $phpcsFile->addFixableError($error, $tag, 'MissingParamType'); Chris@0: if ($fix === true) { Chris@0: $phpcsFile->fixer->replaceToken(($tag + 2), $matches[4].' '.$var); Chris@0: } Chris@0: } else { Chris@0: $phpcsFile->addError($error, $tag, 'MissingParamType'); Chris@0: } Chris@0: } Chris@0: Chris@0: if (empty($matches[2]) === true && $variableArguments === false) { Chris@0: $error = 'Missing parameter name'; Chris@0: $phpcsFile->addError($error, $tag, 'MissingParamName'); Chris@0: } Chris@0: } else { Chris@0: $error = 'Missing parameter type'; Chris@0: $phpcsFile->addError($error, $tag, 'MissingParamType'); Chris@0: }//end if Chris@0: Chris@0: $params[] = array( Chris@0: 'tag' => $tag, Chris@0: 'type' => $type, Chris@0: 'var' => $var, Chris@0: 'comment' => $comment, Chris@0: 'commentLines' => $commentLines, Chris@0: 'type_space' => $typeSpace, Chris@0: 'var_space' => $varSpace, Chris@0: ); Chris@0: }//end foreach Chris@0: Chris@0: $realParams = $phpcsFile->getMethodParameters($stackPtr); Chris@0: $foundParams = array(); Chris@0: Chris@0: $checkPos = 0; Chris@0: foreach ($params as $pos => $param) { Chris@0: if ($param['var'] === '') { Chris@0: continue; Chris@0: } Chris@0: Chris@0: $foundParams[] = $param['var']; Chris@0: Chris@0: // If the type is empty, the whole line is empty. Chris@0: if ($param['type'] === '') { Chris@0: continue; Chris@0: } Chris@0: Chris@0: // Make sure the param name is correct. Chris@0: $matched = false; Chris@0: // Parameter documentation can be omitted for some parameters, so we have Chris@0: // to search the rest for a match. Chris@0: $realName = ''; Chris@0: while (isset($realParams[($checkPos)]) === true) { Chris@0: $realName = $realParams[$checkPos]['name']; Chris@0: Chris@0: if ($realName === $param['var'] || ($realParams[$checkPos]['pass_by_reference'] === true Chris@0: && ('&'.$realName) === $param['var']) Chris@0: ) { Chris@0: $matched = true; Chris@0: break; Chris@0: } Chris@0: Chris@0: $checkPos++; Chris@0: } Chris@0: Chris@0: // Check the param type value. This could also be multiple parameter Chris@0: // types separated by '|'. Chris@0: $typeNames = explode('|', $param['type']); Chris@0: $suggestedNames = array(); Chris@0: foreach ($typeNames as $i => $typeName) { Chris@0: $suggestedNames[] = static::suggestType($typeName); Chris@0: } Chris@0: Chris@0: $suggestedType = implode('|', $suggestedNames); Chris@0: if (preg_match('/\s/', $param['type']) === 1) { Chris@0: $error = 'Parameter type "%s" must not contain spaces'; Chris@0: $data = array($param['type']); Chris@0: $phpcsFile->addError($error, $param['tag'], 'ParamTypeSpaces', $data); Chris@0: } else if ($param['type'] !== $suggestedType) { Chris@0: $error = 'Expected "%s" but found "%s" for parameter type'; Chris@0: $data = array( Chris@0: $suggestedType, Chris@0: $param['type'], Chris@0: ); Chris@0: $fix = $phpcsFile->addFixableError($error, $param['tag'], 'IncorrectParamVarName', $data); Chris@0: if ($fix === true) { Chris@0: $content = $suggestedType; Chris@0: $content .= str_repeat(' ', $param['type_space']); Chris@0: $content .= $param['var']; Chris@0: $phpcsFile->fixer->replaceToken(($param['tag'] + 2), $content); Chris@0: } Chris@0: } Chris@0: Chris@0: if (count($typeNames) === 1) { Chris@0: $typeName = $param['type']; Chris@0: $suggestedName = static::suggestType($typeName); Chris@0: } Chris@0: Chris@0: // This runs only if there is only one type name and the type name Chris@0: // is not one of the disallowed type names. Chris@0: if (count($typeNames) === 1 && $typeName === $suggestedName) { Chris@0: // Check type hint for array and custom type. Chris@0: $suggestedTypeHint = ''; Chris@0: if (strpos($suggestedName, 'array') !== false) { Chris@0: $suggestedTypeHint = 'array'; Chris@0: } else if (strpos($suggestedName, 'callable') !== false) { Chris@0: $suggestedTypeHint = 'callable'; Chris@0: } else if (substr($suggestedName, -2) === '[]') { Chris@0: $suggestedTypeHint = 'array'; Chris@0: } else if ($suggestedName === 'object') { Chris@0: $suggestedTypeHint = ''; Chris@0: } else if (in_array($typeName, $this->allowedTypes) === false) { Chris@0: $suggestedTypeHint = $suggestedName; Chris@0: } Chris@0: Chris@0: if ($suggestedTypeHint !== '' && isset($realParams[$checkPos]) === true) { Chris@0: $typeHint = $realParams[$checkPos]['type_hint']; Chris@0: // Primitive type hints are allowed to be omitted. Chris@0: if ($typeHint === '' && in_array($suggestedTypeHint, ['string', 'int', 'float', 'bool']) === false) { Chris@0: $error = 'Type hint "%s" missing for %s'; Chris@0: $data = array( Chris@0: $suggestedTypeHint, Chris@0: $param['var'], Chris@0: ); Chris@0: $phpcsFile->addError($error, $stackPtr, 'TypeHintMissing', $data); Chris@0: } else if ($typeHint !== $suggestedTypeHint && $typeHint !== '') { Chris@0: // The type hint could be fully namespaced, so we check Chris@0: // for the part after the last "\". Chris@0: $name_parts = explode('\\', $suggestedTypeHint); Chris@0: $last_part = end($name_parts); Chris@0: if ($last_part !== $typeHint && $this->isAliasedType($typeHint, $suggestedTypeHint, $phpcsFile) === false) { Chris@0: $error = 'Expected type hint "%s"; found "%s" for %s'; Chris@0: $data = array( Chris@0: $last_part, Chris@0: $typeHint, Chris@0: $param['var'], Chris@0: ); Chris@0: $phpcsFile->addError($error, $stackPtr, 'IncorrectTypeHint', $data); Chris@0: } Chris@0: }//end if Chris@0: } else if ($suggestedTypeHint === '' Chris@0: && isset($realParams[$checkPos]) === true Chris@0: ) { Chris@0: $typeHint = $realParams[$checkPos]['type_hint']; Chris@0: if ($typeHint !== '' && $typeHint !== 'stdClass') { Chris@0: $error = 'Unknown type hint "%s" found for %s'; Chris@0: $data = array( Chris@0: $typeHint, Chris@0: $param['var'], Chris@0: ); Chris@0: $phpcsFile->addError($error, $stackPtr, 'InvalidTypeHint', $data); Chris@0: } Chris@0: }//end if Chris@0: }//end if Chris@0: Chris@0: // Check number of spaces after the type. Chris@0: $spaces = 1; Chris@0: if ($param['type_space'] !== $spaces) { Chris@0: $error = 'Expected %s spaces after parameter type; %s found'; Chris@0: $data = array( Chris@0: $spaces, Chris@0: $param['type_space'], Chris@0: ); Chris@0: Chris@0: $fix = $phpcsFile->addFixableError($error, $param['tag'], 'SpacingAfterParamType', $data); Chris@0: if ($fix === true) { Chris@0: $phpcsFile->fixer->beginChangeset(); Chris@0: Chris@0: $content = $param['type']; Chris@0: $content .= str_repeat(' ', $spaces); Chris@0: $content .= $param['var']; Chris@0: $content .= str_repeat(' ', $param['var_space']); Chris@0: // At this point there is no description expected in the Chris@0: // @param line so no need to append comment. Chris@0: $phpcsFile->fixer->replaceToken(($param['tag'] + 2), $content); Chris@0: Chris@0: // Fix up the indent of additional comment lines. Chris@0: foreach ($param['commentLines'] as $lineNum => $line) { Chris@0: if ($lineNum === 0 Chris@0: || $param['commentLines'][$lineNum]['indent'] === 0 Chris@0: ) { Chris@0: continue; Chris@0: } Chris@0: Chris@0: $newIndent = ($param['commentLines'][$lineNum]['indent'] + $spaces - $param['type_space']); Chris@0: $phpcsFile->fixer->replaceToken( Chris@0: ($param['commentLines'][$lineNum]['token'] - 1), Chris@0: str_repeat(' ', $newIndent) Chris@0: ); Chris@0: } Chris@0: Chris@0: $phpcsFile->fixer->endChangeset(); Chris@0: }//end if Chris@0: }//end if Chris@0: Chris@0: if ($matched === false) { Chris@0: if ($checkPos >= $pos) { Chris@0: $code = 'ParamNameNoMatch'; Chris@0: $data = array( Chris@0: $param['var'], Chris@0: $realName, Chris@0: ); Chris@0: Chris@0: $error = 'Doc comment for parameter %s does not match '; Chris@0: if (strtolower($param['var']) === strtolower($realName)) { Chris@0: $error .= 'case of '; Chris@0: $code = 'ParamNameNoCaseMatch'; Chris@0: } Chris@0: Chris@0: $error .= 'actual variable name %s'; Chris@0: Chris@0: $phpcsFile->addError($error, $param['tag'], $code, $data); Chris@0: // Reset the parameter position to check for following Chris@0: // parameters. Chris@0: $checkPos = ($pos - 1); Chris@0: } else if (substr($param['var'], -4) !== ',...') { Chris@0: // We must have an extra parameter comment. Chris@0: $error = 'Superfluous parameter comment'; Chris@0: $phpcsFile->addError($error, $param['tag'], 'ExtraParamComment'); Chris@0: }//end if Chris@0: }//end if Chris@0: Chris@0: $checkPos++; Chris@0: Chris@0: if ($param['comment'] === '') { Chris@0: continue; Chris@0: } Chris@0: Chris@0: // Param comments must start with a capital letter and end with the full stop. Chris@0: if (isset($param['commentLines'][0]['comment']) === true) { Chris@0: $firstChar = $param['commentLines'][0]['comment']; Chris@0: } else { Chris@0: $firstChar = $param['comment']; Chris@0: } Chris@0: Chris@0: if (preg_match('|\p{Lu}|u', $firstChar) === 0) { Chris@0: $error = 'Parameter comment must start with a capital letter'; Chris@0: if (isset($param['commentLines'][0]['token']) === true) { Chris@0: $commentToken = $param['commentLines'][0]['token']; Chris@0: } else { Chris@0: $commentToken = $param['tag']; Chris@0: } Chris@0: Chris@0: $phpcsFile->addError($error, $commentToken, 'ParamCommentNotCapital'); Chris@0: } Chris@0: Chris@0: $lastChar = substr($param['comment'], -1); Chris@0: if (in_array($lastChar, array('.', '!', '?', ')')) === false) { Chris@0: $error = 'Parameter comment must end with a full stop'; Chris@0: if (empty($param['commentLines']) === true) { Chris@0: $commentToken = ($param['tag'] + 2); Chris@0: } else { Chris@0: $lastLine = end($param['commentLines']); Chris@0: $commentToken = $lastLine['token']; Chris@0: } Chris@0: Chris@0: $fix = $phpcsFile->addFixableError($error, $commentToken, 'ParamCommentFullStop'); Chris@0: if ($fix === true) { Chris@0: // Add a full stop as the last character of the comment. Chris@0: $phpcsFile->fixer->addContent($commentToken, '.'); Chris@0: } Chris@0: } Chris@0: }//end foreach Chris@0: Chris@0: // Missing parameters only apply to methods and not function because on Chris@0: // functions it is allowed to leave out param comments for form constructors Chris@0: // for example. Chris@0: // It is also allowed to ommit pram tags completely, in which case we don't Chris@0: // throw errors. Only throw errors if param comments exists but are Chris@0: // incomplete on class methods. Chris@0: if ($tokens[$stackPtr]['level'] > 0 && empty($foundParams) === false) { Chris@0: foreach ($realParams as $realParam) { Chris@0: $realParamKeyName = $realParam['name']; Chris@0: if (in_array($realParamKeyName, $foundParams) === false Chris@0: && ($realParam['pass_by_reference'] === true Chris@0: && in_array("&$realParamKeyName", $foundParams) === true) === false Chris@0: ) { Chris@0: $error = 'Parameter %s is not described in comment'; Chris@0: $phpcsFile->addError($error, $commentStart, 'ParamMissingDefinition', [$realParam['name']]); Chris@0: } Chris@0: } Chris@0: } Chris@0: Chris@0: }//end processParams() Chris@0: Chris@0: Chris@0: /** Chris@0: * Process the function "see" comments. Chris@0: * Chris@17: * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned. Chris@17: * @param int $stackPtr The position of the current token Chris@17: * in the stack passed in $tokens. Chris@17: * @param int $commentStart The position in the stack where the comment started. Chris@0: * Chris@0: * @return void Chris@0: */ Chris@17: protected function processSees(File $phpcsFile, $stackPtr, $commentStart) Chris@0: { Chris@0: $tokens = $phpcsFile->getTokens(); Chris@0: foreach ($tokens[$commentStart]['comment_tags'] as $tag) { Chris@0: if ($tokens[$tag]['content'] !== '@see') { Chris@0: continue; Chris@0: } Chris@0: Chris@0: if ($tokens[($tag + 2)]['code'] === T_DOC_COMMENT_STRING) { Chris@0: $comment = $tokens[($tag + 2)]['content']; Chris@0: if (strpos($comment, ' ') !== false) { Chris@0: $error = 'The @see reference should not contain any additional text'; Chris@0: $phpcsFile->addError($error, $tag, 'SeeAdditionalText'); Chris@0: continue; Chris@0: } Chris@0: Chris@0: if (preg_match('/[\.!\?]$/', $comment) === 1) { Chris@0: $error = 'Trailing punctuation for @see references is not allowed.'; Chris@0: $fix = $phpcsFile->addFixableError($error, $tag, 'SeePunctuation'); Chris@0: if ($fix === true) { Chris@0: // Replace the last character from the comment which is Chris@0: // already tested to be a punctuation. Chris@0: $content = substr($comment, 0, -1); Chris@0: $phpcsFile->fixer->replaceToken(($tag + 2), $content); Chris@0: }//end if Chris@0: } Chris@0: } Chris@0: }//end foreach Chris@0: Chris@0: }//end processSees() Chris@0: Chris@0: Chris@0: /** Chris@0: * Returns a valid variable type for param/var tag. Chris@0: * Chris@0: * @param string $type The variable type to process. Chris@0: * Chris@0: * @return string Chris@0: */ Chris@0: public static function suggestType($type) Chris@0: { Chris@0: if (isset(static::$invalidTypes[$type]) === true) { Chris@0: return static::$invalidTypes[$type]; Chris@0: } Chris@0: Chris@0: if ($type === '$this') { Chris@0: return $type; Chris@0: } Chris@0: Chris@0: $type = preg_replace('/[^a-zA-Z0-9_\\\[\]]/', '', $type); Chris@0: Chris@0: return $type; Chris@0: Chris@0: }//end suggestType() Chris@0: Chris@0: Chris@0: /** Chris@0: * Checks if a used type hint is an alias defined by a "use" statement. Chris@0: * Chris@17: * @param string $typeHint The type hint used. Chris@17: * @param string $suggestedTypeHint The fully qualified type to Chris@17: * check against. Chris@17: * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being checked. Chris@0: * Chris@0: * @return boolean Chris@0: */ Chris@17: protected function isAliasedType($typeHint, $suggestedTypeHint, File $phpcsFile) Chris@0: { Chris@0: $tokens = $phpcsFile->getTokens(); Chris@0: Chris@0: // Iterate over all "use" statements in the file. Chris@0: $usePtr = 0; Chris@0: while ($usePtr !== false) { Chris@0: $usePtr = $phpcsFile->findNext(T_USE, ($usePtr + 1)); Chris@0: if ($usePtr === false) { Chris@0: return false; Chris@0: } Chris@0: Chris@0: // Only check use statements in the global scope. Chris@0: if (empty($tokens[$usePtr]['conditions']) === false) { Chris@0: continue; Chris@0: } Chris@0: Chris@0: // Now comes the original class name, possibly with namespace Chris@0: // backslashes. Chris@17: $originalClass = $phpcsFile->findNext(Tokens::$emptyTokens, ($usePtr + 1), null, true); Chris@0: if ($originalClass === false || ($tokens[$originalClass]['code'] !== T_STRING Chris@0: && $tokens[$originalClass]['code'] !== T_NS_SEPARATOR) Chris@0: ) { Chris@0: continue; Chris@0: } Chris@0: Chris@0: $originalClassName = ''; Chris@0: while (in_array($tokens[$originalClass]['code'], array(T_STRING, T_NS_SEPARATOR)) === true) { Chris@0: $originalClassName .= $tokens[$originalClass]['content']; Chris@0: $originalClass++; Chris@0: } Chris@0: Chris@0: if (ltrim($originalClassName, '\\') !== ltrim($suggestedTypeHint, '\\')) { Chris@0: continue; Chris@0: } Chris@0: Chris@0: // Now comes the "as" keyword signaling an alias name for the class. Chris@17: $asPtr = $phpcsFile->findNext(Tokens::$emptyTokens, ($originalClass + 1), null, true); Chris@0: if ($asPtr === false || $tokens[$asPtr]['code'] !== T_AS) { Chris@0: continue; Chris@0: } Chris@0: Chris@0: // Now comes the name the class is aliased to. Chris@17: $aliasPtr = $phpcsFile->findNext(Tokens::$emptyTokens, ($asPtr + 1), null, true); Chris@0: if ($aliasPtr === false || $tokens[$aliasPtr]['code'] !== T_STRING Chris@0: || $tokens[$aliasPtr]['content'] !== $typeHint Chris@0: ) { Chris@0: continue; Chris@0: } Chris@0: Chris@0: // We found a use statement that aliases the used type hint! Chris@0: return true; Chris@0: }//end while Chris@0: Chris@0: return false; Chris@0: Chris@0: }//end isAliasedType() Chris@0: Chris@0: Chris@0: }//end class