You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
632 lines
17 KiB
632 lines
17 KiB
<?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 function array_diff;
|
|
use function array_diff_key;
|
|
use function array_flip;
|
|
use function array_keys;
|
|
use function array_merge;
|
|
use function array_merge_recursive;
|
|
use function array_unique;
|
|
use function count;
|
|
use function explode;
|
|
use function is_array;
|
|
use function is_file;
|
|
use function sort;
|
|
use ReflectionClass;
|
|
use SebastianBergmann\CodeCoverage\Data\ProcessedCodeCoverageData;
|
|
use SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData;
|
|
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\CodeCoverage\Test\TestSize\TestSize;
|
|
use SebastianBergmann\CodeCoverage\Test\TestStatus\TestStatus;
|
|
use SebastianBergmann\CodeUnitReverseLookup\Wizard;
|
|
|
|
/**
|
|
* Provides collection functionality for PHP code coverage information.
|
|
*
|
|
* @psalm-type TestType = array{
|
|
* size: string,
|
|
* status: string,
|
|
* }
|
|
*/
|
|
final class CodeCoverage
|
|
{
|
|
private const UNCOVERED_FILES = 'UNCOVERED_FILES';
|
|
private readonly Driver $driver;
|
|
private readonly Filter $filter;
|
|
private readonly Wizard $wizard;
|
|
private bool $checkForUnintentionallyCoveredCode = false;
|
|
private bool $includeUncoveredFiles = true;
|
|
private bool $ignoreDeprecatedCode = false;
|
|
private ?string $currentId = null;
|
|
private ?TestSize $currentSize = null;
|
|
private ProcessedCodeCoverageData $data;
|
|
private bool $useAnnotationsForIgnoringCode = true;
|
|
|
|
/**
|
|
* @psalm-var array<string,list<int>>
|
|
*/
|
|
private array $linesToBeIgnored = [];
|
|
|
|
/**
|
|
* @psalm-var array<string, TestType>
|
|
*/
|
|
private array $tests = [];
|
|
|
|
/**
|
|
* @psalm-var list<class-string>
|
|
*/
|
|
private array $parentClassesExcludedFromUnintentionallyCoveredCodeCheck = [];
|
|
private ?FileAnalyser $analyser = null;
|
|
private ?string $cacheDirectory = null;
|
|
private ?Directory $cachedReport = null;
|
|
|
|
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->currentSize = 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->includeUncoveredFiles) {
|
|
$this->addUncoveredFilesFromFilter();
|
|
}
|
|
}
|
|
|
|
return $this->data;
|
|
}
|
|
|
|
/**
|
|
* Sets the coverage data.
|
|
*/
|
|
public function setData(ProcessedCodeCoverageData $data): void
|
|
{
|
|
$this->data = $data;
|
|
}
|
|
|
|
/**
|
|
* @psalm-return array<string, TestType>
|
|
*/
|
|
public function getTests(): array
|
|
{
|
|
return $this->tests;
|
|
}
|
|
|
|
/**
|
|
* @psalm-param array<string, TestType> $tests
|
|
*/
|
|
public function setTests(array $tests): void
|
|
{
|
|
$this->tests = $tests;
|
|
}
|
|
|
|
public function start(string $id, TestSize $size = null, bool $clear = false): void
|
|
{
|
|
if ($clear) {
|
|
$this->clear();
|
|
}
|
|
|
|
$this->currentId = $id;
|
|
$this->currentSize = $size;
|
|
|
|
$this->driver->start();
|
|
|
|
$this->cachedReport = null;
|
|
}
|
|
|
|
/**
|
|
* @psalm-param array<string,list<int>> $linesToBeIgnored
|
|
*/
|
|
public function stop(bool $append = true, TestStatus $status = null, array|false $linesToBeCovered = [], array $linesToBeUsed = [], array $linesToBeIgnored = []): RawCodeCoverageData
|
|
{
|
|
$data = $this->driver->stop();
|
|
|
|
$this->linesToBeIgnored = array_merge_recursive(
|
|
$this->linesToBeIgnored,
|
|
$linesToBeIgnored
|
|
);
|
|
|
|
$this->append($data, null, $append, $status, $linesToBeCovered, $linesToBeUsed, $linesToBeIgnored);
|
|
|
|
$this->currentId = null;
|
|
$this->currentSize = null;
|
|
$this->cachedReport = null;
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* @psalm-param array<string,list<int>> $linesToBeIgnored
|
|
*
|
|
* @throws ReflectionException
|
|
* @throws TestIdMissingException
|
|
* @throws UnintentionallyCoveredCodeException
|
|
*/
|
|
public function append(RawCodeCoverageData $rawData, string $id = null, bool $append = true, TestStatus $status = null, array|false $linesToBeCovered = [], array $linesToBeUsed = [], array $linesToBeIgnored = []): void
|
|
{
|
|
if ($id === null) {
|
|
$id = $this->currentId;
|
|
}
|
|
|
|
if ($id === null) {
|
|
throw new TestIdMissingException;
|
|
}
|
|
|
|
$this->cachedReport = null;
|
|
|
|
if ($status === null) {
|
|
$status = TestStatus::unknown();
|
|
}
|
|
|
|
$size = $this->currentSize;
|
|
|
|
if ($size === null) {
|
|
$size = TestSize::unknown();
|
|
}
|
|
|
|
$this->applyFilter($rawData);
|
|
|
|
$this->applyExecutableLinesFilter($rawData);
|
|
|
|
if ($this->useAnnotationsForIgnoringCode) {
|
|
$this->applyIgnoredLinesFilter($rawData, $linesToBeIgnored);
|
|
}
|
|
|
|
$this->data->initializeUnseenData($rawData);
|
|
|
|
if (!$append) {
|
|
return;
|
|
}
|
|
|
|
if ($id === self::UNCOVERED_FILES) {
|
|
return;
|
|
}
|
|
|
|
$this->applyCoversAndUsesFilter(
|
|
$rawData,
|
|
$linesToBeCovered,
|
|
$linesToBeUsed,
|
|
$size
|
|
);
|
|
|
|
if (empty($rawData->lineCoverage())) {
|
|
return;
|
|
}
|
|
|
|
$this->tests[$id] = [
|
|
'size' => $size->asString(),
|
|
'status' => $status->asString(),
|
|
];
|
|
|
|
$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 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();
|
|
}
|
|
|
|
/**
|
|
* @throws ReflectionException
|
|
* @throws UnintentionallyCoveredCodeException
|
|
*/
|
|
private function applyCoversAndUsesFilter(RawCodeCoverageData $rawData, array|false $linesToBeCovered, array $linesToBeUsed, TestSize $size): void
|
|
{
|
|
if ($linesToBeCovered === false) {
|
|
$rawData->clear();
|
|
|
|
return;
|
|
}
|
|
|
|
if (empty($linesToBeCovered)) {
|
|
return;
|
|
}
|
|
|
|
if ($this->checkForUnintentionallyCoveredCode && !$size->isMedium() && !$size->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
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @psalm-param array<string,list<int>> $linesToBeIgnored
|
|
*/
|
|
private function applyIgnoredLinesFilter(RawCodeCoverageData $data, array $linesToBeIgnored): void
|
|
{
|
|
foreach (array_keys($data->lineCoverage()) as $filename) {
|
|
if (!$this->filter->isFile($filename)) {
|
|
continue;
|
|
}
|
|
|
|
if (isset($linesToBeIgnored[$filename])) {
|
|
$data->removeCoverageDataForLines(
|
|
$filename,
|
|
$linesToBeIgnored[$filename]
|
|
);
|
|
}
|
|
|
|
$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 (is_file($uncoveredFile)) {
|
|
$this->append(
|
|
RawCodeCoverageData::fromUncoveredFile(
|
|
$uncoveredFile,
|
|
$this->analyser()
|
|
),
|
|
self::UNCOVERED_FILES,
|
|
linesToBeIgnored: $this->linesToBeIgnored
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $unintentionallyCoveredUnits
|
|
*
|
|
* @throws ReflectionException
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
private function processUnintentionallyCoveredUnits(array $unintentionallyCoveredUnits): array
|
|
{
|
|
$unintentionallyCoveredUnits = array_unique($unintentionallyCoveredUnits);
|
|
$processed = [];
|
|
|
|
foreach ($unintentionallyCoveredUnits as $unintentionallyCoveredUnit) {
|
|
$tmp = explode('::', $unintentionallyCoveredUnit);
|
|
|
|
if (count($tmp) !== 2) {
|
|
$processed[] = $unintentionallyCoveredUnit;
|
|
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$class = new ReflectionClass($tmp[0]);
|
|
|
|
foreach ($this->parentClassesExcludedFromUnintentionallyCoveredCodeCheck as $parentClass) {
|
|
if ($class->isSubclassOf($parentClass)) {
|
|
continue 2;
|
|
}
|
|
}
|
|
} catch (\ReflectionException $e) {
|
|
throw new ReflectionException(
|
|
$e->getMessage(),
|
|
$e->getCode(),
|
|
$e
|
|
);
|
|
}
|
|
|
|
$processed[] = $tmp[0];
|
|
}
|
|
|
|
$processed = array_unique($processed);
|
|
|
|
sort($processed);
|
|
|
|
return $processed;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|