From c6249f6a55641666bb805cac9544b534a5072d9d Mon Sep 17 00:00:00 2001 From: bradley kent Date: Tue, 26 Oct 2021 09:52:46 -0500 Subject: [PATCH] refactor MaximumComplexitySniff and Analyzer to utilize "$phpcsFile" and how nestingIncrement is calculated --- .gitignore | 3 +- phpunit.xml | 25 +-- src/CognitiveComplexity/Analyzer.php | 141 ++++++------ .../Complexity/MaximumComplexitySniff.php | 8 +- tests/AnalyzerTest.php | 47 ++-- tests/Data/function3.php.inc | 39 +++- tests/SniffTest.php | 63 ------ .../Complexity/MaximumComplexitySniffTest.php | 24 +++ tests/TestCase.php | 201 ++++++++++++++++++ tests/bootstrap.php | 1 + 10 files changed, 362 insertions(+), 190 deletions(-) delete mode 100644 tests/SniffTest.php create mode 100644 tests/Sniffs/Complexity/MaximumComplexitySniffTest.php create mode 100644 tests/TestCase.php diff --git a/.gitignore b/.gitignore index 49ce3c1..d8a745c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -/vendor \ No newline at end of file +.phpunit.result.cache +/vendor diff --git a/phpunit.xml b/phpunit.xml index 8d40677..afa0e20 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,12 +1,13 @@ - - - - tests - - - - - src - - - \ No newline at end of file + + + + + src + + + + + tests + + + diff --git a/src/CognitiveComplexity/Analyzer.php b/src/CognitiveComplexity/Analyzer.php index 9b0510f..31ed5fe 100644 --- a/src/CognitiveComplexity/Analyzer.php +++ b/src/CognitiveComplexity/Analyzer.php @@ -4,6 +4,9 @@ namespace Rarst\PHPCS\CognitiveComplexity; +use PHP_CodeSniffer\Files\File; +use PHP_CodeSniffer\Util\Tokens; + /** * Based on https://www.sonarsource.com/docs/CognitiveComplexity.pdf * @@ -14,15 +17,6 @@ */ final class Analyzer { - /** @var int */ - private $cognitiveComplexity = 0; - - /** @var bool */ - private $isInTryConstruction = false; - - /** @var int */ - private $lastBooleanOperator = 0; - /** * B1. Increments * @@ -30,7 +24,7 @@ final class Analyzer * * @var int[]|string[] */ - private const increments = [ + private const INCREMENTS = [ T_IF => T_IF, T_ELSE => T_ELSE, T_ELSEIF => T_ELSEIF, @@ -43,13 +37,13 @@ final class Analyzer ]; /** @var int[]|string[] */ - private const booleanOperators = [ + private const BOOLEAN_OPERATORS = [ T_BOOLEAN_AND => T_BOOLEAN_AND, // && T_BOOLEAN_OR => T_BOOLEAN_OR, // || ]; /** @var int[]|string[] */ - private const operatorChainBreaks = [ + private const OPERATOR_CHAIN_BREAKS = [ T_OPEN_PARENTHESIS => T_OPEN_PARENTHESIS, T_CLOSE_PARENTHESIS => T_CLOSE_PARENTHESIS, T_SEMICOLON => T_SEMICOLON, @@ -59,9 +53,11 @@ final class Analyzer /** * B3. Nesting increments + * * @var int[]|string[] */ - private const nestingIncrements = [ + private const NESTING_INCREMENTS = [ + T_CLOSURE => T_CLOSURE, T_IF => T_IF, T_INLINE_THEN => T_INLINE_THEN, T_SWITCH => T_SWITCH, @@ -74,19 +70,32 @@ final class Analyzer /** * B1. Increments + * * @var int[] */ - private const breakingTokens = [ + private const BREAKING_TOKENS = [ T_CONTINUE => T_CONTINUE, T_GOTO => T_GOTO, T_BREAK => T_BREAK, ]; + /** @var int */ + private $cognitiveComplexity = 0; + + /** @var int */ + private $lastBooleanOperator = 0; + + private $phpcsFile; + /** - * @param mixed[] $tokens + * @param File $phpcsFile phpcs File instance + * @param int $position current index */ - public function computeForFunctionFromTokensAndPosition(array $tokens, int $position): int + public function computeForFunctionFromTokensAndPosition(File $phpcsFile, int $position): int { + $this->phpcsFile = $phpcsFile; + $tokens = $phpcsFile->getTokens(); + // function without body, e.g. in interface if (!isset($tokens[$position]['scope_opener'])) { return 0; @@ -96,14 +105,40 @@ public function computeForFunctionFromTokensAndPosition(array $tokens, int $posi $functionStartPosition = $tokens[$position]['scope_opener']; $functionEndPosition = $tokens[$position]['scope_closer']; - $this->isInTryConstruction = false; $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 = array(); + /* + 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]; - $this->resolveTryControlStructure($currentToken); + $isNestingToken = false; + if (\in_array($currentToken['code'], Tokens::$scopeOpeners)) { + $isNestingToken = true; + if ($levelIncreased === false && \count($levelStack)) { + // 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']) { + \array_pop($levelStack); + } elseif (isset($tokens[$i - 1]) && $currentToken['level'] > $tokens[$i - 1]['level']) { + $levelIncreased = true; + } + $this->resolveBooleanOperatorChain($currentToken); if (!$this->isIncrementingToken($currentToken, $tokens, $i)) { @@ -112,16 +147,19 @@ public function computeForFunctionFromTokensAndPosition(array $tokens, int $posi ++$this->cognitiveComplexity; - if (isset(self::breakingTokens[$currentToken['code']])) { + $addNestingIncrement = isset(self::NESTING_INCREMENTS[$currentToken['code']]); + if (!$addNestingIncrement) { continue; } - - $isNestingIncrement = isset(self::nestingIncrements[$currentToken['code']]); - $measuredNestingLevel = $this->getMeasuredNestingLevel($currentToken, $tokens, $position); - + $measuredNestingLevel = \count(\array_filter($levelStack, function ($token) { + return \in_array($token['code'], self::NESTING_INCREMENTS); + })); + if ($isNestingToken) { + $measuredNestingLevel--; + } // B3. Nesting increment - if ($isNestingIncrement && $measuredNestingLevel > 1) { - $this->cognitiveComplexity += $measuredNestingLevel - 1; + if ($measuredNestingLevel > 0) { + $this->cognitiveComplexity += $measuredNestingLevel; } } @@ -131,18 +169,18 @@ public function computeForFunctionFromTokensAndPosition(array $tokens, int $posi /** * Keep track of consecutive matching boolean operators, that don't receive increment. * - * @param mixed[] $token + * @param mixed[] $token token */ private function resolveBooleanOperatorChain(array $token): void { // Whenever we cross anything that interrupts possible condition we reset chain. - if ($this->lastBooleanOperator && isset(self::operatorChainBreaks[$token['code']])) { + if ($this->lastBooleanOperator && isset(self::OPERATOR_CHAIN_BREAKS[$token['code']])) { $this->lastBooleanOperator = 0; return; } - if (!isset(self::booleanOperators[$token['code']])) { + if (!isset(self::BOOLEAN_OPERATORS[$token['code']])) { return; } @@ -156,29 +194,13 @@ private function resolveBooleanOperatorChain(array $token): void } /** - * @param mixed[] $token - */ - private function resolveTryControlStructure(array $token): void - { - // code entered "try { }" - if ($token['code'] === T_TRY) { - $this->isInTryConstruction = true; - return; - } - - // code left "try { }" - if ($token['code'] === T_CATCH) { - $this->isInTryConstruction = false; - } - } - - /** - * @param mixed[] $token - * @param mixed[] $tokens + * @param mixed[] $token token to test + * @param mixed[] $tokens all function tokens + * @param int $position token index */ private function isIncrementingToken(array $token, array $tokens, int $position): bool { - if (isset(self::increments[$token['code']])) { + if (isset(self::INCREMENTS[$token['code']])) { return true; } @@ -188,30 +210,13 @@ private function isIncrementingToken(array $token, array $tokens, int $position) } // B1. goto LABEL, break LABEL, continue LABEL - if (isset(self::breakingTokens[$token['code']])) { - $nextToken = $tokens[$position + 1]['code']; - if ($nextToken !== T_SEMICOLON) { + if (isset(self::BREAKING_TOKENS[$token['code']])) { + $nextToken = $this->phpcsFile->findNext(Tokens::$emptyTokens, $position + 1, null, true); + if ($nextToken === false || $tokens[$nextToken]['code'] !== T_SEMICOLON) { return true; } } return false; } - - /** - * @param mixed[] $currentToken - * @param mixed[] $tokens - */ - private function getMeasuredNestingLevel(array $currentToken, array $tokens, int $functionTokenPosition): int - { - $functionNestingLevel = $tokens[$functionTokenPosition]['level']; - - $measuredNestingLevel = $currentToken['level'] - $functionNestingLevel; - - if ($this->isInTryConstruction) { - return --$measuredNestingLevel; - } - - return $measuredNestingLevel; - } } diff --git a/src/CognitiveComplexity/Sniffs/Complexity/MaximumComplexitySniff.php b/src/CognitiveComplexity/Sniffs/Complexity/MaximumComplexitySniff.php index 2430e7b..f87eb86 100644 --- a/src/CognitiveComplexity/Sniffs/Complexity/MaximumComplexitySniff.php +++ b/src/CognitiveComplexity/Sniffs/Complexity/MaximumComplexitySniff.php @@ -34,10 +34,8 @@ public function register(): array */ public function process(File $phpcsFile, $stackPtr): void { - $tokens = $phpcsFile->getTokens(); - $cognitiveComplexity = $this->analyzer->computeForFunctionFromTokensAndPosition( - $tokens, + $phpcsFile, $stackPtr ); @@ -45,7 +43,7 @@ public function process(File $phpcsFile, $stackPtr): void return; } - $name = $tokens[$stackPtr + 2]['content']; + $name = $phpcsFile->getDeclarationName($stackPtr); $phpcsFile->addError( 'Cognitive complexity for "%s" is %d but has to be less than or equal to %d.', @@ -54,7 +52,7 @@ public function process(File $phpcsFile, $stackPtr): void [ $name, $cognitiveComplexity, - $this->maxCognitiveComplexity + $this->maxCognitiveComplexity, ] ); } diff --git a/tests/AnalyzerTest.php b/tests/AnalyzerTest.php index 3fa13a3..cae377b 100644 --- a/tests/AnalyzerTest.php +++ b/tests/AnalyzerTest.php @@ -4,6 +4,8 @@ use Iterator; use PHP_CodeSniffer\Config; +use PHP_CodeSniffer\Ruleset; +use PHP_CodeSniffer\Files\File; use PHP_CodeSniffer\Tokenizers\PHP; use PHPUnit\Framework\TestCase; use Rarst\PHPCS\CognitiveComplexity\Analyzer; @@ -24,21 +26,12 @@ protected function setUp(): void */ public function test(string $filePath, int $expectedCognitiveComplexity): void { - $fileContent = file_get_contents($filePath); - $tokens = $this->fileToTokens($fileContent); - $functionTokenPosition = null; - foreach ($tokens as $position => $token) { - if ($token['code'] === T_FUNCTION) { - $functionTokenPosition = $position; - break; - } - } - + $file = $this->fileFactory($filePath); + $functionTokenPos = $file->findNext(T_FUNCTION, 0); $cognitiveComplexity = $this->analyzer->computeForFunctionFromTokensAndPosition( - $tokens, - $functionTokenPosition + $file, + $functionTokenPos ); - $this->assertSame($expectedCognitiveComplexity, $cognitiveComplexity); } @@ -49,7 +42,7 @@ public function provideTokensAndExpectedCognitiveComplexity(): Iterator { yield [__DIR__ . '/Data/function.php.inc', 9]; yield [__DIR__ . '/Data/function2.php.inc', 6]; - yield [__DIR__ . '/Data/function3.php.inc', 1]; + yield [__DIR__ . '/Data/function3.php.inc', 9]; yield [__DIR__ . '/Data/function4.php.inc', 2]; yield [__DIR__ . '/Data/function5.php.inc', 19]; yield [__DIR__ . '/Data/function6.php.inc', 0]; @@ -60,23 +53,17 @@ public function provideTokensAndExpectedCognitiveComplexity(): Iterator } /** - * @return mixed[] - */ - private function fileToTokens(string $fileContent): array - { - return (new PHP($fileContent, $this->getLegacyConfig()))->getTokens(); - } - - /** - * @return Config|stdClass + * @param string $filePath + * + * @return File */ - private function getLegacyConfig() + private function fileFactory($filePath) { - $config = new stdClass(); - $config->tabWidth = 4; - $config->annotations = false; - $config->encoding = 'UTF-8'; - - return $config; + $config = new Config(); + $ruleset = new Ruleset($config); + $file = new File($filePath, $ruleset, $config); + $file->setContent(\file_get_contents($filePath)); + $file->parse(); + return $file; } } diff --git a/tests/Data/function3.php.inc b/tests/Data/function3.php.inc index 1a8e61c..49c8124 100644 --- a/tests/Data/function3.php.inc +++ b/tests/Data/function3.php.inc @@ -1,16 +1,33 @@ getMockBuilder(File::class) - ->disableOriginalConstructor() - ->onlyMethods(['getTokens', 'addError']) - ->getMock(); - - $file->expects($this->once()) - ->method('getTokens') - ->willReturn( - [ - [ - 'scope_opener' => 2, - 'scope_closer' => 5, - 'level' => 0, - ], - [], - [ - 'content' => 'function_name', - 'level' => 0, - ], - [ - 'type' => 'T_IF', - 'code' => T_IF, - 'level' => 1, - ], - [ - 'type' => 'T_BOOLEAN_AND', - 'code' => T_BOOLEAN_AND, - 'level' => 2, - ], - ] - ); - - $file->expects($this->once()) - ->method('addError') - ->with( - $this->isType('string'), - $this->anything(), - $this->equalTo('TooHigh'), - $this->equalTo(['function_name', 2, 1]) - ); - - $sniff = new MaximumComplexitySniff(); - $sniff->maxCognitiveComplexity = 1; - - $this->assertEquals([T_FUNCTION], $sniff->register()); - - $sniff->process($file, 0); - } -} diff --git a/tests/Sniffs/Complexity/MaximumComplexitySniffTest.php b/tests/Sniffs/Complexity/MaximumComplexitySniffTest.php new file mode 100644 index 0000000..f3e9ba6 --- /dev/null +++ b/tests/Sniffs/Complexity/MaximumComplexitySniffTest.php @@ -0,0 +1,24 @@ +getErrorCount()); + self::assertSniffError($report, 3, 'TooHigh'); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..62bb941 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,201 @@ +)[] $sniffProperties + * @param string[] $codesToCheck + * @param string[] $cliArgs + * @return File + */ + protected static function checkFile(string $filePath, array $sniffProperties = [], array $codesToCheck = [], array $cliArgs = []): File + { + if (defined('PHP_CODESNIFFER_CBF') === false) { + define('PHP_CODESNIFFER_CBF', false); + } + $codeSniffer = new Runner(); + $codeSniffer->config = new Config(array_merge(['-s'], $cliArgs)); + $codeSniffer->init(); + + if (count($sniffProperties) > 0) { + $codeSniffer->ruleset->ruleset[static::getSniffName()]['properties'] = $sniffProperties; + } + + $sniffClassName = static::getSniffClassName(); + + $codeSniffer->ruleset->sniffs = [$sniffClassName => new $sniffClassName()]; + + if (count($codesToCheck) > 0) { + foreach (static::getSniffClassReflection()->getConstants() as $constantName => $constantValue) { + if (strpos($constantName, 'CODE_') !== 0 || in_array($constantValue, $codesToCheck, true)) { + continue; + } + + $codeSniffer->ruleset->ruleset[sprintf('%s.%s', static::getSniffName(), $constantValue)]['severity'] = 0; + } + } + + $codeSniffer->ruleset->populateTokenListeners(); + + $file = new LocalFile($filePath, $codeSniffer->ruleset, $codeSniffer->config); + $file->process(); + + return $file; + } + + protected static function assertNoSniffErrorInFile(File $phpcsFile): void + { + $errors = $phpcsFile->getErrors(); + self::assertEmpty($errors, sprintf('No errors expected, but %d errors found.', count($errors))); + } + + protected static function assertSniffError(File $phpcsFile, int $line, string $code, ?string $message = null): void + { + $errors = $phpcsFile->getErrors(); + self::assertTrue(isset($errors[$line]), sprintf('Expected error on line %s, but none found.', $line)); + + $sniffCode = sprintf('%s.%s', static::getSniffName(), $code); + + self::assertTrue( + self::hasError($errors[$line], $sniffCode, $message), + sprintf( + 'Expected error %s%s, but none found on line %d.%sErrors found on line %d:%s%s%s', + $sniffCode, + $message !== null + ? sprintf(' with message "%s"', $message) + : '', + $line, + PHP_EOL . PHP_EOL, + $line, + PHP_EOL, + self::getFormattedErrors($errors[$line]), + PHP_EOL + ) + ); + } + + protected static function assertNoSniffError(File $phpcsFile, int $line): void + { + $errors = $phpcsFile->getErrors(); + self::assertFalse( + isset($errors[$line]), + sprintf( + 'Expected no error on line %s, but found:%s%s%s', + $line, + PHP_EOL . PHP_EOL, + isset($errors[$line]) ? self::getFormattedErrors($errors[$line]) : '', + PHP_EOL + ) + ); + } + + protected static function assertAllFixedInFile(File $phpcsFile): void + { + $phpcsFile->disableCaching(); + $phpcsFile->fixer->fixFile(); + self::assertStringEqualsFile(preg_replace('~(\\.php)$~', '.fixed\\1', $phpcsFile->getFilename()), $phpcsFile->fixer->getContents()); + } + + /** + * Customized / different from Slevomat + */ + private static function getSniffName(): string + { + return CommonUtil::getSniffCode(static::getSniffClassName()); + } + + /** + * Customized / different from Slevomat + */ + private static function getSniffClassName(): string + { + $className = \substr(static::class, 0, -\strlen('Test')); + return \preg_replace('#\\\Tests?\\\#i', '\\', $className); + } + + private static function getSniffClassReflection(): ReflectionClass + { + static $reflections = []; + + /** @phpstan-var class-string $className */ + $className = static::getSniffClassName(); + + return $reflections[$className] ?? $reflections[$className] = new ReflectionClass($className); + } + + /** + * @param (string|int)[][][] $errorsOnLine + * @param string $sniffCode + * @param string|null $message + * @return bool + */ + private static function hasError(array $errorsOnLine, string $sniffCode, ?string $message): bool + { + $hasError = false; + + foreach ($errorsOnLine as $errorsOnPosition) { + foreach ($errorsOnPosition as $error) { + /** @var string $errorSource */ + $errorSource = $error['source']; + /** @var string $errorMessage */ + $errorMessage = $error['message']; + + if ( + $errorSource === $sniffCode + && ( + $message === null + || strpos($errorMessage, $message) !== false + ) + ) { + $hasError = true; + break; + } + } + } + + return $hasError; + } + + /** + * @param (string|int|bool)[][][] $errors + */ + private static function getFormattedErrors(array $errors): string + { + return implode(PHP_EOL, array_map(static function (array $errors): string { + return implode(PHP_EOL, array_map(static function (array $error): string { + return sprintf("\t%s: %s", $error['source'], $error['message']); + }, $errors)); + }, $errors)); + } + +} \ No newline at end of file diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 6c9f249..641a1f0 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -5,6 +5,7 @@ use PHP_CodeSniffer\Util\Tokens; require_once __DIR__ . '/../vendor/squizlabs/php_codesniffer/autoload.php'; +require_once __DIR__ . '/TestCase.php'; require_once __DIR__ . '/../src/CognitiveComplexity/Analyzer.php'; require_once __DIR__ . '/../src/CognitiveComplexity/Sniffs/Complexity/MaximumComplexitySniff.php';