311 lines
7.6 KiB
PHP
311 lines
7.6 KiB
PHP
<?php declare(strict_types = 1);
|
|
|
|
namespace SlevomatCodingStandard\Sniffs\Complexity;
|
|
|
|
use PHP_CodeSniffer\Files\File;
|
|
use PHP_CodeSniffer\Sniffs\Sniff;
|
|
use PHP_CodeSniffer\Util\Tokens;
|
|
use SlevomatCodingStandard\Helpers\FunctionHelper;
|
|
use function array_filter;
|
|
use function array_pop;
|
|
use function array_splice;
|
|
use function count;
|
|
use function in_array;
|
|
use const T_BOOLEAN_AND;
|
|
use const T_BOOLEAN_OR;
|
|
use const T_BREAK;
|
|
use const T_CATCH;
|
|
use const T_CLOSE_PARENTHESIS;
|
|
use const T_CLOSURE;
|
|
use const T_CONTINUE;
|
|
use const T_DO;
|
|
use const T_ELSE;
|
|
use const T_ELSEIF;
|
|
use const T_FOR;
|
|
use const T_FOREACH;
|
|
use const T_FUNCTION;
|
|
use const T_GOTO;
|
|
use const T_IF;
|
|
use const T_INLINE_ELSE;
|
|
use const T_INLINE_THEN;
|
|
use const T_OPEN_PARENTHESIS;
|
|
use const T_SEMICOLON;
|
|
use const T_SWITCH;
|
|
use const T_WHILE;
|
|
|
|
/**
|
|
* Cognitive Complexity
|
|
*
|
|
* @link https://www.sonarsource.com/docs/CognitiveComplexity.pdf
|
|
*/
|
|
class CognitiveSniff implements Sniff
|
|
{
|
|
|
|
public const CODE_COMPLEXITY = 'ComplexityTooHigh';
|
|
|
|
/**
|
|
* B1. Increments
|
|
*
|
|
* Boolean operators are handled separately due to their chain logic.
|
|
*/
|
|
private const INCREMENTS = [
|
|
T_CATCH => T_CATCH,
|
|
T_DO => T_DO,
|
|
T_ELSE => T_ELSE,
|
|
T_ELSEIF => T_ELSEIF,
|
|
T_FOR => T_FOR,
|
|
T_FOREACH => T_FOREACH,
|
|
T_IF => T_IF,
|
|
T_SWITCH => T_SWITCH,
|
|
T_WHILE => T_WHILE,
|
|
];
|
|
|
|
private const BOOLEAN_OPERATORS = [
|
|
T_BOOLEAN_AND => T_BOOLEAN_AND,
|
|
T_BOOLEAN_OR => T_BOOLEAN_OR,
|
|
];
|
|
|
|
private const OPERATOR_CHAIN_BREAKS = [
|
|
T_OPEN_PARENTHESIS => T_OPEN_PARENTHESIS,
|
|
T_CLOSE_PARENTHESIS => T_CLOSE_PARENTHESIS,
|
|
T_SEMICOLON => T_SEMICOLON,
|
|
T_INLINE_THEN => T_INLINE_THEN,
|
|
T_INLINE_ELSE => T_INLINE_ELSE,
|
|
];
|
|
|
|
/**
|
|
* B3. Nesting increments
|
|
*/
|
|
private const NESTING_INCREMENTS = [
|
|
T_CLOSURE => T_CLOSURE,
|
|
// increments, but does not receive
|
|
T_ELSEIF => T_ELSEIF,
|
|
// increments, but does not receive
|
|
T_ELSE => T_ELSE,
|
|
T_IF => T_IF,
|
|
T_INLINE_THEN => T_INLINE_THEN,
|
|
T_SWITCH => T_SWITCH,
|
|
T_FOR => T_FOR,
|
|
T_FOREACH => T_FOREACH,
|
|
T_WHILE => T_WHILE,
|
|
T_DO => T_DO,
|
|
T_CATCH => T_CATCH,
|
|
];
|
|
|
|
/**
|
|
* B1. Increments
|
|
*/
|
|
private const BREAKING_TOKENS = [
|
|
T_CONTINUE => T_CONTINUE,
|
|
T_GOTO => T_GOTO,
|
|
T_BREAK => T_BREAK,
|
|
];
|
|
|
|
/**
|
|
* @deprecated
|
|
* @var ?int maximum allowed complexity
|
|
*/
|
|
public $maxComplexity = null;
|
|
|
|
/** @var int complexity which will raise warning */
|
|
public $warningThreshold = 6;
|
|
|
|
/** @var int complexity which will raise error */
|
|
public $errorThreshold = 6;
|
|
|
|
/** @var int */
|
|
private $cognitiveComplexity = 0;
|
|
|
|
/** @var int|string */
|
|
private $lastBooleanOperator = 0;
|
|
|
|
/** @var File */
|
|
private $phpcsFile;
|
|
|
|
/**
|
|
* @return array<int, (int|string)>
|
|
*/
|
|
public function register(): array
|
|
{
|
|
return [
|
|
T_CLOSURE,
|
|
T_FUNCTION,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint
|
|
* @param int $stackPtr
|
|
*/
|
|
public function process(File $phpcsFile, $stackPtr): void
|
|
{
|
|
$this->phpcsFile = $phpcsFile;
|
|
|
|
if ($phpcsFile->getCondition($stackPtr, T_FUNCTION) !== false) {
|
|
return;
|
|
}
|
|
|
|
if ($this->maxComplexity !== null) {
|
|
// maxComplexity is deprecated... if set use it
|
|
$this->warningThreshold = $this->maxComplexity + 1;
|
|
$this->errorThreshold = $this->maxComplexity + 1;
|
|
}
|
|
|
|
$cognitiveComplexity = $this->computeForFunctionFromTokensAndPosition($stackPtr);
|
|
|
|
if ($cognitiveComplexity < $this->warningThreshold) {
|
|
return;
|
|
}
|
|
|
|
$name = $phpcsFile->getDeclarationName($stackPtr);
|
|
|
|
$errorParameters = [
|
|
'Cognitive complexity for "%s" is %d but has to be less than or equal to %d.',
|
|
$stackPtr,
|
|
self::CODE_COMPLEXITY,
|
|
[
|
|
$name,
|
|
$cognitiveComplexity,
|
|
$this->warningThreshold - 1,
|
|
],
|
|
];
|
|
|
|
$cognitiveComplexity >= $this->errorThreshold
|
|
? $phpcsFile->addError(...$errorParameters)
|
|
: $phpcsFile->addWarning(...$errorParameters);
|
|
}
|
|
|
|
public function computeForFunctionFromTokensAndPosition(int $position): int
|
|
{
|
|
if (FunctionHelper::isAbstract($this->phpcsFile, $position)) {
|
|
return 0;
|
|
}
|
|
|
|
$tokens = $this->phpcsFile->getTokens();
|
|
|
|
// Detect start and end of this function definition
|
|
$functionStartPosition = $tokens[$position]['scope_opener'];
|
|
$functionEndPosition = $tokens[$position]['scope_closer'];
|
|
|
|
$this->lastBooleanOperator = 0;
|
|
$this->cognitiveComplexity = 0;
|
|
|
|
/*
|
|
Keep track of parser's level stack
|
|
We push to this stak whenever we encounter a Tokens::$scopeOpeners
|
|
*/
|
|
$levelStack = [];
|
|
/*
|
|
We look for changes in token[level] to know when to remove from the stack
|
|
however ['level'] only increases when there are tokens inside {}
|
|
after pushing to the stack watch for a level change
|
|
*/
|
|
$levelIncreased = false;
|
|
|
|
for ($i = $functionStartPosition + 1; $i < $functionEndPosition; $i++) {
|
|
$currentToken = $tokens[$i];
|
|
|
|
$isNestingToken = false;
|
|
if (in_array($currentToken['code'], Tokens::$scopeOpeners, true)) {
|
|
$isNestingToken = true;
|
|
if ($levelIncreased === false && count($levelStack) > 0) {
|
|
// parser's level never increased
|
|
// caused by empty condition such as `if ($x) { }`
|
|
array_pop($levelStack);
|
|
}
|
|
$levelStack[] = $currentToken;
|
|
$levelIncreased = false;
|
|
} elseif (isset($tokens[$i - 1]) && $currentToken['level'] < $tokens[$i - 1]['level']) {
|
|
$diff = $tokens[$i - 1]['level'] - $currentToken['level'];
|
|
array_splice($levelStack, 0 - $diff);
|
|
} elseif (isset($tokens[$i - 1]) && $currentToken['level'] > $tokens[$i - 1]['level']) {
|
|
$levelIncreased = true;
|
|
}
|
|
|
|
$this->resolveBooleanOperatorChain($currentToken);
|
|
|
|
if (!$this->isIncrementingToken($currentToken, $tokens, $i)) {
|
|
continue;
|
|
}
|
|
|
|
$this->cognitiveComplexity++;
|
|
|
|
$addNestingIncrement = isset(self::NESTING_INCREMENTS[$currentToken['code']])
|
|
&& in_array($currentToken['code'], [T_ELSEIF, T_ELSE], true) === false;
|
|
if (!$addNestingIncrement) {
|
|
continue;
|
|
}
|
|
$measuredNestingLevel = count(array_filter($levelStack, static function (array $token) {
|
|
return in_array($token['code'], self::NESTING_INCREMENTS, true);
|
|
}));
|
|
if ($isNestingToken) {
|
|
$measuredNestingLevel--;
|
|
}
|
|
// B3. Nesting increment
|
|
if ($measuredNestingLevel > 0) {
|
|
$this->cognitiveComplexity += $measuredNestingLevel;
|
|
}
|
|
}
|
|
|
|
return $this->cognitiveComplexity;
|
|
}
|
|
|
|
/**
|
|
* Keep track of consecutive matching boolean operators, that don't receive increment.
|
|
*
|
|
* @param array{code:int|string} $token
|
|
*/
|
|
private function resolveBooleanOperatorChain(array $token): void
|
|
{
|
|
$code = $token['code'];
|
|
|
|
// Whenever we cross anything that interrupts possible condition we reset chain.
|
|
if ($this->lastBooleanOperator > 0 && isset(self::OPERATOR_CHAIN_BREAKS[$code])) {
|
|
$this->lastBooleanOperator = 0;
|
|
return;
|
|
}
|
|
|
|
if (isset(self::BOOLEAN_OPERATORS[$code]) === false) {
|
|
return;
|
|
}
|
|
|
|
// If we match last operator, there is no increment added for current one.
|
|
if ($this->lastBooleanOperator === $code) {
|
|
return;
|
|
}
|
|
|
|
$this->cognitiveComplexity++;
|
|
|
|
$this->lastBooleanOperator = $code;
|
|
}
|
|
|
|
/**
|
|
* @param array{code:int|string} $token
|
|
* @param array<int, array<string, array<int, int|string>|int|string>> $tokens
|
|
*/
|
|
private function isIncrementingToken(array $token, array $tokens, int $position): bool
|
|
{
|
|
$code = $token['code'];
|
|
|
|
if (isset(self::INCREMENTS[$code])) {
|
|
return true;
|
|
}
|
|
|
|
// B1. ternary operator
|
|
if ($code === T_INLINE_THEN) {
|
|
return true;
|
|
}
|
|
|
|
// B1. goto LABEL, break LABEL, continue LABEL
|
|
if (isset(self::BREAKING_TOKENS[$code])) {
|
|
$nextToken = $this->phpcsFile->findNext(Tokens::$emptyTokens, $position + 1, null, true);
|
|
if ($nextToken === false || $tokens[$nextToken]['code'] !== T_SEMICOLON) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
}
|