diff --git a/ChangeLog.md b/ChangeLog.md index 2d7e3585c..8214d374d 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -20,7 +20,6 @@ All notable changes are documented in this file using the [Keep a CHANGELOG](htt * [#754](https://github.com/sebastianbergmann/php-code-coverage/pull/754): Implement collection of raw branch and path coverage * [#755](https://github.com/sebastianbergmann/php-code-coverage/pull/755): Implement processing of raw branch and path coverage * [#756](https://github.com/sebastianbergmann/php-code-coverage/pull/756): Improve handling of uncovered files -* `SebastianBergmann\CodeCoverage\CodeCoverage::getCacheTokens()` has been renamed to `SebastianBergmann\CodeCoverage\CodeCoverage::cachesTokens()` * `SebastianBergmann\CodeCoverage\Filter::addDirectoryToWhitelist()` has been renamed to `SebastianBergmann\CodeCoverage\Filter::includeDirectory()` * `SebastianBergmann\CodeCoverage\Filter::addFilesToWhitelist()` has been renamed to `SebastianBergmann\CodeCoverage\Filter::includeFiles()` * `SebastianBergmann\CodeCoverage\Filter::addFileToWhitelist()` has been renamed to `SebastianBergmann\CodeCoverage\Filter::includeFile()` @@ -32,7 +31,7 @@ All notable changes are documented in this file using the [Keep a CHANGELOG](htt ### Removed -* `SebastianBergmann\CodeCoverage\CodeCoverage::setCacheTokens()` has been removed, please use `SebastianBergmann\CodeCoverage\CodeCoverage::enableTokenCaching()` or `SebastianBergmann\CodeCoverage\CodeCoverage::disableTokenCaching()` instead +* `SebastianBergmann\CodeCoverage\CodeCoverage::setCacheTokens()` and `SebastianBergmann\CodeCoverage\CodeCoverage::getCacheTokens()` have been removed * `SebastianBergmann\CodeCoverage\CodeCoverage::setCheckForUnintentionallyCoveredCode()` has been removed, please use `SebastianBergmann\CodeCoverage\CodeCoverage::enableCheckForUnintentionallyCoveredCode()` or `SebastianBergmann\CodeCoverage\CodeCoverage::disableCheckForUnintentionallyCoveredCode()` instead * `SebastianBergmann\CodeCoverage\CodeCoverage::setSubclassesExcludedFromUnintentionallyCoveredCodeCheck()` has been removed, please use `SebastianBergmann\CodeCoverage\CodeCoverage::excludeSubclassesOfThisClassFromUnintentionallyCoveredCodeCheck()` instead * `SebastianBergmann\CodeCoverage\CodeCoverage::setAddUncoveredFilesFromWhitelist()` has been removed, please use `SebastianBergmann\CodeCoverage\CodeCoverage::includeUncoveredFiles()` or `SebastianBergmann\CodeCoverage\CodeCoverage::excludeUncoveredFiles()` instead diff --git a/composer.json b/composer.json index 063aa25f9..18fc51888 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "php": "^7.3 || ^8.0", "ext-dom": "*", "ext-xmlwriter": "*", + "nikic/php-parser": "^4.6", "phpunit/php-file-iterator": "^3.0.3", "phpunit/php-text-template": "^2.0.2", "phpunit/php-token-stream": "^4.0.3", diff --git a/src/CodeCoverage.php b/src/CodeCoverage.php index d388af3e8..7c05c7038 100644 --- a/src/CodeCoverage.php +++ b/src/CodeCoverage.php @@ -18,22 +18,10 @@ use function array_values; use function count; use function explode; -use function file; use function file_exists; use function get_class; use function is_array; use function sort; -use function strpos; -use function trim; -use OutOfBoundsException; -use PHP_Token_CLASS; -use PHP_Token_COMMENT; -use PHP_Token_DOC_COMMENT; -use PHP_Token_FUNCTION; -use PHP_Token_INTERFACE; -use PHP_Token_Stream; -use PHP_Token_Stream_CachingFactory; -use PHP_Token_TRAIT; use PHPUnit\Framework\TestCase; use PHPUnit\Runner\PhptTestCase; use PHPUnit\Util\Test; @@ -41,6 +29,7 @@ use SebastianBergmann\CodeCoverage\Driver\Driver; use SebastianBergmann\CodeCoverage\Node\Builder; use SebastianBergmann\CodeCoverage\Node\Directory; +use SebastianBergmann\CodeCoverage\StaticAnalysis\IgnoredLinesFinder; use SebastianBergmann\CodeUnitReverseLookup\Wizard; /** @@ -65,11 +54,6 @@ final class CodeCoverage */ private $wizard; - /** - * @var bool - */ - private $cacheTokens = false; - /** * @var bool */ @@ -103,14 +87,14 @@ final class CodeCoverage private $data; /** - * @var array + * @var IgnoredLinesFinder */ - private $ignoredLines = []; + private $ignoredLinesFinder; /** * @var bool */ - private $disableIgnoredLines = false; + private $useAnnotationsForIgnoringCode = true; /** * Test data. @@ -133,10 +117,11 @@ final class CodeCoverage public function __construct(Driver $driver, Filter $filter) { - $this->driver = $driver; - $this->filter = $filter; - $this->data = new ProcessedCodeCoverageData; - $this->wizard = new Wizard; + $this->driver = $driver; + $this->filter = $filter; + $this->data = new ProcessedCodeCoverageData; + $this->ignoredLinesFinder = new IgnoredLinesFinder; + $this->wizard = new Wizard; } /** @@ -264,7 +249,10 @@ public function append(RawCodeCoverageData $rawData, $id = null, bool $append = } $this->applyFilter($rawData); - $this->applyIgnoredLinesFilter($rawData); + + if ($this->useAnnotationsForIgnoringCode) { + $this->applyIgnoredLinesFilter($rawData); + } $this->data->initializeUnseenData($rawData); @@ -326,21 +314,6 @@ public function merge(self $that): void $this->tests = array_merge($this->tests, $that->getTests()); } - public function enableTokenCaching(): void - { - $this->cacheTokens = true; - } - - public function disableTokenCaching(): void - { - $this->cacheTokens = false; - } - - public function cachesTokens(): bool - { - return $this->cacheTokens; - } - public function enableCheckForUnintentionallyCoveredCode(): void { $this->checkForUnintentionallyCoveredCode = true; @@ -373,12 +346,12 @@ public function doNotProcessUncoveredFiles(): void public function enableAnnotationsForIgnoringCode(): void { - $this->disableIgnoredLines = false; + $this->useAnnotationsForIgnoringCode = true; } public function disableAnnotationsForIgnoringCode(): void { - $this->disableIgnoredLines = true; + $this->useAnnotationsForIgnoringCode = false; } public function ignoreDeprecatedCode(): void @@ -479,7 +452,14 @@ private function applyIgnoredLinesFilter(RawCodeCoverageData $data): void continue; } - $data->removeCoverageDataForLines($filename, $this->getLinesToBeIgnored($filename)); + $data->removeCoverageDataForLines( + $filename, + $this->ignoredLinesFinder->findIgnoredLinesInFile( + $filename, + $this->useAnnotationsForIgnoringCode, + $this->ignoreDeprecatedCode + ) + ); } } @@ -495,111 +475,9 @@ private function addUncoveredFilesFromFilter(): void foreach ($uncoveredFiles as $uncoveredFile) { if (file_exists($uncoveredFile)) { - if ($this->cacheTokens) { - $tokens = PHP_Token_Stream_CachingFactory::get($uncoveredFile); - } else { - $tokens = new PHP_Token_Stream($uncoveredFile); - } - - $this->append(RawCodeCoverageData::fromUncoveredFile($uncoveredFile, $tokens), self::UNCOVERED_FILES); - } - } - } - - private function getLinesToBeIgnored(string $fileName): array - { - if (isset($this->ignoredLines[$fileName])) { - return $this->ignoredLines[$fileName]; - } - - try { - return $this->getLinesToBeIgnoredInner($fileName); - } catch (OutOfBoundsException $e) { - // This can happen with PHP_Token_Stream if the file is syntactically invalid, - // and probably affects a file that wasn't executed. - return []; - } - } - - private function getLinesToBeIgnoredInner(string $fileName): array - { - $this->ignoredLines[$fileName] = []; - - if ($this->cacheTokens) { - $tokens = PHP_Token_Stream_CachingFactory::get($fileName); - } else { - $tokens = new PHP_Token_Stream($fileName); - } - - if ($this->disableIgnoredLines) { - $this->ignoredLines[$fileName] = array_unique($this->ignoredLines[$fileName]); - sort($this->ignoredLines[$fileName]); - - return $this->ignoredLines[$fileName]; - } - - $ignore = false; - $stop = false; - - foreach ($tokens->tokens() as $token) { - switch (get_class($token)) { - case PHP_Token_COMMENT::class: - case PHP_Token_DOC_COMMENT::class: - $_token = trim((string) $token); - - if ($_token === '// @codeCoverageIgnore' || - $_token === '//@codeCoverageIgnore') { - $ignore = true; - $stop = true; - } elseif ($_token === '// @codeCoverageIgnoreStart' || - $_token === '//@codeCoverageIgnoreStart') { - $ignore = true; - } elseif ($_token === '// @codeCoverageIgnoreEnd' || - $_token === '//@codeCoverageIgnoreEnd') { - $stop = true; - } - - break; - - case PHP_Token_INTERFACE::class: - case PHP_Token_TRAIT::class: - case PHP_Token_CLASS::class: - $this->ignoredLines[$fileName][] = $token->getLine(); //work around https://bugs.xdebug.org/view.php?id=1798 - // Intentional fallthrough - case PHP_Token_FUNCTION::class: - /* @var \PHP_Token_Interface $token */ - - $docblock = (string) $token->getDocblock(); - - if (strpos($docblock, '@codeCoverageIgnore') || ($this->ignoreDeprecatedCode && strpos($docblock, '@deprecated'))) { - $endLine = $token->getEndLine(); - - for ($i = $token->getLine(); $i <= $endLine; $i++) { - $this->ignoredLines[$fileName][] = $i; - } - } - - break; - } - - if ($ignore) { - $this->ignoredLines[$fileName][] = $token->getLine(); - - if ($stop) { - $ignore = false; - $stop = false; - } + $this->append(RawCodeCoverageData::fromUncoveredFile($uncoveredFile), self::UNCOVERED_FILES); } } - - $this->ignoredLines[$fileName] = array_unique( - $this->ignoredLines[$fileName] - ); - - $this->ignoredLines[$fileName] = array_unique($this->ignoredLines[$fileName]); - sort($this->ignoredLines[$fileName]); - - return $this->ignoredLines[$fileName]; } /** diff --git a/src/Driver/Xdebug3Driver.php b/src/Driver/Xdebug3Driver.php index 8bca291bb..10fa1b55a 100644 --- a/src/Driver/Xdebug3Driver.php +++ b/src/Driver/Xdebug3Driver.php @@ -15,6 +15,7 @@ use const XDEBUG_FILTER_CODE_COVERAGE; use const XDEBUG_PATH_INCLUDE; use function extension_loaded; +use function in_array; use function ini_get; use function phpversion; use function sprintf; @@ -51,7 +52,7 @@ public function __construct(Filter $filter) ); } - if (!ini_get('xdebug.mode') || ini_get('xdebug.mode') !== 'coverage') { + if (!ini_get('xdebug.mode') || !in_array('coverage', explode(',', ini_get('xdebug.mode')))) { throw new Xdebug3NotEnabledException; } diff --git a/src/Node/Builder.php b/src/Node/Builder.php index ecaa088c3..7ff445f68 100644 --- a/src/Node/Builder.php +++ b/src/Node/Builder.php @@ -40,14 +40,13 @@ public function build(CodeCoverage $coverage): Directory $this->addItems( $root, $this->buildDirectoryStructure($data), - $coverage->getTests(), - $coverage->cachesTokens() + $coverage->getTests() ); return $root; } - private function addItems(Directory $root, array $items, array $tests, bool $cacheTokens): void + private function addItems(Directory $root, array $items, array $tests): void { foreach ($items as $key => $value) { $key = (string) $key; @@ -56,11 +55,11 @@ private function addItems(Directory $root, array $items, array $tests, bool $cac $key = substr($key, 0, -2); if (file_exists($root->pathAsString() . DIRECTORY_SEPARATOR . $key)) { - $root->addFile($key, $value['lineCoverage'], $value['functionCoverage'], $tests, $cacheTokens); + $root->addFile($key, $value['lineCoverage'], $value['functionCoverage'], $tests); } } else { $child = $root->addDirectory($key); - $this->addItems($child, $value, $tests, $cacheTokens); + $this->addItems($child, $value, $tests); } } } diff --git a/src/Node/Directory.php b/src/Node/Directory.php index 9b4189b4c..7ffc5d6ed 100644 --- a/src/Node/Directory.php +++ b/src/Node/Directory.php @@ -160,9 +160,9 @@ public function addDirectory(string $name): self return $directory; } - public function addFile(string $name, array $lineCoverageData, array $functionCoverageData, array $testData, bool $cacheTokens): File + public function addFile(string $name, array $lineCoverageData, array $functionCoverageData, array $testData): File { - $file = new File($name, $this, $lineCoverageData, $functionCoverageData, $testData, $cacheTokens); + $file = new File($name, $this, $lineCoverageData, $functionCoverageData, $testData); $this->children[] = $file; $this->files[] = &$this->children[count($this->children) - 1]; diff --git a/src/Node/File.php b/src/Node/File.php index ee779f186..4b08e8f64 100644 --- a/src/Node/File.php +++ b/src/Node/File.php @@ -16,7 +16,6 @@ use function strpos; use OutOfBoundsException; use PHP_Token_Stream; -use PHP_Token_Stream_CachingFactory; /** * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage @@ -123,24 +122,18 @@ final class File extends AbstractNode */ private $numTestedFunctions; - /** - * @var bool - */ - private $cacheTokens; - /** * @var array */ private $codeUnitsByLine = []; - public function __construct(string $name, AbstractNode $parent, array $lineCoverageData, array $functionCoverageData, array $testData, bool $cacheTokens) + public function __construct(string $name, AbstractNode $parent, array $lineCoverageData, array $functionCoverageData, array $testData) { parent::__construct($name, $parent); $this->lineCoverageData = $lineCoverageData; $this->functionCoverageData = $functionCoverageData; $this->testData = $testData; - $this->cacheTokens = $cacheTokens; $this->calculateStatistics(); } @@ -338,11 +331,7 @@ public function numberOfTestedFunctions(): int private function calculateStatistics(): void { - if ($this->cacheTokens) { - $tokens = PHP_Token_Stream_CachingFactory::get($this->pathAsString()); - } else { - $tokens = new PHP_Token_Stream($this->pathAsString()); - } + $tokens = new PHP_Token_Stream($this->pathAsString()); $this->linesOfCode = $tokens->getLinesOfCode(); diff --git a/src/RawCodeCoverageData.php b/src/RawCodeCoverageData.php index 1e83c496c..112e5b66c 100644 --- a/src/RawCodeCoverageData.php +++ b/src/RawCodeCoverageData.php @@ -14,33 +14,12 @@ use function array_flip; use function array_intersect; use function array_intersect_key; -use function array_merge; -use function array_pop; -use function array_shift; use function count; use function file; -use function get_class; use function in_array; use function range; -use function strpos; -use function substr; -use function substr_count; -use function trim; -use Exception; -use PHP_Token_CLASS; -use PHP_Token_CLOSE_TAG; -use PHP_Token_COMMENT; -use PHP_Token_DECLARE; -use PHP_Token_DOC_COMMENT; -use PHP_Token_FUNCTION; -use PHP_Token_INTERFACE; -use PHP_Token_NAMESPACE; -use PHP_Token_OPEN_TAG; -use PHP_Token_Stream; -use PHP_Token_TRAIT; -use PHP_Token_USE; -use PHP_Token_USE_FUNCTION; use SebastianBergmann\CodeCoverage\Driver\Driver; +use SebastianBergmann\CodeCoverage\StaticAnalysis\ExecutableLinesFinder; /** * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage @@ -100,118 +79,12 @@ public static function fromXdebugWithMixedCoverage(array $rawCoverage): self return new self($lineCoverage, $functionCoverage); } - public static function fromUncoveredFile(string $filename, PHP_Token_Stream $tokens): self + public static function fromUncoveredFile(string $filename): self { $lineCoverage = []; - $lines = file($filename); - $lineCount = count($lines); - - for ($i = 1; $i <= $lineCount; $i++) { - $lineCoverage[$i] = Driver::LINE_NOT_EXECUTED; - } - - //remove empty lines - foreach ($lines as $index => $line) { - if (!trim($line)) { - unset($lineCoverage[$index + 1]); - } - } - - //not all lines are actually executable though, remove these - try { - foreach ($tokens->getInterfaces() as $interface) { - $interfaceStartLine = $interface['startLine']; - $interfaceEndLine = $interface['endLine']; - - foreach (range($interfaceStartLine, $interfaceEndLine) as $line) { - unset($lineCoverage[$line]); - } - } - - foreach (array_merge($tokens->getClasses(), $tokens->getTraits()) as $classOrTrait) { - $classOrTraitStartLine = $classOrTrait['startLine']; - $classOrTraitEndLine = $classOrTrait['endLine']; - - if (empty($classOrTrait['methods'])) { - foreach (range($classOrTraitStartLine, $classOrTraitEndLine) as $line) { - unset($lineCoverage[$line]); - } - - continue; - } - - $firstMethod = array_shift($classOrTrait['methods']); - $firstMethodStartLine = $firstMethod['startLine']; - $lastMethodEndLine = $firstMethod['endLine']; - - do { - $lastMethod = array_pop($classOrTrait['methods']); - } while ($lastMethod !== null && 0 === strpos($lastMethod['signature'], 'anonymousFunction')); - - if ($lastMethod !== null) { - $lastMethodEndLine = $lastMethod['endLine']; - } - - foreach (range($classOrTraitStartLine, $firstMethodStartLine) as $line) { - unset($lineCoverage[$line]); - } - - foreach (range($lastMethodEndLine + 1, $classOrTraitEndLine) as $line) { - unset($lineCoverage[$line]); - } - } - - foreach ($tokens->tokens() as $token) { - switch (get_class($token)) { - case PHP_Token_COMMENT::class: - case PHP_Token_DOC_COMMENT::class: - $_token = trim((string) $token); - $_line = trim($lines[$token->getLine() - 1]); - - $start = $token->getLine(); - $end = $start + substr_count((string) $token, "\n"); - - // Do not ignore the first line when there is a token - // before the comment - if (0 !== strpos($_token, $_line)) { - $start++; - } - - for ($i = $start; $i < $end; $i++) { - unset($lineCoverage[$i]); - } - - // A DOC_COMMENT token or a COMMENT token starting with "/*" - // does not contain the final \n character in its text - if (isset($lines[$i - 1]) && 0 === strpos($_token, '/*') && '*/' === substr(trim($lines[$i - 1]), -2)) { - unset($lineCoverage[$i]); - } - - break; - - /* @noinspection PhpMissingBreakStatementInspection */ - case PHP_Token_NAMESPACE::class: - unset($lineCoverage[$token->getEndLine()]); - - // Intentional fallthrough - - case PHP_Token_INTERFACE::class: - case PHP_Token_TRAIT::class: - case PHP_Token_CLASS::class: - case PHP_Token_FUNCTION::class: - case PHP_Token_DECLARE::class: - case PHP_Token_OPEN_TAG::class: - case PHP_Token_CLOSE_TAG::class: - case PHP_Token_USE::class: - case PHP_Token_USE_FUNCTION::class: - unset($lineCoverage[$token->getLine()]); - - break; - } - } - } catch (Exception $e) { // This can happen with PHP_Token_Stream if the file is syntactically invalid - // do nothing + foreach ((new ExecutableLinesFinder)->findExecutableLinesInFile($filename) as $line) { + $lineCoverage[$line] = Driver::LINE_NOT_EXECUTED; } return new self([$filename => $lineCoverage], []); diff --git a/src/StaticAnalysis/ExecutableLinesFinder.php b/src/StaticAnalysis/ExecutableLinesFinder.php new file mode 100644 index 000000000..b1ab3f38a --- /dev/null +++ b/src/StaticAnalysis/ExecutableLinesFinder.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\StaticAnalysis; + +use PhpParser\Error; +use PhpParser\NodeTraverser; +use PhpParser\ParserFactory; + +final class ExecutableLinesFinder +{ + public function findExecutableLinesInFile(string $filename): array + { + $parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7); + + try { + $nodes = $parser->parse(file_get_contents($filename)); + + assert($nodes !== null); + + $traverser = new NodeTraverser; + $visitor = new ExecutableLinesFindingVisitor; + + $traverser->addVisitor($visitor); + + /* @noinspection UnusedFunctionResultInspection */ + $traverser->traverse($nodes); + + return $visitor->executableLines(); + + // @codeCoverageIgnoreStart + } catch (Error $error) { + } + // @codeCoverageIgnoreEnd + + return []; + } +} diff --git a/src/StaticAnalysis/ExecutableLinesFindingVisitor.php b/src/StaticAnalysis/ExecutableLinesFindingVisitor.php new file mode 100644 index 000000000..1adcaf1b4 --- /dev/null +++ b/src/StaticAnalysis/ExecutableLinesFindingVisitor.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\StaticAnalysis; + +use function array_unique; +use function sort; +use PhpParser\Node; +use PhpParser\Node\Stmt\Break_; +use PhpParser\Node\Stmt\Case_; +use PhpParser\Node\Stmt\Catch_; +use PhpParser\Node\Stmt\Continue_; +use PhpParser\Node\Stmt\Do_; +use PhpParser\Node\Stmt\Echo_; +use PhpParser\Node\Stmt\Else_; +use PhpParser\Node\Stmt\ElseIf_; +use PhpParser\Node\Stmt\Expression; +use PhpParser\Node\Stmt\Finally_; +use PhpParser\Node\Stmt\For_; +use PhpParser\Node\Stmt\Foreach_; +use PhpParser\Node\Stmt\Goto_; +use PhpParser\Node\Stmt\If_; +use PhpParser\Node\Stmt\Return_; +use PhpParser\Node\Stmt\Switch_; +use PhpParser\Node\Stmt\Throw_; +use PhpParser\Node\Stmt\TryCatch; +use PhpParser\Node\Stmt\Unset_; +use PhpParser\Node\Stmt\While_; +use PhpParser\NodeVisitorAbstract; + +final class ExecutableLinesFindingVisitor extends NodeVisitorAbstract +{ + /** + * @psalm-var list + */ + private $executableLines = []; + + public function enterNode(Node $node): void + { + if (!$this->isExecutable($node)) { + return; + } + + $this->executableLines[] = $node->getStartLine(); + } + + /** + * @psalm-return list + */ + public function executableLines(): array + { + $executableLines = array_unique($this->executableLines); + + sort($executableLines); + + return $executableLines; + } + + private function isExecutable(Node $node): bool + { + return $node instanceof Break_ || + $node instanceof Case_ || + $node instanceof Catch_ || + $node instanceof Continue_ || + $node instanceof Do_ || + $node instanceof Echo_ || + $node instanceof ElseIf_ || + $node instanceof Else_ || + $node instanceof Expression || + $node instanceof Finally_ || + $node instanceof Foreach_ || + $node instanceof For_ || + $node instanceof Goto_ || + $node instanceof If_ || + $node instanceof Return_ || + $node instanceof Switch_ || + $node instanceof Throw_ || + $node instanceof TryCatch || + $node instanceof Unset_ || + $node instanceof While_; + } +} diff --git a/src/StaticAnalysis/IgnoredLinesFinder.php b/src/StaticAnalysis/IgnoredLinesFinder.php new file mode 100644 index 000000000..cbb8b3c5a --- /dev/null +++ b/src/StaticAnalysis/IgnoredLinesFinder.php @@ -0,0 +1,135 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\StaticAnalysis; + +use const T_CLASS; +use const T_COMMENT; +use const T_DOC_COMMENT; +use const T_INTERFACE; +use const T_TRAIT; +use function array_merge; +use function array_unique; +use function file_get_contents; +use function is_array; +use function sort; +use function token_get_all; +use function trim; +use PhpParser\Error; +use PhpParser\NodeTraverser; +use PhpParser\ParserFactory; + +final class IgnoredLinesFinder +{ + /** + * @psalm-var array> + */ + private $ignoredLines = []; + + public function findIgnoredLinesInFile(string $filename, bool $useAnnotationsForIgnoringCode, bool $ignoreDeprecatedCode): array + { + if (isset($this->ignoredLines[$filename])) { + return $this->ignoredLines[$filename]; + } + + $this->ignoredLines[$filename] = []; + + $this->findLinesIgnoredByLineBasedAnnotations($filename, $useAnnotationsForIgnoringCode); + + if ($useAnnotationsForIgnoringCode) { + $this->findLinesIgnoredByDocBlockAnnotations($filename, $ignoreDeprecatedCode); + } + + $this->ignoredLines[$filename] = array_unique($this->ignoredLines[$filename]); + + sort($this->ignoredLines[$filename]); + + return $this->ignoredLines[$filename]; + } + + private function findLinesIgnoredByLineBasedAnnotations(string $filename, bool $useAnnotationsForIgnoringCode): void + { + $ignore = false; + $stop = false; + + foreach (token_get_all(file_get_contents($filename)) as $token) { + if (!is_array($token)) { + continue; + } + + switch ($token[0]) { + case T_COMMENT: + case T_DOC_COMMENT: + if (!$useAnnotationsForIgnoringCode) { + break; + } + + $comment = trim($token[1]); + + if ($comment === '// @codeCoverageIgnore' || + $comment === '//@codeCoverageIgnore') { + $ignore = true; + $stop = true; + } elseif ($comment === '// @codeCoverageIgnoreStart' || + $comment === '//@codeCoverageIgnoreStart') { + $ignore = true; + } elseif ($comment === '// @codeCoverageIgnoreEnd' || + $comment === '//@codeCoverageIgnoreEnd') { + $stop = true; + } + + break; + + case T_INTERFACE: + case T_TRAIT: + case T_CLASS: + // Workaround for https://bugs.xdebug.org/view.php?id=1798 + $this->ignoredLines[$filename][] = $token[2]; + + break; + } + + if ($ignore) { + $this->ignoredLines[$filename][] = $token[2]; + + if ($stop) { + $ignore = false; + $stop = false; + } + } + } + } + + private function findLinesIgnoredByDocBlockAnnotations(string $filename, bool $ignoreDeprecatedCode): void + { + $parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7); + + try { + $nodes = $parser->parse(file_get_contents($filename)); + + assert($nodes !== null); + + $traverser = new NodeTraverser; + $visitor = new IgnoredLinesFindingVisitor($ignoreDeprecatedCode); + + $traverser->addVisitor($visitor); + + /* @noinspection UnusedFunctionResultInspection */ + $traverser->traverse($nodes); + + $this->ignoredLines[$filename] = array_merge( + $this->ignoredLines[$filename], + $visitor->ignoredLines() + ); + // @codeCoverageIgnoreStart + } catch (Error $error) { + } + // @codeCoverageIgnoreEnd + } +} diff --git a/src/StaticAnalysis/IgnoredLinesFindingVisitor.php b/src/StaticAnalysis/IgnoredLinesFindingVisitor.php new file mode 100644 index 000000000..a4b06f331 --- /dev/null +++ b/src/StaticAnalysis/IgnoredLinesFindingVisitor.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\StaticAnalysis; + +use function array_merge; +use function range; +use function strpos; +use PhpParser\Node; +use PhpParser\Node\Stmt\Class_; +use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\Node\Stmt\Function_; +use PhpParser\Node\Stmt\Trait_; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitorAbstract; + +final class IgnoredLinesFindingVisitor extends NodeVisitorAbstract +{ + /** + * @psalm-var list + */ + private $ignoredLines = []; + + /** + * @var bool + */ + private $ignoreDeprecated; + + public function __construct(bool $ignoreDeprecated) + { + $this->ignoreDeprecated = $ignoreDeprecated; + } + + public function enterNode(Node $node): ?int + { + if (!$node instanceof Class_ && + !$node instanceof Trait_ && + !$node instanceof ClassMethod && + !$node instanceof Function_) { + return null; + } + + if ($node instanceof Class_ && $node->isAnonymous()) { + return null; + } + + $docComment = $node->getDocComment(); + + if ($docComment === null) { + return null; + } + + if (strpos($docComment->getText(), '@codeCoverageIgnore') !== false) { + $this->ignoredLines = array_merge( + $this->ignoredLines, + range($node->getStartLine(), $node->getEndLine()) + ); + } + + if ($this->ignoreDeprecated && strpos($docComment->getText(), '@deprecated') !== false) { + $this->ignoredLines = array_merge( + $this->ignoredLines, + range($node->getStartLine(), $node->getEndLine()) + ); + } + + if ($node instanceof ClassMethod || $node instanceof Function_) { + return NodeTraverser::DONT_TRAVERSE_CHILDREN; + } + + return null; + } + + /** + * @psalm-return list + */ + public function ignoredLines(): array + { + return $this->ignoredLines; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index d759250b8..1b0e867f2 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1556,28 +1556,6 @@ protected function setUpXdebugStubForClassWithAnonymousFunction(): Driver return $stub; } - protected function getCoverageForCrashParsing(): CodeCoverage - { - $filter = new Filter; - $filter->includeFile(TEST_FILES_PATH . 'Crash.php'); - - // This is a file with invalid syntax, so it isn't executed. - return new CodeCoverage( - $this->setUpXdebugStubForCrashParsing(), - $filter - ); - } - - protected function setUpXdebugStubForCrashParsing(): Driver - { - $stub = $this->createStub(Driver::class); - - $stub->method('stop') - ->willReturn(RawCodeCoverageData::fromXdebugWithoutPathCoverage([])); - - return $stub; - } - protected function removeTemporaryFiles(): void { $tmpFilesIterator = new RecursiveIteratorIterator( diff --git a/tests/_files/Crash.php b/tests/_files/Crash.php deleted file mode 100644 index 0e91b42b0..000000000 --- a/tests/_files/Crash.php +++ /dev/null @@ -1,2 +0,0 @@ -assertEquals([], $root->functions()); } - public function testNotCrashParsing(): void - { - $coverage = $this->getCoverageForCrashParsing(); - $root = $coverage->getReport(); - - $expectedPath = rtrim(TEST_FILES_PATH, DIRECTORY_SEPARATOR); - $this->assertEquals($expectedPath, $root->name()); - $this->assertEquals($expectedPath, $root->pathAsString()); - $this->assertEquals(1, $root->numberOfExecutableLines()); - $this->assertEquals(0, $root->numberOfExecutedLines()); - $data = $coverage->getData()->lineCoverage(); - $expectedFile = $expectedPath . DIRECTORY_SEPARATOR . 'Crash.php'; - $this->assertSame([$expectedFile => [1 => []]], $data); - } - public function testBuildDirectoryStructure(): void { $s = DIRECTORY_SEPARATOR; diff --git a/tests/tests/CodeCoverageTest.php b/tests/tests/CodeCoverageTest.php index 588c518ad..efc5db68b 100644 --- a/tests/tests/CodeCoverageTest.php +++ b/tests/tests/CodeCoverageTest.php @@ -9,7 +9,6 @@ */ namespace SebastianBergmann\CodeCoverage; -use ReflectionMethod; use SebastianBergmann\CodeCoverage\Driver\Driver; use SebastianBergmann\Environment\Runtime; @@ -129,101 +128,4 @@ public function testMerge2(): void $coverage->getData()->lineCoverage() ); } - - public function testGetLinesToBeIgnored(): void - { - $this->assertEquals( - [ - 3, - 4, - 5, - 11, - 12, - 13, - 14, - 15, - 16, - 18, - 23, - 24, - 25, - 30, - 33, - ], - $this->getLinesToBeIgnored()->invoke( - $this->coverage, - TEST_FILES_PATH . 'source_with_ignore.php' - ) - ); - } - - public function testGetLinesToBeIgnored2(): void - { - $this->assertEquals( - [], - $this->getLinesToBeIgnored()->invoke( - $this->coverage, - TEST_FILES_PATH . 'source_without_ignore.php' - ) - ); - } - - public function testGetLinesToBeIgnored3(): void - { - $this->assertEquals( - [ - 3, - ], - $this->getLinesToBeIgnored()->invoke( - $this->coverage, - TEST_FILES_PATH . 'source_with_class_and_anonymous_function.php' - ) - ); - } - - public function testGetLinesToBeIgnoredOneLineAnnotations(): void - { - $this->assertEquals( - [ - 4, - 9, - 29, - 31, - 32, - 33, - ], - $this->getLinesToBeIgnored()->invoke( - $this->coverage, - TEST_FILES_PATH . 'source_with_oneline_annotations.php' - ) - ); - } - - public function testGetLinesToBeIgnoredWhenIgnoreIsDisabled(): void - { - $this->coverage->disableAnnotationsForIgnoringCode(); - - $this->assertEquals( - [], - $this->getLinesToBeIgnored()->invoke( - $this->coverage, - TEST_FILES_PATH . 'source_with_ignore.php' - ) - ); - } - - /** - * @return ReflectionMethod - */ - private function getLinesToBeIgnored() - { - $getLinesToBeIgnored = new ReflectionMethod( - 'SebastianBergmann\CodeCoverage\CodeCoverage', - 'getLinesToBeIgnored' - ); - - $getLinesToBeIgnored->setAccessible(true); - - return $getLinesToBeIgnored; - } } diff --git a/tests/tests/FilterTest.php b/tests/tests/FilterTest.php index abbd9291f..a28b5133e 100644 --- a/tests/tests/FilterTest.php +++ b/tests/tests/FilterTest.php @@ -56,7 +56,6 @@ protected function setUp(): void TEST_FILES_PATH . 'CoverageTwoDefaultClassAnnotations.php', TEST_FILES_PATH . 'CoveredClass.php', TEST_FILES_PATH . 'CoveredFunction.php', - TEST_FILES_PATH . 'Crash.php', TEST_FILES_PATH . 'NamespaceCoverageClassExtendedTest.php', TEST_FILES_PATH . 'NamespaceCoverageClassTest.php', TEST_FILES_PATH . 'NamespaceCoverageCoversClassPublicTest.php', diff --git a/tests/tests/RawCodeCoverageDataTest.php b/tests/tests/RawCodeCoverageDataTest.php index 141f0386b..8e51b393b 100644 --- a/tests/tests/RawCodeCoverageDataTest.php +++ b/tests/tests/RawCodeCoverageDataTest.php @@ -10,7 +10,6 @@ namespace SebastianBergmann\CodeCoverage; use function array_keys; -use PHP_Token_Stream; final class RawCodeCoverageDataTest extends TestCase { @@ -264,72 +263,50 @@ public function testRemoveCoverageDataForLines(): void public function testUseStatementsAreUncovered(): void { - $file = TEST_FILES_PATH . 'source_with_use_statements.php'; - $tokens = new PHP_Token_Stream($file); + $file = TEST_FILES_PATH . 'source_with_use_statements.php'; $this->assertEquals( [ - 11, 12, 14, - 15, - 17, 18, - 19, - 20, - 21, - 22, ], - array_keys(RawCodeCoverageData::fromUncoveredFile($file, $tokens)->lineCoverage()[$file]) + array_keys(RawCodeCoverageData::fromUncoveredFile($file)->lineCoverage()[$file]) ); } public function testEmptyClassesAreUncovered(): void { - $file = TEST_FILES_PATH . 'source_with_empty_class.php'; - $tokens = new PHP_Token_Stream($file); + $file = TEST_FILES_PATH . 'source_with_empty_class.php'; $this->assertEquals( [ 12, - 14, ], - array_keys(RawCodeCoverageData::fromUncoveredFile($file, $tokens)->lineCoverage()[$file]) + array_keys(RawCodeCoverageData::fromUncoveredFile($file)->lineCoverage()[$file]) ); } public function testInterfacesAreUncovered(): void { - $file = TEST_FILES_PATH . 'source_with_interface.php'; - $tokens = new PHP_Token_Stream($file); + $file = TEST_FILES_PATH . 'source_with_interface.php'; $this->assertEquals( [ - 6, 7, 9, - 10, - 12, 13, - 14, - 15, - 16, - 17, ], - array_keys(RawCodeCoverageData::fromUncoveredFile($file, $tokens)->lineCoverage()[$file]) + array_keys(RawCodeCoverageData::fromUncoveredFile($file)->lineCoverage()[$file]) ); } public function testInlineCommentsKeepTheLine(): void { - $file = TEST_FILES_PATH . 'source_with_oneline_annotations.php'; - $tokens = new PHP_Token_Stream($file); + $file = TEST_FILES_PATH . 'source_with_oneline_annotations.php'; $this->assertEquals( [ - 12, - 13, - 17, 19, 22, 26, @@ -338,9 +315,8 @@ public function testInlineCommentsKeepTheLine(): void 32, 33, 35, - 36, ], - array_keys(RawCodeCoverageData::fromUncoveredFile($file, $tokens)->lineCoverage()[$file]) + array_keys(RawCodeCoverageData::fromUncoveredFile($file)->lineCoverage()[$file]) ); } diff --git a/tests/tests/StaticAnalysis/IgnoredLinesFinderTest.php b/tests/tests/StaticAnalysis/IgnoredLinesFinderTest.php new file mode 100644 index 000000000..170feef2c --- /dev/null +++ b/tests/tests/StaticAnalysis/IgnoredLinesFinderTest.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\StaticAnalysis; + +use PHPUnit\Framework\TestCase; + +/** + * @covers \SebastianBergmann\CodeCoverage\StaticAnalysis\IgnoredLinesFinder + */ +final class IgnoredLinesFinderTest extends TestCase +{ + public function testGetLinesToBeIgnored(): void + { + $this->assertEquals( + [ + 3, + 4, + 5, + 11, + 12, + 13, + 14, + 15, + 16, + 18, + 23, + 24, + 25, + 30, + 33, + ], + (new IgnoredLinesFinder)->findIgnoredLinesInFile( + TEST_FILES_PATH . 'source_with_ignore.php', + true, + true, + ) + ); + } + + public function testGetLinesToBeIgnored2(): void + { + $this->assertEquals( + [], + (new IgnoredLinesFinder)->findIgnoredLinesInFile( + TEST_FILES_PATH . 'source_without_ignore.php', + true, + true, + ) + ); + } + + public function testGetLinesToBeIgnored3(): void + { + $this->assertEquals( + [ + 3, + ], + (new IgnoredLinesFinder)->findIgnoredLinesInFile( + TEST_FILES_PATH . 'source_with_class_and_anonymous_function.php', + true, + true, + ) + ); + } + + public function testGetLinesToBeIgnoredOneLineAnnotations(): void + { + $this->assertEquals( + [ + 4, + 9, + 29, + 31, + 32, + 33, + ], + (new IgnoredLinesFinder)->findIgnoredLinesInFile( + TEST_FILES_PATH . 'source_with_oneline_annotations.php', + true, + true, + ) + ); + } + + public function testGetLinesToBeIgnoredWhenIgnoreIsDisabled(): void + { + $this->assertEquals( + [ + 11, + 18, + 33, + ], + (new IgnoredLinesFinder)->findIgnoredLinesInFile( + TEST_FILES_PATH . 'source_with_ignore.php', + false, + false, + ) + ); + } +}