717 lines
18 KiB
PHP
717 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/*
|
|
* This file is part of phpunit/php-code-coverage.
|
|
*
|
|
* (c) Sebastian Bergmann <sebastian@phpunit.de>
|
|
*
|
|
* For the full copyright and license information, please view the LICENSE
|
|
* file that was distributed with this source code.
|
|
*/
|
|
|
|
namespace SebastianBergmann\CodeCoverage;
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use PHPUnit\Runner\PhptTestCase;
|
|
use PHPUnit\Util\Test;
|
|
use ReflectionClass;
|
|
use SebastianBergmann\CodeCoverage\Driver\Driver;
|
|
use SebastianBergmann\CodeCoverage\Node\Builder;
|
|
use SebastianBergmann\CodeCoverage\Node\Directory;
|
|
use SebastianBergmann\CodeCoverage\StaticAnalysis\CachingFileAnalyser;
|
|
use SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser;
|
|
use SebastianBergmann\CodeCoverage\StaticAnalysis\ParsingFileAnalyser;
|
|
use SebastianBergmann\CodeUnitReverseLookup\Wizard;
|
|
|
|
use function array_diff;
|
|
use function array_diff_key;
|
|
use function array_flip;
|
|
use function array_keys;
|
|
use function array_merge;
|
|
use function array_unique;
|
|
use function array_values;
|
|
use function count;
|
|
use function explode;
|
|
use function get_class;
|
|
use function is_array;
|
|
use function sort;
|
|
|
|
/**
|
|
* Provides collection functionality for PHP code coverage information.
|
|
*/
|
|
final class CodeCoverage
|
|
{
|
|
private const UNCOVERED_FILES = 'UNCOVERED_FILES';
|
|
|
|
/**
|
|
* @var Driver
|
|
*/
|
|
private $driver;
|
|
|
|
/**
|
|
* @var Filter
|
|
*/
|
|
private $filter;
|
|
|
|
/**
|
|
* @var Wizard
|
|
*/
|
|
private $wizard;
|
|
|
|
/**
|
|
* @var bool
|
|
*/
|
|
private $checkForUnintentionallyCoveredCode = false;
|
|
|
|
/**
|
|
* @var bool
|
|
*/
|
|
private $includeUncoveredFiles = true;
|
|
|
|
/**
|
|
* @var bool
|
|
*/
|
|
private $processUncoveredFiles = false;
|
|
|
|
/**
|
|
* @var bool
|
|
*/
|
|
private $ignoreDeprecatedCode = false;
|
|
|
|
/**
|
|
* @var null|PhptTestCase|string|TestCase
|
|
*/
|
|
private $currentId;
|
|
|
|
/**
|
|
* Code coverage data.
|
|
*
|
|
* @var ProcessedCodeCoverageData
|
|
*/
|
|
private $data;
|
|
|
|
/**
|
|
* @var bool
|
|
*/
|
|
private $useAnnotationsForIgnoringCode = true;
|
|
|
|
/**
|
|
* Test data.
|
|
*
|
|
* @var array
|
|
*/
|
|
private $tests = [];
|
|
|
|
/**
|
|
* @psalm-var list<class-string>
|
|
*/
|
|
private $parentClassesExcludedFromUnintentionallyCoveredCodeCheck = [];
|
|
|
|
/**
|
|
* @var ?FileAnalyser
|
|
*/
|
|
private $analyser;
|
|
|
|
/**
|
|
* @var ?string
|
|
*/
|
|
private $cacheDirectory;
|
|
|
|
/**
|
|
* @var ?Directory
|
|
*/
|
|
private $cachedReport;
|
|
|
|
public function __construct(Driver $driver, Filter $filter)
|
|
{
|
|
$this->driver = $driver;
|
|
$this->filter = $filter;
|
|
$this->data = new ProcessedCodeCoverageData();
|
|
$this->wizard = new Wizard();
|
|
}
|
|
|
|
/**
|
|
* Returns the code coverage information as a graph of node objects.
|
|
*/
|
|
public function getReport(): Directory
|
|
{
|
|
if ($this->cachedReport === null) {
|
|
$this->cachedReport = (new Builder($this->analyser()))->build($this);
|
|
}
|
|
|
|
return $this->cachedReport;
|
|
}
|
|
|
|
/**
|
|
* Clears collected code coverage data.
|
|
*/
|
|
public function clear(): void
|
|
{
|
|
$this->currentId = null;
|
|
$this->data = new ProcessedCodeCoverageData();
|
|
$this->tests = [];
|
|
$this->cachedReport = null;
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
public function clearCache(): void
|
|
{
|
|
$this->cachedReport = null;
|
|
}
|
|
|
|
/**
|
|
* Returns the filter object used.
|
|
*/
|
|
public function filter(): Filter
|
|
{
|
|
return $this->filter;
|
|
}
|
|
|
|
/**
|
|
* Returns the collected code coverage data.
|
|
*/
|
|
public function getData(bool $raw = false): ProcessedCodeCoverageData
|
|
{
|
|
if (!$raw) {
|
|
if ($this->processUncoveredFiles) {
|
|
$this->processUncoveredFilesFromFilter();
|
|
} elseif ($this->includeUncoveredFiles) {
|
|
$this->addUncoveredFilesFromFilter();
|
|
}
|
|
}
|
|
|
|
return $this->data;
|
|
}
|
|
|
|
/**
|
|
* Sets the coverage data.
|
|
*/
|
|
public function setData(ProcessedCodeCoverageData $data): void
|
|
{
|
|
$this->data = $data;
|
|
}
|
|
|
|
/**
|
|
* Returns the test data.
|
|
*/
|
|
public function getTests(): array
|
|
{
|
|
return $this->tests;
|
|
}
|
|
|
|
/**
|
|
* Sets the test data.
|
|
*/
|
|
public function setTests(array $tests): void
|
|
{
|
|
$this->tests = $tests;
|
|
}
|
|
|
|
/**
|
|
* Start collection of code coverage information.
|
|
*
|
|
* @param PhptTestCase|string|TestCase $id
|
|
*/
|
|
public function start($id, bool $clear = false): void
|
|
{
|
|
if ($clear) {
|
|
$this->clear();
|
|
}
|
|
|
|
$this->currentId = $id;
|
|
|
|
$this->driver->start();
|
|
|
|
$this->cachedReport = null;
|
|
}
|
|
|
|
/**
|
|
* Stop collection of code coverage information.
|
|
*
|
|
* @param array|false $linesToBeCovered
|
|
*/
|
|
public function stop(bool $append = true, $linesToBeCovered = [], array $linesToBeUsed = []): RawCodeCoverageData
|
|
{
|
|
if (!is_array($linesToBeCovered) && $linesToBeCovered !== false) {
|
|
throw new InvalidArgumentException(
|
|
'$linesToBeCovered must be an array or false'
|
|
);
|
|
}
|
|
|
|
$data = $this->driver->stop();
|
|
$this->append($data, null, $append, $linesToBeCovered, $linesToBeUsed);
|
|
|
|
$this->currentId = null;
|
|
$this->cachedReport = null;
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Appends code coverage data.
|
|
*
|
|
* @param PhptTestCase|string|TestCase $id
|
|
* @param array|false $linesToBeCovered
|
|
*
|
|
* @throws ReflectionException
|
|
* @throws TestIdMissingException
|
|
* @throws UnintentionallyCoveredCodeException
|
|
*/
|
|
public function append(RawCodeCoverageData $rawData, $id = null, bool $append = true, $linesToBeCovered = [], array $linesToBeUsed = []): void
|
|
{
|
|
if ($id === null) {
|
|
$id = $this->currentId;
|
|
}
|
|
|
|
if ($id === null) {
|
|
throw new TestIdMissingException();
|
|
}
|
|
|
|
$this->cachedReport = null;
|
|
|
|
$this->applyFilter($rawData);
|
|
|
|
$this->applyExecutableLinesFilter($rawData);
|
|
|
|
if ($this->useAnnotationsForIgnoringCode) {
|
|
$this->applyIgnoredLinesFilter($rawData);
|
|
}
|
|
|
|
$this->data->initializeUnseenData($rawData);
|
|
|
|
if (!$append) {
|
|
return;
|
|
}
|
|
|
|
if ($id !== self::UNCOVERED_FILES) {
|
|
$this->applyCoversAnnotationFilter(
|
|
$rawData,
|
|
$linesToBeCovered,
|
|
$linesToBeUsed
|
|
);
|
|
|
|
if (empty($rawData->lineCoverage())) {
|
|
return;
|
|
}
|
|
|
|
$size = 'unknown';
|
|
$status = -1;
|
|
$fromTestcase = false;
|
|
|
|
if ($id instanceof TestCase) {
|
|
$fromTestcase = true;
|
|
$_size = $id->getSize();
|
|
|
|
if ($_size === Test::SMALL) {
|
|
$size = 'small';
|
|
} elseif ($_size === Test::MEDIUM) {
|
|
$size = 'medium';
|
|
} elseif ($_size === Test::LARGE) {
|
|
$size = 'large';
|
|
}
|
|
|
|
$status = $id->getStatus();
|
|
$id = get_class($id) . '::' . $id->getName();
|
|
} elseif ($id instanceof PhptTestCase) {
|
|
$fromTestcase = true;
|
|
$size = 'large';
|
|
$id = $id->getName();
|
|
}
|
|
|
|
$this->tests[$id] = ['size' => $size, 'status' => $status, 'fromTestcase' => $fromTestcase];
|
|
|
|
$this->data->markCodeAsExecutedByTestCase($id, $rawData);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Merges the data from another instance.
|
|
*/
|
|
public function merge(self $that): void
|
|
{
|
|
$this->filter->includeFiles(
|
|
$that->filter()->files()
|
|
);
|
|
|
|
$this->data->merge($that->data);
|
|
|
|
$this->tests = array_merge($this->tests, $that->getTests());
|
|
|
|
$this->cachedReport = null;
|
|
}
|
|
|
|
public function enableCheckForUnintentionallyCoveredCode(): void
|
|
{
|
|
$this->checkForUnintentionallyCoveredCode = true;
|
|
}
|
|
|
|
public function disableCheckForUnintentionallyCoveredCode(): void
|
|
{
|
|
$this->checkForUnintentionallyCoveredCode = false;
|
|
}
|
|
|
|
public function includeUncoveredFiles(): void
|
|
{
|
|
$this->includeUncoveredFiles = true;
|
|
}
|
|
|
|
public function excludeUncoveredFiles(): void
|
|
{
|
|
$this->includeUncoveredFiles = false;
|
|
}
|
|
|
|
public function processUncoveredFiles(): void
|
|
{
|
|
$this->processUncoveredFiles = true;
|
|
}
|
|
|
|
public function doNotProcessUncoveredFiles(): void
|
|
{
|
|
$this->processUncoveredFiles = false;
|
|
}
|
|
|
|
public function enableAnnotationsForIgnoringCode(): void
|
|
{
|
|
$this->useAnnotationsForIgnoringCode = true;
|
|
}
|
|
|
|
public function disableAnnotationsForIgnoringCode(): void
|
|
{
|
|
$this->useAnnotationsForIgnoringCode = false;
|
|
}
|
|
|
|
public function ignoreDeprecatedCode(): void
|
|
{
|
|
$this->ignoreDeprecatedCode = true;
|
|
}
|
|
|
|
public function doNotIgnoreDeprecatedCode(): void
|
|
{
|
|
$this->ignoreDeprecatedCode = false;
|
|
}
|
|
|
|
/**
|
|
* @psalm-assert-if-true !null $this->cacheDirectory
|
|
*/
|
|
public function cachesStaticAnalysis(): bool
|
|
{
|
|
return $this->cacheDirectory !== null;
|
|
}
|
|
|
|
public function cacheStaticAnalysis(string $directory): void
|
|
{
|
|
$this->cacheDirectory = $directory;
|
|
}
|
|
|
|
public function doNotCacheStaticAnalysis(): void
|
|
{
|
|
$this->cacheDirectory = null;
|
|
}
|
|
|
|
/**
|
|
* @throws StaticAnalysisCacheNotConfiguredException
|
|
*/
|
|
public function cacheDirectory(): string
|
|
{
|
|
if (!$this->cachesStaticAnalysis()) {
|
|
throw new StaticAnalysisCacheNotConfiguredException(
|
|
'The static analysis cache is not configured'
|
|
);
|
|
}
|
|
|
|
return $this->cacheDirectory;
|
|
}
|
|
|
|
/**
|
|
* @psalm-param class-string $className
|
|
*/
|
|
public function excludeSubclassesOfThisClassFromUnintentionallyCoveredCodeCheck(string $className): void
|
|
{
|
|
$this->parentClassesExcludedFromUnintentionallyCoveredCodeCheck[] = $className;
|
|
}
|
|
|
|
public function enableBranchAndPathCoverage(): void
|
|
{
|
|
$this->driver->enableBranchAndPathCoverage();
|
|
}
|
|
|
|
public function disableBranchAndPathCoverage(): void
|
|
{
|
|
$this->driver->disableBranchAndPathCoverage();
|
|
}
|
|
|
|
public function collectsBranchAndPathCoverage(): bool
|
|
{
|
|
return $this->driver->collectsBranchAndPathCoverage();
|
|
}
|
|
|
|
public function detectsDeadCode(): bool
|
|
{
|
|
return $this->driver->detectsDeadCode();
|
|
}
|
|
|
|
/**
|
|
* Applies the @covers annotation filtering.
|
|
*
|
|
* @param array|false $linesToBeCovered
|
|
*
|
|
* @throws ReflectionException
|
|
* @throws UnintentionallyCoveredCodeException
|
|
*/
|
|
private function applyCoversAnnotationFilter(RawCodeCoverageData $rawData, $linesToBeCovered, array $linesToBeUsed): void
|
|
{
|
|
if ($linesToBeCovered === false) {
|
|
$rawData->clear();
|
|
|
|
return;
|
|
}
|
|
|
|
if (empty($linesToBeCovered)) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
$this->checkForUnintentionallyCoveredCode &&
|
|
(!$this->currentId instanceof TestCase ||
|
|
(!$this->currentId->isMedium() && !$this->currentId->isLarge()))
|
|
) {
|
|
$this->performUnintentionallyCoveredCodeCheck($rawData, $linesToBeCovered, $linesToBeUsed);
|
|
}
|
|
|
|
$rawLineData = $rawData->lineCoverage();
|
|
$filesWithNoCoverage = array_diff_key($rawLineData, $linesToBeCovered);
|
|
|
|
foreach (array_keys($filesWithNoCoverage) as $fileWithNoCoverage) {
|
|
$rawData->removeCoverageDataForFile($fileWithNoCoverage);
|
|
}
|
|
|
|
if (is_array($linesToBeCovered)) {
|
|
foreach ($linesToBeCovered as $fileToBeCovered => $includedLines) {
|
|
$rawData->keepLineCoverageDataOnlyForLines($fileToBeCovered, $includedLines);
|
|
$rawData->keepFunctionCoverageDataOnlyForLines($fileToBeCovered, $includedLines);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function applyFilter(RawCodeCoverageData $data): void
|
|
{
|
|
if ($this->filter->isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
foreach (array_keys($data->lineCoverage()) as $filename) {
|
|
if ($this->filter->isExcluded($filename)) {
|
|
$data->removeCoverageDataForFile($filename);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function applyExecutableLinesFilter(RawCodeCoverageData $data): void
|
|
{
|
|
foreach (array_keys($data->lineCoverage()) as $filename) {
|
|
if (!$this->filter->isFile($filename)) {
|
|
continue;
|
|
}
|
|
|
|
$linesToBranchMap = $this->analyser()->executableLinesIn($filename);
|
|
|
|
$data->keepLineCoverageDataOnlyForLines(
|
|
$filename,
|
|
array_keys($linesToBranchMap)
|
|
);
|
|
|
|
$data->markExecutableLineByBranch(
|
|
$filename,
|
|
$linesToBranchMap
|
|
);
|
|
}
|
|
}
|
|
|
|
private function applyIgnoredLinesFilter(RawCodeCoverageData $data): void
|
|
{
|
|
foreach (array_keys($data->lineCoverage()) as $filename) {
|
|
if (!$this->filter->isFile($filename)) {
|
|
continue;
|
|
}
|
|
|
|
$data->removeCoverageDataForLines(
|
|
$filename,
|
|
$this->analyser()->ignoredLinesFor($filename)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws UnintentionallyCoveredCodeException
|
|
*/
|
|
private function addUncoveredFilesFromFilter(): void
|
|
{
|
|
$uncoveredFiles = array_diff(
|
|
$this->filter->files(),
|
|
$this->data->coveredFiles()
|
|
);
|
|
|
|
foreach ($uncoveredFiles as $uncoveredFile) {
|
|
if ($this->filter->isFile($uncoveredFile)) {
|
|
$this->append(
|
|
RawCodeCoverageData::fromUncoveredFile(
|
|
$uncoveredFile,
|
|
$this->analyser()
|
|
),
|
|
self::UNCOVERED_FILES
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws UnintentionallyCoveredCodeException
|
|
*/
|
|
private function processUncoveredFilesFromFilter(): void
|
|
{
|
|
$uncoveredFiles = array_diff(
|
|
$this->filter->files(),
|
|
$this->data->coveredFiles()
|
|
);
|
|
|
|
$this->driver->start();
|
|
|
|
foreach ($uncoveredFiles as $uncoveredFile) {
|
|
if ($this->filter->isFile($uncoveredFile)) {
|
|
include_once $uncoveredFile;
|
|
}
|
|
}
|
|
|
|
$this->append($this->driver->stop(), self::UNCOVERED_FILES);
|
|
}
|
|
|
|
/**
|
|
* @throws ReflectionException
|
|
* @throws UnintentionallyCoveredCodeException
|
|
*/
|
|
private function performUnintentionallyCoveredCodeCheck(RawCodeCoverageData $data, array $linesToBeCovered, array $linesToBeUsed): void
|
|
{
|
|
$allowedLines = $this->getAllowedLines(
|
|
$linesToBeCovered,
|
|
$linesToBeUsed
|
|
);
|
|
|
|
$unintentionallyCoveredUnits = [];
|
|
|
|
foreach ($data->lineCoverage() as $file => $_data) {
|
|
foreach ($_data as $line => $flag) {
|
|
if ($flag === 1 && !isset($allowedLines[$file][$line])) {
|
|
$unintentionallyCoveredUnits[] = $this->wizard->lookup($file, $line);
|
|
}
|
|
}
|
|
}
|
|
|
|
$unintentionallyCoveredUnits = $this->processUnintentionallyCoveredUnits($unintentionallyCoveredUnits);
|
|
|
|
if (!empty($unintentionallyCoveredUnits)) {
|
|
throw new UnintentionallyCoveredCodeException(
|
|
$unintentionallyCoveredUnits
|
|
);
|
|
}
|
|
}
|
|
|
|
private function getAllowedLines(array $linesToBeCovered, array $linesToBeUsed): array
|
|
{
|
|
$allowedLines = [];
|
|
|
|
foreach (array_keys($linesToBeCovered) as $file) {
|
|
if (!isset($allowedLines[$file])) {
|
|
$allowedLines[$file] = [];
|
|
}
|
|
|
|
$allowedLines[$file] = array_merge(
|
|
$allowedLines[$file],
|
|
$linesToBeCovered[$file]
|
|
);
|
|
}
|
|
|
|
foreach (array_keys($linesToBeUsed) as $file) {
|
|
if (!isset($allowedLines[$file])) {
|
|
$allowedLines[$file] = [];
|
|
}
|
|
|
|
$allowedLines[$file] = array_merge(
|
|
$allowedLines[$file],
|
|
$linesToBeUsed[$file]
|
|
);
|
|
}
|
|
|
|
foreach (array_keys($allowedLines) as $file) {
|
|
$allowedLines[$file] = array_flip(
|
|
array_unique($allowedLines[$file])
|
|
);
|
|
}
|
|
|
|
return $allowedLines;
|
|
}
|
|
|
|
/**
|
|
* @throws ReflectionException
|
|
*/
|
|
private function processUnintentionallyCoveredUnits(array $unintentionallyCoveredUnits): array
|
|
{
|
|
$unintentionallyCoveredUnits = array_unique($unintentionallyCoveredUnits);
|
|
sort($unintentionallyCoveredUnits);
|
|
|
|
foreach (array_keys($unintentionallyCoveredUnits) as $k => $v) {
|
|
$unit = explode('::', $unintentionallyCoveredUnits[$k]);
|
|
|
|
if (count($unit) !== 2) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$class = new ReflectionClass($unit[0]);
|
|
|
|
foreach ($this->parentClassesExcludedFromUnintentionallyCoveredCodeCheck as $parentClass) {
|
|
if ($class->isSubclassOf($parentClass)) {
|
|
unset($unintentionallyCoveredUnits[$k]);
|
|
|
|
break;
|
|
}
|
|
}
|
|
} catch (\ReflectionException $e) {
|
|
throw new ReflectionException(
|
|
$e->getMessage(),
|
|
$e->getCode(),
|
|
$e
|
|
);
|
|
}
|
|
}
|
|
|
|
return array_values($unintentionallyCoveredUnits);
|
|
}
|
|
|
|
private function analyser(): FileAnalyser
|
|
{
|
|
if ($this->analyser !== null) {
|
|
return $this->analyser;
|
|
}
|
|
|
|
$this->analyser = new ParsingFileAnalyser(
|
|
$this->useAnnotationsForIgnoringCode,
|
|
$this->ignoreDeprecatedCode
|
|
);
|
|
|
|
if ($this->cachesStaticAnalysis()) {
|
|
$this->analyser = new CachingFileAnalyser(
|
|
$this->cacheDirectory,
|
|
$this->analyser,
|
|
$this->useAnnotationsForIgnoringCode,
|
|
$this->ignoreDeprecatedCode
|
|
);
|
|
}
|
|
|
|
return $this->analyser;
|
|
}
|
|
}
|