src/Mimmi20CodingStandard/Sniffs/Commenting/FunctionCommentThrowTagSniff.php
<?php
/**
* This file is part of the coding-standard package.
*
* Copyright (c) 2020-2024, Thomas Mueller <mimmi20@live.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types = 1);
namespace Mimmi20CodingStandard\Sniffs\Commenting;
use PHP_CodeSniffer\Exceptions\RuntimeException;
use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Sniffs\Sniff;
use PHP_CodeSniffer\Util\Tokens;
use function array_keys;
use function array_unique;
use function count;
use function explode;
use function is_string;
use function mb_strlen;
use function mb_strpos;
use function mb_strrpos;
use function mb_substr;
use function trim;
use const T_ANON_CLASS;
use const T_ATTRIBUTE_END;
use const T_CATCH;
use const T_CLOSURE;
use const T_COMMENT;
use const T_DOC_COMMENT_CLOSE_TAG;
use const T_DOC_COMMENT_STRING;
use const T_FUNCTION;
use const T_NEW;
use const T_NS_SEPARATOR;
use const T_STRING;
use const T_THROW;
use const T_VARIABLE;
use const T_WHITESPACE;
/**
* Verifies that a @throws tag exists for each exception type a function throws.
*
* @phpcs:disable Generic.Metrics.CyclomaticComplexity.MaxExceeded
* @phpcs:disable SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh
* @phpcs:disable SlevomatCodingStandard.Functions.FunctionLength.FunctionLength
*/
final class FunctionCommentThrowTagSniff implements Sniff
{
/**
* Returns an array of tokens this test wants to listen for.
*
* @return array<int>
*
* @throws void
*/
public function register(): array
{
return [T_FUNCTION];
}
/**
* Processes this test, when one of its tokens is encountered.
*
* @param File $phpcsFile the file being scanned
* @param int $stackPtr the position of the current token
* in the stack passed in $tokens
*
* @throws RuntimeException
*
* @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint
*/
public function process(File $phpcsFile, $stackPtr): void
{
$tokens = $phpcsFile->getTokens();
$find = Tokens::$methodPrefixes;
$find[T_WHITESPACE] = T_WHITESPACE;
$commentEnd = null;
for ($commentEnd = $stackPtr - 1; 0 <= $commentEnd; --$commentEnd) {
if (isset($find[$tokens[$commentEnd]['code']]) === true) {
continue;
}
if (
$tokens[$commentEnd]['code'] === T_ATTRIBUTE_END
&& isset($tokens[$commentEnd]['attribute_opener']) === true
) {
$commentEnd = $tokens[$commentEnd]['attribute_opener'];
continue;
}
break;
}
if ($commentEnd === null) {
$phpcsFile->addError(
error: 'Missing doc comment',
stackPtr: $stackPtr,
code: 'MissingFunctionComment',
);
return;
}
if ($tokens[$commentEnd]['code'] === T_COMMENT) {
// Inline comments might just be closing comments for
// control structures or functions instead of function comments
// using the wrong comment type. If there is other code on the line,
// assume they relate to that code.
$prev = $phpcsFile->findPrevious(
types: $find,
start: (int) ($commentEnd - 1),
exclude: true,
);
if ($prev !== false && $tokens[$prev]['line'] === $tokens[$commentEnd]['line']) {
$commentEnd = $prev;
}
}
if (
$tokens[$commentEnd]['code'] !== T_DOC_COMMENT_CLOSE_TAG
&& $tokens[$commentEnd]['code'] !== T_COMMENT
) {
$function = $phpcsFile->getDeclarationName($stackPtr);
$phpcsFile->addError(
error: 'Missing doc comment for function %s()',
stackPtr: $stackPtr,
code: 'MissingFunctionComment',
data: [$function],
);
return;
}
if ($tokens[$commentEnd]['code'] === T_COMMENT) {
$phpcsFile->addError(
error: 'You must use "/**" style comments for a function comment',
stackPtr: $stackPtr,
code: 'WrongStyle',
);
return;
}
if ($tokens[$commentEnd]['line'] !== $tokens[$stackPtr]['line'] - 1) {
for ($i = $commentEnd + 1; $i < $stackPtr; ++$i) {
if ($tokens[$i]['column'] !== 1) {
continue;
}
if (
$tokens[$i]['code'] === T_WHITESPACE
&& $tokens[$i]['line'] !== $tokens[$i + 1]['line']
) {
$error = 'There must be no blank lines after the function comment';
$phpcsFile->addError(error: $error, stackPtr: $commentEnd, code: 'SpacingAfter');
break;
}
}
}
// Find all the exception type tokens within the current scope.
$thrownExceptions = [];
$currPos = $stackPtr;
$unknownCount = 0;
if (isset($tokens[$stackPtr]['scope_closer'])) {
$stackPtrEnd = $tokens[$stackPtr]['scope_closer'];
do {
$currPos = $phpcsFile->findNext(
types: [T_THROW, T_ANON_CLASS, T_CLOSURE],
start: (int) ($currPos + 1),
end: (int) ($stackPtrEnd),
);
if ($currPos === false) {
break;
}
if ($tokens[$currPos]['code'] !== T_THROW) {
$currPos = $tokens[$currPos]['scope_closer'];
continue;
}
/*
If we can't find a NEW, we are probably throwing
a variable or calling a method.
If we're throwing a variable, and it's the same variable as the
exception container from the nearest 'catch' block, we take that exception
as it is likely to be a re-throw.
If we can't find a matching catch block, or the variable name
is different, it's probably a different variable, so we ignore it,
but they still need to provide at least one @throws tag, even through we
don't know the exception class.
*/
$nextToken = $phpcsFile->findNext(
types: T_WHITESPACE,
start: (int) ($currPos + 1),
exclude: true,
);
if (
$tokens[$nextToken]['code'] === T_NEW
|| $tokens[$nextToken]['code'] === T_NS_SEPARATOR
|| $tokens[$nextToken]['code'] === T_STRING
) {
$currException = $tokens[$nextToken]['code'] === T_NEW ? $phpcsFile->findNext(
types: [
T_NS_SEPARATOR,
T_STRING,
],
start: (int) ($currPos),
end: (int) ($stackPtrEnd),
local: true,
) : $nextToken;
if ($currException !== false) {
$endException = $phpcsFile->findNext(
types: [
T_NS_SEPARATOR,
T_STRING,
],
start: (int) ($currException + 1),
end: (int) ($stackPtrEnd),
exclude: true,
local: true,
);
$thrownExceptions[] = $endException === false
? $tokens[$currException]['content']
: $phpcsFile->getTokensAsString(
$currException,
$endException - $currException,
);
}
} elseif ($tokens[$nextToken]['code'] === T_VARIABLE) {
// Find the nearest catch block in this scope and, if the caught var
// matches our re-thrown var, use the exception types being caught as
// exception types that are being thrown as well.
$catch = $phpcsFile->findPrevious(
types: T_CATCH,
start: (int) ($currPos),
end: (int) ($tokens[$stackPtr]['scope_opener']),
);
if ($catch !== false) {
$thrownVar = $phpcsFile->findPrevious(
types: T_VARIABLE,
start: (int) ($tokens[$catch]['parenthesis_closer'] - 1),
end: (int) ($tokens[$catch]['parenthesis_opener']),
);
if ($tokens[$thrownVar]['content'] === $tokens[$nextToken]['content']) {
$exceptions = explode(
'|',
(string) $phpcsFile->getTokensAsString(
start: (int) ($tokens[$catch]['parenthesis_opener'] + 1),
length: (int) ($thrownVar - $tokens[$catch]['parenthesis_opener'] - 1),
),
);
foreach ($exceptions as $exception) {
$thrownExceptions[] = trim($exception);
}
}
}
} else {
++$unknownCount;
}
} while ($currPos < $stackPtrEnd && $currPos !== false);
}
// Only need one @throws tag for each type of exception thrown.
$thrownExceptions = array_unique($thrownExceptions);
$throwTags = [];
$commentStart = $tokens[$commentEnd]['comment_opener'];
foreach ($tokens[$commentStart]['comment_tags'] as $tag) {
if ($tokens[$tag]['content'] !== '@throws') {
continue;
}
if ($tokens[$tag + 2]['code'] !== T_DOC_COMMENT_STRING) {
continue;
}
$exception = $tokens[$tag + 2]['content'];
$space = mb_strpos((string) $exception, ' ');
if ($space !== false) {
$exception = mb_substr((string) $exception, 0, $space);
}
$throwTags[$exception] = true;
}
if (empty($throwTags) === true) {
$error = 'Missing @throws tag in function comment';
$phpcsFile->addError(error: $error, stackPtr: $commentEnd, code: 'MissingAtThrow');
return;
}
if (empty($thrownExceptions) === true) {
// If token count is zero, it means that only variables are being
// thrown, so we need at least one @throws tag (checked above).
// Nothing more to do.
return;
}
// Make sure @throws tag count matches thrown count.
$thrownCount = count($thrownExceptions) + $unknownCount;
$tagCount = count($throwTags);
foreach ($thrownExceptions as $throw) {
if (isset($throwTags[$throw]) === true) {
continue;
}
foreach (array_keys($throwTags) as $tag) {
if (!is_string($tag)) {
continue;
}
if (
mb_strrpos($tag, (string) $throw) === mb_strlen($tag) - mb_strlen((string) $throw)
) {
continue 2;
}
}
$error = 'Missing @throws tag for "%s" exception';
$data = [$throw];
$phpcsFile->addError(
error: $error,
stackPtr: $commentEnd,
code: 'MissingThrowForException',
data: $data,
);
}
if ($tagCount >= $thrownCount) {
return;
}
$error = 'Expected %s @throws tag(s) in function comment; %s found';
$data = [
$thrownCount,
$tagCount,
];
$phpcsFile->addError(error: $error, stackPtr: $commentEnd, code: 'WrongNumber', data: $data);
}
}