diff --git a/src/CodeCoverage.php b/src/CodeCoverage.php index b732521f0..628593f5f 100644 --- a/src/CodeCoverage.php +++ b/src/CodeCoverage.php @@ -496,27 +496,22 @@ private function applyIgnoredLinesFilter(RawCodeCoverageData $data): void */ private function addUncoveredFilesFromWhitelist(): void { - $data = []; $uncoveredFiles = \array_diff( $this->filter->getWhitelist(), \array_keys($this->data->getLineCoverage()) ); foreach ($uncoveredFiles as $uncoveredFile) { - if (!\file_exists($uncoveredFile)) { - continue; - } - - $data[$uncoveredFile] = []; - - $lines = \count(\file($uncoveredFile)); + if (\file_exists($uncoveredFile)) { + if ($this->cacheTokens) { + $tokens = \PHP_Token_Stream_CachingFactory::get($uncoveredFile); + } else { + $tokens = new \PHP_Token_Stream($uncoveredFile); + } - for ($i = 1; $i <= $lines; $i++) { - $data[$uncoveredFile][$i] = Driver::LINE_NOT_EXECUTED; + $this->append(RawCodeCoverageData::fromUncoveredFile($uncoveredFile, $tokens), self::UNCOVERED_FILES_FROM_WHITELIST); } } - - $this->append(RawCodeCoverageData::fromXdebugWithoutPathCoverage($data), self::UNCOVERED_FILES_FROM_WHITELIST); } private function getLinesToBeIgnored(string $fileName): array @@ -540,60 +535,12 @@ private function getLinesToBeIgnoredInner(string $fileName): array $lines = \file($fileName); - foreach ($lines as $index => $line) { - if (!\trim($line)) { - $this->ignoredLines[$fileName][] = $index + 1; - } - } - if ($this->cacheTokens) { $tokens = \PHP_Token_Stream_CachingFactory::get($fileName); } else { $tokens = new \PHP_Token_Stream($fileName); } - foreach ($tokens->getInterfaces() as $interface) { - $interfaceStartLine = $interface['startLine']; - $interfaceEndLine = $interface['endLine']; - - foreach (\range($interfaceStartLine, $interfaceEndLine) as $line) { - $this->ignoredLines[$fileName][] = $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) { - $this->ignoredLines[$fileName][] = $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) { - $this->ignoredLines[$fileName][] = $line; - } - - foreach (\range($lastMethodEndLine + 1, $classOrTraitEndLine) as $line) { - $this->ignoredLines[$fileName][] = $line; - } - } - if ($this->disableIgnoredLines) { $this->ignoredLines[$fileName] = \array_unique($this->ignoredLines[$fileName]); \sort($this->ignoredLines[$fileName]); @@ -623,29 +570,10 @@ private function getLinesToBeIgnoredInner(string $fileName): array $stop = true; } - if (!$ignore) { - $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++) { - $this->ignoredLines[$fileName][] = $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)) { - $this->ignoredLines[$fileName][] = $i; - } - } - break; + // Intentional fallthrough + case \PHP_Token_INTERFACE::class: case \PHP_Token_TRAIT::class: case \PHP_Token_CLASS::class: @@ -654,8 +582,6 @@ private function getLinesToBeIgnoredInner(string $fileName): array $docblock = (string) $token->getDocblock(); - $this->ignoredLines[$fileName][] = $token->getLine(); - if (\strpos($docblock, '@codeCoverageIgnore') || ($this->ignoreDeprecatedCode && \strpos($docblock, '@deprecated'))) { $endLine = $token->getEndLine(); @@ -664,20 +590,6 @@ private function getLinesToBeIgnoredInner(string $fileName): array } } - break; - - /* @noinspection PhpMissingBreakStatementInspection */ - case \PHP_Token_NAMESPACE::class: - $this->ignoredLines[$fileName][] = $token->getEndLine(); - - // Intentional fallthrough - 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: - $this->ignoredLines[$fileName][] = $token->getLine(); - break; } @@ -691,8 +603,6 @@ private function getLinesToBeIgnoredInner(string $fileName): array } } - $this->ignoredLines[$fileName][] = \count($lines) + 1; - $this->ignoredLines[$fileName] = \array_unique( $this->ignoredLines[$fileName] ); diff --git a/src/RawCodeCoverageData.php b/src/RawCodeCoverageData.php index ea6a6313c..dc67eb3cf 100644 --- a/src/RawCodeCoverageData.php +++ b/src/RawCodeCoverageData.php @@ -9,6 +9,9 @@ */ namespace SebastianBergmann\CodeCoverage; +use PHP_Token_Stream; +use SebastianBergmann\CodeCoverage\Driver\Driver; + /** * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage */ @@ -54,6 +57,123 @@ public static function fromXdebugWithPathCoverage(array $rawCoverage): self return new self($lineCoverage, $functionCoverage); } + public static function fromUncoveredFile(string $filename, PHP_Token_Stream $tokens): 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 + } + + return new self([$filename => $lineCoverage], []); + } + private function __construct(array $lineCoverage, array $functionCoverage) { $this->lineCoverage = $lineCoverage; diff --git a/tests/TestCase.php b/tests/TestCase.php index b5266f3b9..571d89ba5 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1530,7 +1530,6 @@ protected function setUpXdebugStubForFileWithIgnoredLines(): Driver 2 => 1, 4 => -1, 6 => -1, - 7 => 1, ], ] ) diff --git a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/dashboard.html b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/dashboard.html index 8b27809c7..c1246a182 100644 --- a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/dashboard.html +++ b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/dashboard.html @@ -57,7 +57,7 @@