fix:repo cleanup

This commit is contained in:
2025-11-08 20:09:07 +00:00
parent f1da93791d
commit dedcc73567
1204 changed files with 121800 additions and 16971 deletions
+122 -81
View File
@@ -14,11 +14,9 @@ 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;
@@ -27,59 +25,76 @@ 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\CachingSourceAnalyser;
use SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser;
use SebastianBergmann\CodeCoverage\StaticAnalysis\ParsingFileAnalyser;
use SebastianBergmann\CodeCoverage\StaticAnalysis\ParsingSourceAnalyser;
use SebastianBergmann\CodeCoverage\Test\Target\MapBuilder;
use SebastianBergmann\CodeCoverage\Test\Target\Mapper;
use SebastianBergmann\CodeCoverage\Test\Target\TargetCollection;
use SebastianBergmann\CodeCoverage\Test\Target\TargetCollectionValidator;
use SebastianBergmann\CodeCoverage\Test\Target\ValidationResult;
use SebastianBergmann\CodeCoverage\Test\TestSize\TestSize;
use SebastianBergmann\CodeCoverage\Test\TestStatus\TestStatus;
use SebastianBergmann\CodeUnitReverseLookup\Wizard;
/**
* Provides collection functionality for PHP code coverage information.
*
* @phpstan-type TestType = array{
* size: string,
* status: string,
* }
* @phpstan-type TestType array{size: string, status: string}
* @phpstan-type TargetedLines array<non-empty-string, list<positive-int>>
*/
final class CodeCoverage
{
private const UNCOVERED_FILES = 'UNCOVERED_FILES';
private const string UNCOVERED_FILES = 'UNCOVERED_FILES';
private readonly Driver $driver;
private readonly Filter $filter;
private readonly Wizard $wizard;
private ?FileAnalyser $analyser = null;
private ?Mapper $targetMapper = null;
private ?string $cacheDirectory = null;
private bool $checkForUnintentionallyCoveredCode = false;
private bool $collectBranchAndPathCoverage = false;
private bool $includeUncoveredFiles = true;
private bool $ignoreDeprecatedCode = false;
private ?string $currentId = null;
private ?TestSize $currentSize = null;
private ProcessedCodeCoverageData $data;
private bool $useAnnotationsForIgnoringCode = true;
/**
* @var array<string,list<int>>
*/
private array $linesToBeIgnored = [];
/**
* @var array<string, TestType>
*/
private array $tests = [];
private bool $useAnnotationsForIgnoringCode = true;
/**
* @var list<class-string>
*/
private array $parentClassesExcludedFromUnintentionallyCoveredCodeCheck = [];
private ?FileAnalyser $analyser = null;
private ?string $cacheDirectory = null;
private ?Directory $cachedReport = null;
private ?string $currentId = null;
private ?TestSize $currentSize = null;
private ProcessedCodeCoverageData $data;
/**
* @var array<string, TestType>
*/
private array $tests = [];
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;
}
public function __serialize(): array
{
$prefix = "\x00" . self::class . "\x00";
return [
// Configuration
$prefix . 'cacheDirectory' => $this->cacheDirectory,
$prefix . 'checkForUnintentionallyCoveredCode' => $this->checkForUnintentionallyCoveredCode,
$prefix . 'includeUncoveredFiles' => $this->includeUncoveredFiles,
$prefix . 'ignoreDeprecatedCode' => $this->ignoreDeprecatedCode,
$prefix . 'parentClassesExcludedFromUnintentionallyCoveredCodeCheck' => $this->parentClassesExcludedFromUnintentionallyCoveredCodeCheck,
$prefix . 'useAnnotationsForIgnoringCode' => $this->useAnnotationsForIgnoringCode,
$prefix . 'filter' => $this->filter,
// Data
$prefix . 'data' => $this->data,
$prefix . 'tests' => $this->tests,
];
}
/**
@@ -174,19 +189,11 @@ final class CodeCoverage
$this->cachedReport = null;
}
/**
* @param array<string,list<int>> $linesToBeIgnored
*/
public function stop(bool $append = true, ?TestStatus $status = null, array|false $linesToBeCovered = [], array $linesToBeUsed = [], array $linesToBeIgnored = []): RawCodeCoverageData
public function stop(bool $append = true, ?TestStatus $status = null, null|false|TargetCollection $covers = null, ?TargetCollection $uses = null): RawCodeCoverageData
{
$data = $this->driver->stop();
$this->linesToBeIgnored = array_merge_recursive(
$this->linesToBeIgnored,
$linesToBeIgnored,
);
$this->append($data, null, $append, $status, $linesToBeCovered, $linesToBeUsed, $linesToBeIgnored);
$this->append($data, null, $append, $status, $covers, $uses);
$this->currentId = null;
$this->currentSize = null;
@@ -196,13 +203,11 @@ final class CodeCoverage
}
/**
* @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
public function append(RawCodeCoverageData $rawData, ?string $id = null, bool $append = true, ?TestStatus $status = null, null|false|TargetCollection $covers = null, ?TargetCollection $uses = null): void
{
if ($id === null) {
$id = $this->currentId;
@@ -212,24 +217,32 @@ final class CodeCoverage
throw new TestIdMissingException;
}
$this->cachedReport = null;
if ($status === null) {
$status = TestStatus::unknown();
}
if ($covers === null) {
$covers = TargetCollection::fromArray([]);
}
if ($uses === null) {
$uses = TargetCollection::fromArray([]);
}
$size = $this->currentSize;
if ($size === null) {
$size = TestSize::unknown();
}
$this->cachedReport = null;
$this->applyFilter($rawData);
$this->applyExecutableLinesFilter($rawData);
if ($this->useAnnotationsForIgnoringCode) {
$this->applyIgnoredLinesFilter($rawData, $linesToBeIgnored);
$this->applyIgnoredLinesFilter($rawData);
}
$this->data->initializeUnseenData($rawData);
@@ -242,6 +255,17 @@ final class CodeCoverage
return;
}
$linesToBeCovered = false;
$linesToBeUsed = [];
if ($covers !== false) {
$linesToBeCovered = $this->targetMapper()->mapTargets($covers);
}
if ($linesToBeCovered !== false) {
$linesToBeUsed = $this->targetMapper()->mapTargets($uses);
}
$this->applyCoversAndUsesFilter(
$rawData,
$linesToBeCovered,
@@ -249,7 +273,7 @@ final class CodeCoverage
$size,
);
if (empty($rawData->lineCoverage())) {
if ($rawData->lineCoverage() === []) {
return;
}
@@ -360,24 +384,31 @@ final class CodeCoverage
public function enableBranchAndPathCoverage(): void
{
$this->driver->enableBranchAndPathCoverage();
$this->collectBranchAndPathCoverage = true;
}
public function disableBranchAndPathCoverage(): void
{
$this->driver->disableBranchAndPathCoverage();
$this->collectBranchAndPathCoverage = false;
}
public function collectsBranchAndPathCoverage(): bool
{
return $this->driver->collectsBranchAndPathCoverage();
return $this->collectBranchAndPathCoverage;
}
public function detectsDeadCode(): bool
public function validate(TargetCollection $targets): ValidationResult
{
return $this->driver->detectsDeadCode();
return (new TargetCollectionValidator)->validate($this->targetMapper(), $targets);
}
/**
* @param false|TargetedLines $linesToBeCovered
* @param TargetedLines $linesToBeUsed
*
* @throws ReflectionException
* @throws UnintentionallyCoveredCodeException
*/
@@ -389,7 +420,7 @@ final class CodeCoverage
return;
}
if (empty($linesToBeCovered)) {
if ($linesToBeCovered === []) {
return;
}
@@ -404,11 +435,9 @@ final class CodeCoverage
$rawData->removeCoverageDataForFile($fileWithNoCoverage);
}
if (is_array($linesToBeCovered)) {
foreach ($linesToBeCovered as $fileToBeCovered => $includedLines) {
$rawData->keepLineCoverageDataOnlyForLines($fileToBeCovered, $includedLines);
$rawData->keepFunctionCoverageDataOnlyForLines($fileToBeCovered, $includedLines);
}
foreach ($linesToBeCovered as $fileToBeCovered => $includedLines) {
$rawData->keepLineCoverageDataOnlyForLines($fileToBeCovered, $includedLines);
$rawData->keepFunctionCoverageDataOnlyForLines($fileToBeCovered, $includedLines);
}
}
@@ -432,7 +461,7 @@ final class CodeCoverage
continue;
}
$linesToBranchMap = $this->analyser()->executableLinesIn($filename);
$linesToBranchMap = $this->analyser()->analyse($filename)->executableLines();
$data->keepLineCoverageDataOnlyForLines(
$filename,
@@ -446,26 +475,16 @@ final class CodeCoverage
}
}
/**
* @param array<string,list<int>> $linesToBeIgnored
*/
private function applyIgnoredLinesFilter(RawCodeCoverageData $data, array $linesToBeIgnored): void
private function applyIgnoredLinesFilter(RawCodeCoverageData $data): 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),
$this->analyser()->analyse($filename)->ignoredLines(),
);
}
}
@@ -488,13 +507,15 @@ final class CodeCoverage
$this->analyser(),
),
self::UNCOVERED_FILES,
linesToBeIgnored: $this->linesToBeIgnored,
);
}
}
}
/**
* @param TargetedLines $linesToBeCovered
* @param TargetedLines $linesToBeUsed
*
* @throws ReflectionException
* @throws UnintentionallyCoveredCodeException
*/
@@ -510,20 +531,26 @@ final class CodeCoverage
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->targetMapper->lookup($file, $line);
}
}
}
$unintentionallyCoveredUnits = $this->processUnintentionallyCoveredUnits($unintentionallyCoveredUnits);
if (!empty($unintentionallyCoveredUnits)) {
if ($unintentionallyCoveredUnits !== []) {
throw new UnintentionallyCoveredCodeException(
$unintentionallyCoveredUnits,
);
}
}
/**
* @param TargetedLines $linesToBeCovered
* @param TargetedLines $linesToBeUsed
*
* @return TargetedLines
*/
private function getAllowedLines(array $linesToBeCovered, array $linesToBeUsed): array
{
$allowedLines = [];
@@ -606,26 +633,40 @@ final class CodeCoverage
return $processed;
}
private function targetMapper(): Mapper
{
if ($this->targetMapper !== null) {
return $this->targetMapper;
}
$this->targetMapper = new Mapper(
(new MapBuilder)->build($this->filter, $this->analyser()),
);
return $this->targetMapper;
}
private function analyser(): FileAnalyser
{
if ($this->analyser !== null) {
return $this->analyser;
}
$this->analyser = new ParsingFileAnalyser(
$sourceAnalyser = new ParsingSourceAnalyser;
if ($this->cachesStaticAnalysis()) {
$sourceAnalyser = new CachingSourceAnalyser(
$this->cacheDirectory,
$sourceAnalyser,
);
}
$this->analyser = new FileAnalyser(
$sourceAnalyser,
$this->useAnnotationsForIgnoringCode,
$this->ignoreDeprecatedCode,
);
if ($this->cachesStaticAnalysis()) {
$this->analyser = new CachingFileAnalyser(
$this->cacheDirectory,
$this->analyser,
$this->useAnnotationsForIgnoringCode,
$this->ignoreDeprecatedCode,
);
}
return $this->analyser;
}
}
@@ -17,13 +17,31 @@ use function count;
use function is_array;
use function ksort;
use SebastianBergmann\CodeCoverage\Driver\Driver;
use SebastianBergmann\CodeCoverage\Driver\XdebugDriver;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*
* @phpstan-import-type XdebugFunctionCoverageType from \SebastianBergmann\CodeCoverage\Driver\XdebugDriver
* @phpstan-import-type XdebugFunctionCoverageType from XdebugDriver
*
* @phpstan-type TestIdType = string
* @phpstan-type TestIdType string
* @phpstan-type FunctionCoverageDataType array{
* branches: array<int, array{
* op_start: int,
* op_end: int,
* line_start: int,
* line_end: int,
* hit: list<TestIdType>,
* out: array<int, int>,
* out_hit: array<int, int>,
* }>,
* paths: array<int, array{
* path: array<int, int>,
* hit: list<TestIdType>,
* }>,
* hit: list<TestIdType>
* }
* @phpstan-type FunctionCoverageType array<string, array<string, FunctionCoverageDataType>>
*/
final class ProcessedCodeCoverageData
{
@@ -40,22 +58,7 @@ final class ProcessedCodeCoverageData
* Maintains base format of raw data (@see https://xdebug.org/docs/code_coverage), but each 'hit' entry is an array
* of testcase ids.
*
* @var array<string, array<string, array{
* branches: array<int, array{
* op_start: int,
* op_end: int,
* line_start: int,
* line_end: int,
* hit: list<TestIdType>,
* out: array<int, int>,
* out_hit: array<int, int>,
* }>,
* paths: array<int, array{
* path: array<int, int>,
* hit: list<TestIdType>,
* }>,
* hit: list<TestIdType>
* }>>
* @var FunctionCoverageType
*/
private array $functionCoverage = [];
@@ -216,6 +219,8 @@ final class ProcessedCodeCoverageData
* 4 = the line has been tested
*
* During a merge, a higher number is better.
*
* @return 1|2|3|4
*/
private function priorityForLine(array $data, int $line): int
{
@@ -237,7 +242,7 @@ final class ProcessedCodeCoverageData
/**
* For a function we have never seen before, copy all data over and simply init the 'hit' array.
*
* @param XdebugFunctionCoverageType $functionData
* @param FunctionCoverageDataType|XdebugFunctionCoverageType $functionData
*/
private function initPreviouslyUnseenFunction(string $file, string $functionName, array $functionData): void
{
@@ -257,7 +262,7 @@ final class ProcessedCodeCoverageData
* Techniques such as mocking and where the contents of a file are different vary during tests (e.g. compiling
* containers) mean that the functions inside a file cannot be relied upon to be static.
*
* @param XdebugFunctionCoverageType $functionData
* @param FunctionCoverageDataType|XdebugFunctionCoverageType $functionData
*/
private function initPreviouslySeenFunction(string $file, string $functionName, array $functionData): void
{
@@ -14,6 +14,7 @@ use function array_diff_key;
use function array_flip;
use function array_intersect;
use function array_intersect_key;
use function array_map;
use function count;
use function explode;
use function file_get_contents;
@@ -25,14 +26,15 @@ use function str_ends_with;
use function str_starts_with;
use function trim;
use SebastianBergmann\CodeCoverage\Driver\Driver;
use SebastianBergmann\CodeCoverage\Driver\XdebugDriver;
use SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*
* @phpstan-import-type XdebugFunctionsCoverageType from \SebastianBergmann\CodeCoverage\Driver\XdebugDriver
* @phpstan-import-type XdebugCodeCoverageWithoutPathCoverageType from \SebastianBergmann\CodeCoverage\Driver\XdebugDriver
* @phpstan-import-type XdebugCodeCoverageWithPathCoverageType from \SebastianBergmann\CodeCoverage\Driver\XdebugDriver
* @phpstan-import-type XdebugFunctionsCoverageType from XdebugDriver
* @phpstan-import-type XdebugCodeCoverageWithoutPathCoverageType from XdebugDriver
* @phpstan-import-type XdebugCodeCoverageWithPathCoverageType from XdebugDriver
*/
final class RawCodeCoverageData
{
@@ -86,11 +88,10 @@ final class RawCodeCoverageData
public static function fromUncoveredFile(string $filename, FileAnalyser $analyser): self
{
$lineCoverage = [];
foreach ($analyser->executableLinesIn($filename) as $line => $branch) {
$lineCoverage[$line] = Driver::LINE_NOT_EXECUTED;
}
$lineCoverage = array_map(
static fn (): int => Driver::LINE_NOT_EXECUTED,
$analyser->analyse($filename)->executableLines(),
);
return new self([$filename => $lineCoverage], []);
}
@@ -211,7 +212,7 @@ final class RawCodeCoverageData
*/
public function removeCoverageDataForLines(string $filename, array $lines): void
{
if (empty($lines)) {
if ($lines === []) {
return;
}
@@ -258,6 +259,9 @@ final class RawCodeCoverageData
}
}
/**
* @return array<int>
*/
private function getEmptyLinesForFile(string $filename): array
{
if (!isset(self::$emptyLineCache[$filename])) {
+5 -49
View File
@@ -12,7 +12,6 @@ namespace SebastianBergmann\CodeCoverage\Driver;
use function sprintf;
use SebastianBergmann\CodeCoverage\BranchAndPathCoverageNotSupportedException;
use SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData;
use SebastianBergmann\CodeCoverage\DeadCodeDetectionNotSupportedException;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
@@ -20,41 +19,30 @@ use SebastianBergmann\CodeCoverage\DeadCodeDetectionNotSupportedException;
abstract class Driver
{
/**
* @var int
*
* @see http://xdebug.org/docs/code_coverage
*/
public const LINE_NOT_EXECUTABLE = -2;
public const int LINE_NOT_EXECUTABLE = -2;
/**
* @var int
*
* @see http://xdebug.org/docs/code_coverage
*/
public const LINE_NOT_EXECUTED = -1;
public const int LINE_NOT_EXECUTED = -1;
/**
* @var int
*
* @see http://xdebug.org/docs/code_coverage
*/
public const LINE_EXECUTED = 1;
public const int LINE_EXECUTED = 1;
/**
* @var int
*
* @see http://xdebug.org/docs/code_coverage
*/
public const BRANCH_NOT_HIT = 0;
public const int BRANCH_NOT_HIT = 0;
/**
* @var int
*
* @see http://xdebug.org/docs/code_coverage
*/
public const BRANCH_HIT = 1;
public const int BRANCH_HIT = 1;
private bool $collectBranchAndPathCoverage = false;
private bool $detectDeadCode = false;
public function canCollectBranchAndPathCoverage(): bool
{
@@ -88,38 +76,6 @@ abstract class Driver
$this->collectBranchAndPathCoverage = false;
}
public function canDetectDeadCode(): bool
{
return false;
}
public function detectsDeadCode(): bool
{
return $this->detectDeadCode;
}
/**
* @throws DeadCodeDetectionNotSupportedException
*/
public function enableDeadCodeDetection(): void
{
if (!$this->canDetectDeadCode()) {
throw new DeadCodeDetectionNotSupportedException(
sprintf(
'%s does not support dead code detection',
$this->nameAndVersion(),
),
);
}
$this->detectDeadCode = true;
}
public function disableDeadCodeDetection(): void
{
$this->detectDeadCode = false;
}
abstract public function nameAndVersion(): string;
abstract public function start(): void;
@@ -38,6 +38,9 @@ final class PcovDriver extends Driver
$this->filter = $filter;
}
/**
* @codeCoverageIgnore
*/
public function start(): void
{
start();
@@ -47,10 +50,11 @@ final class PcovDriver extends Driver
{
stop();
// @codeCoverageIgnoreStart
$filesToCollectCoverageFor = waiting();
$collected = [];
if ($filesToCollectCoverageFor) {
if ($filesToCollectCoverageFor !== []) {
if (!$this->filter->isEmpty()) {
$filesToCollectCoverageFor = array_intersect($filesToCollectCoverageFor, $this->filter->files());
}
@@ -61,6 +65,7 @@ final class PcovDriver extends Driver
}
return RawCodeCoverageData::fromXdebugWithoutPathCoverage($collected);
// @codeCoverageIgnoreEnd
}
public function nameAndVersion(): string
@@ -21,6 +21,7 @@ final class Selector
* @throws PcovNotAvailableException
* @throws XdebugNotAvailableException
* @throws XdebugNotEnabledException
* @throws XdebugVersionNotSupportedException
*/
public function forLineCoverage(Filter $filter): Driver
{
@@ -31,11 +32,7 @@ final class Selector
}
if ($runtime->hasXdebug()) {
$driver = new XdebugDriver($filter);
$driver->enableDeadCodeDetection();
return $driver;
return new XdebugDriver($filter);
}
throw new NoCodeCoverageDriverAvailableException;
@@ -45,13 +42,13 @@ final class Selector
* @throws NoCodeCoverageDriverWithPathCoverageSupportAvailableException
* @throws XdebugNotAvailableException
* @throws XdebugNotEnabledException
* @throws XdebugVersionNotSupportedException
*/
public function forLineAndPathCoverage(Filter $filter): Driver
{
if ((new Runtime)->hasXdebug()) {
$driver = new XdebugDriver($filter);
$driver->enableDeadCodeDetection();
$driver->enableBranchAndPathCoverage();
return $driver;
@@ -14,11 +14,8 @@ use const XDEBUG_CC_DEAD_CODE;
use const XDEBUG_CC_UNUSED;
use const XDEBUG_FILTER_CODE_COVERAGE;
use const XDEBUG_PATH_INCLUDE;
use function explode;
use function extension_loaded;
use function getenv;
use function in_array;
use function ini_get;
use function phpversion;
use function version_compare;
use function xdebug_get_code_coverage;
@@ -34,8 +31,8 @@ use SebastianBergmann\CodeCoverage\Filter;
*
* @see https://xdebug.org/docs/code_coverage#xdebug_get_code_coverage
*
* @phpstan-type XdebugLinesCoverageType = array<int, int>
* @phpstan-type XdebugBranchCoverageType = array{
* @phpstan-type XdebugLinesCoverageType array<int, int>
* @phpstan-type XdebugBranchCoverageType array{
* op_start: int,
* op_end: int,
* line_start: int,
@@ -44,32 +41,32 @@ use SebastianBergmann\CodeCoverage\Filter;
* out: array<int, int>,
* out_hit: array<int, int>,
* }
* @phpstan-type XdebugPathCoverageType = array{
* @phpstan-type XdebugPathCoverageType array{
* path: array<int, int>,
* hit: int,
* }
* @phpstan-type XdebugFunctionCoverageType = array{
* @phpstan-type XdebugFunctionCoverageType array{
* branches: array<int, XdebugBranchCoverageType>,
* paths: array<int, XdebugPathCoverageType>,
* }
* @phpstan-type XdebugFunctionsCoverageType = array<string, XdebugFunctionCoverageType>
* @phpstan-type XdebugPathAndBranchesCoverageType = array{
* @phpstan-type XdebugFunctionsCoverageType array<string, XdebugFunctionCoverageType>
* @phpstan-type XdebugPathAndBranchesCoverageType array{
* lines: XdebugLinesCoverageType,
* functions: XdebugFunctionsCoverageType,
* }
* @phpstan-type XdebugCodeCoverageWithoutPathCoverageType = array<string, XdebugLinesCoverageType>
* @phpstan-type XdebugCodeCoverageWithPathCoverageType = array<string, XdebugPathAndBranchesCoverageType>
* @phpstan-type XdebugCodeCoverageWithoutPathCoverageType array<string, XdebugLinesCoverageType>
* @phpstan-type XdebugCodeCoverageWithPathCoverageType array<string, XdebugPathAndBranchesCoverageType>
*/
final class XdebugDriver extends Driver
{
/**
* @throws XdebugNotAvailableException
* @throws XdebugNotEnabledException
* @throws XdebugVersionNotSupportedException
*/
public function __construct(Filter $filter)
{
$this->ensureXdebugIsAvailable();
$this->ensureXdebugCodeCoverageFeatureIsEnabled();
if (!$filter->isEmpty()) {
xdebug_set_filter(
@@ -85,18 +82,9 @@ final class XdebugDriver extends Driver
return true;
}
public function canDetectDeadCode(): bool
{
return true;
}
public function start(): void
{
$flags = XDEBUG_CC_UNUSED;
if ($this->detectsDeadCode() || $this->collectsBranchAndPathCoverage()) {
$flags |= XDEBUG_CC_DEAD_CODE;
}
$flags = XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE;
if ($this->collectsBranchAndPathCoverage()) {
$flags |= XDEBUG_CC_BRANCH_CHECK;
@@ -127,35 +115,20 @@ final class XdebugDriver extends Driver
/**
* @throws XdebugNotAvailableException
* @throws XdebugNotEnabledException
* @throws XdebugVersionNotSupportedException
*/
private function ensureXdebugIsAvailable(): void
{
if (!extension_loaded('xdebug')) {
throw new XdebugNotAvailableException;
}
}
/**
* @throws XdebugNotEnabledException
*/
private function ensureXdebugCodeCoverageFeatureIsEnabled(): void
{
if (version_compare(phpversion('xdebug'), '3.1', '>=')) {
if (!in_array('coverage', xdebug_info('mode'), true)) {
throw new XdebugNotEnabledException;
}
return;
if (!version_compare(phpversion('xdebug'), '3.1', '>=')) {
throw new XdebugVersionNotSupportedException(phpversion('xdebug'));
}
$mode = getenv('XDEBUG_MODE');
if ($mode === false || $mode === '') {
$mode = ini_get('xdebug.mode');
}
if ($mode === false ||
!in_array('coverage', explode(',', $mode), true)) {
if (!in_array('coverage', xdebug_info('mode'), true)) {
throw new XdebugNotEnabledException;
}
}
@@ -1,16 +0,0 @@
<?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 RuntimeException;
final class DeadCodeDetectionNotSupportedException extends RuntimeException implements Exception
{
}
@@ -7,11 +7,10 @@
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace SebastianBergmann\CodeCoverage\Driver;
namespace SebastianBergmann\CodeCoverage;
use function sprintf;
use RuntimeException;
use SebastianBergmann\CodeCoverage\Exception;
final class PathExistsButIsNotDirectoryException extends RuntimeException implements Exception
{
@@ -7,11 +7,10 @@
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace SebastianBergmann\CodeCoverage\Driver;
namespace SebastianBergmann\CodeCoverage;
use function sprintf;
use RuntimeException;
use SebastianBergmann\CodeCoverage\Exception;
final class WriteOperationFailedException extends RuntimeException implements Exception
{
+1 -1
View File
@@ -88,6 +88,6 @@ final class Filter
public function isEmpty(): bool
{
return empty($this->files);
return $this->files === [];
}
}
@@ -15,20 +15,24 @@ use function str_ends_with;
use function str_replace;
use function substr;
use Countable;
use SebastianBergmann\CodeCoverage\StaticAnalysis\LinesOfCode;
use SebastianBergmann\CodeCoverage\Util\Percentage;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*
* @phpstan-import-type LinesOfCodeType from \SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser
* @phpstan-import-type ProcessedFunctionType from \SebastianBergmann\CodeCoverage\Node\File
* @phpstan-import-type ProcessedClassType from \SebastianBergmann\CodeCoverage\Node\File
* @phpstan-import-type ProcessedTraitType from \SebastianBergmann\CodeCoverage\Node\File
* @phpstan-import-type ProcessedFunctionType from File
* @phpstan-import-type ProcessedClassType from File
* @phpstan-import-type ProcessedTraitType from File
*/
abstract class AbstractNode implements Countable
{
private readonly string $name;
private string $pathAsString;
/**
* @var non-empty-list<self>
*/
private array $pathAsArray;
private readonly ?AbstractNode $parent;
private string $id;
@@ -61,6 +65,9 @@ abstract class AbstractNode implements Countable
return $this->pathAsString;
}
/**
* @return non-empty-list<self>
*/
public function pathAsArray(): array
{
return $this->pathAsArray;
@@ -171,6 +178,24 @@ abstract class AbstractNode implements Countable
return $this->numberOfTestedFunctions() + $this->numberOfTestedMethods();
}
/**
* @return non-negative-int
*/
public function cyclomaticComplexity(): int
{
$ccn = 0;
foreach ($this->classesAndTraits() as $classLike) {
$ccn += $classLike['ccn'];
}
foreach ($this->functions() as $function) {
$ccn += $function['ccn'];
}
return $ccn;
}
/**
* @return array<string, ProcessedClassType>
*/
@@ -186,10 +211,7 @@ abstract class AbstractNode implements Countable
*/
abstract public function functions(): array;
/**
* @return LinesOfCodeType
*/
abstract public function linesOfCode(): array;
abstract public function linesOfCode(): LinesOfCode;
abstract public function numberOfExecutableLines(): int;
+12 -9
View File
@@ -28,11 +28,11 @@ use SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*
* @phpstan-import-type TestType from \SebastianBergmann\CodeCoverage\CodeCoverage
* @phpstan-import-type TestType from CodeCoverage
*/
final class Builder
final readonly class Builder
{
private readonly FileAnalyser $analyser;
private FileAnalyser $analyser;
public function __construct(FileAnalyser $analyser)
{
@@ -70,6 +70,8 @@ final class Builder
$filename = $root->pathAsString() . DIRECTORY_SEPARATOR . $key;
if (is_file($filename)) {
$analysisResult = $this->analyser->analyse($filename);
$root->addFile(
new File(
$key,
@@ -77,10 +79,10 @@ final class Builder
$value['lineCoverage'],
$value['functionCoverage'],
$tests,
$this->analyser->classesIn($filename),
$this->analyser->traitsIn($filename),
$this->analyser->functionsIn($filename),
$this->analyser->linesOfCodeFor($filename),
$analysisResult->classes(),
$analysisResult->traits(),
$analysisResult->functions(),
$analysisResult->linesOfCode(),
),
);
}
@@ -201,7 +203,7 @@ final class Builder
*/
private function reducePaths(ProcessedCodeCoverageData $coverage): string
{
if (empty($coverage->coveredFiles())) {
if ($coverage->coveredFiles() === []) {
return '.';
}
@@ -223,9 +225,10 @@ final class Builder
$paths[$i] = substr($paths[$i], 7);
$paths[$i] = str_replace('/', DIRECTORY_SEPARATOR, $paths[$i]);
}
$paths[$i] = explode(DIRECTORY_SEPARATOR, $paths[$i]);
if (empty($paths[$i][0])) {
if ($paths[$i][0] === '') {
$paths[$i][0] = DIRECTORY_SEPARATOR;
}
}
@@ -14,10 +14,10 @@ use function sprintf;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*/
final class CrapIndex
final readonly class CrapIndex
{
private readonly int $cyclomaticComplexity;
private readonly float $codeCoverage;
private int $cyclomaticComplexity;
private float $codeCoverage;
public function __construct(int $cyclomaticComplexity, float $codeCoverage)
{
+51 -20
View File
@@ -14,11 +14,16 @@ use function assert;
use function count;
use IteratorAggregate;
use RecursiveIteratorIterator;
use SebastianBergmann\CodeCoverage\StaticAnalysis\LinesOfCode;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
* @template-implements IteratorAggregate<int, AbstractNode>
*
* @phpstan-import-type LinesOfCodeType from \SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser
* @phpstan-import-type ProcessedFunctionType from File
* @phpstan-import-type ProcessedClassType from File
* @phpstan-import-type ProcessedTraitType from File
*
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*/
final class Directory extends AbstractNode implements IteratorAggregate
{
@@ -35,15 +40,23 @@ final class Directory extends AbstractNode implements IteratorAggregate
/**
* @var list<File>
*/
private array $files = [];
private ?array $classes = null;
private ?array $traits = null;
private ?array $functions = null;
private array $files = [];
/**
* @var null|LinesOfCodeType
* @var ?array<string, ProcessedClassType>
*/
private ?array $linesOfCode = null;
private ?array $classes = null;
/**
* @var ?array<string, ProcessedTraitType>
*/
private ?array $traits = null;
/**
* @var ?array<string, ProcessedFunctionType>
*/
private ?array $functions = null;
private ?LinesOfCode $linesOfCode = null;
private int $numFiles = -1;
private int $numExecutableLines = -1;
private int $numExecutedLines = -1;
@@ -73,6 +86,9 @@ final class Directory extends AbstractNode implements IteratorAggregate
return $this->numFiles;
}
/**
* @return RecursiveIteratorIterator<Iterator<AbstractNode>>
*/
public function getIterator(): RecursiveIteratorIterator
{
return new RecursiveIteratorIterator(
@@ -102,21 +118,33 @@ final class Directory extends AbstractNode implements IteratorAggregate
$this->numExecutedLines = -1;
}
/**
* @return list<Directory>
*/
public function directories(): array
{
return $this->directories;
}
/**
* @return list<File>
*/
public function files(): array
{
return $this->files;
}
/**
* @return list<Directory|File>
*/
public function children(): array
{
return $this->children;
}
/**
* @return array<string, ProcessedClassType>
*/
public function classes(): array
{
if ($this->classes === null) {
@@ -133,6 +161,9 @@ final class Directory extends AbstractNode implements IteratorAggregate
return $this->classes;
}
/**
* @return array<string, ProcessedTraitType>
*/
public function traits(): array
{
if ($this->traits === null) {
@@ -149,6 +180,9 @@ final class Directory extends AbstractNode implements IteratorAggregate
return $this->traits;
}
/**
* @return array<string, ProcessedFunctionType>
*/
public function functions(): array
{
if ($this->functions === null) {
@@ -165,25 +199,22 @@ final class Directory extends AbstractNode implements IteratorAggregate
return $this->functions;
}
/**
* @return LinesOfCodeType
*/
public function linesOfCode(): array
public function linesOfCode(): LinesOfCode
{
if ($this->linesOfCode === null) {
$this->linesOfCode = [
'linesOfCode' => 0,
'commentLinesOfCode' => 0,
'nonCommentLinesOfCode' => 0,
];
$linesOfCode = 0;
$commentLinesOfCode = 0;
$nonCommentLinesOfCode = 0;
foreach ($this->children as $child) {
$childLinesOfCode = $child->linesOfCode();
$this->linesOfCode['linesOfCode'] += $childLinesOfCode['linesOfCode'];
$this->linesOfCode['commentLinesOfCode'] += $childLinesOfCode['commentLinesOfCode'];
$this->linesOfCode['nonCommentLinesOfCode'] += $childLinesOfCode['nonCommentLinesOfCode'];
$linesOfCode += $childLinesOfCode->linesOfCode();
$commentLinesOfCode += $childLinesOfCode->commentLinesOfCode();
$nonCommentLinesOfCode += $childLinesOfCode->nonCommentLinesOfCode();
}
$this->linesOfCode = new LinesOfCode($linesOfCode, $commentLinesOfCode, $nonCommentLinesOfCode);
}
return $this->linesOfCode;
+89 -85
View File
@@ -12,18 +12,21 @@ namespace SebastianBergmann\CodeCoverage\Node;
use function array_filter;
use function count;
use function range;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\StaticAnalysis\AnalysisResult;
use SebastianBergmann\CodeCoverage\StaticAnalysis\Class_;
use SebastianBergmann\CodeCoverage\StaticAnalysis\Function_;
use SebastianBergmann\CodeCoverage\StaticAnalysis\LinesOfCode;
use SebastianBergmann\CodeCoverage\StaticAnalysis\Method;
use SebastianBergmann\CodeCoverage\StaticAnalysis\Trait_;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*
* @phpstan-import-type CodeUnitFunctionType from \SebastianBergmann\CodeCoverage\StaticAnalysis\CodeUnitFindingVisitor
* @phpstan-import-type CodeUnitMethodType from \SebastianBergmann\CodeCoverage\StaticAnalysis\CodeUnitFindingVisitor
* @phpstan-import-type CodeUnitClassType from \SebastianBergmann\CodeCoverage\StaticAnalysis\CodeUnitFindingVisitor
* @phpstan-import-type CodeUnitTraitType from \SebastianBergmann\CodeCoverage\StaticAnalysis\CodeUnitFindingVisitor
* @phpstan-import-type LinesOfCodeType from \SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser
* @phpstan-import-type LinesType from \SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser
* @phpstan-import-type TestType from CodeCoverage
* @phpstan-import-type LinesType from AnalysisResult
*
* @phpstan-type ProcessedFunctionType = array{
* @phpstan-type ProcessedFunctionType array{
* functionName: string,
* namespace: string,
* signature: string,
@@ -40,7 +43,7 @@ use function range;
* crap: int|string,
* link: string
* }
* @phpstan-type ProcessedMethodType = array{
* @phpstan-type ProcessedMethodType array{
* methodName: string,
* visibility: string,
* signature: string,
@@ -57,7 +60,7 @@ use function range;
* crap: int|string,
* link: string
* }
* @phpstan-type ProcessedClassType = array{
* @phpstan-type ProcessedClassType array{
* className: string,
* namespace: string,
* methods: array<string, ProcessedMethodType>,
@@ -73,7 +76,7 @@ use function range;
* crap: int|string,
* link: string
* }
* @phpstan-type ProcessedTraitType = array{
* @phpstan-type ProcessedTraitType array{
* traitName: string,
* namespace: string,
* methods: array<string, ProcessedMethodType>,
@@ -97,6 +100,10 @@ final class File extends AbstractNode
*/
private array $lineCoverageData;
private array $functionCoverageData;
/**
* @var array<string, TestType>
*/
private readonly array $testData;
private int $numExecutableLines = 0;
private int $numExecutedLines = 0;
@@ -119,11 +126,7 @@ final class File extends AbstractNode
* @var array<string, ProcessedFunctionType>
*/
private array $functions = [];
/**
* @var LinesOfCodeType
*/
private readonly array $linesOfCode;
private readonly LinesOfCode $linesOfCode;
private ?int $numClasses = null;
private int $numTestedClasses = 0;
private ?int $numTraits = null;
@@ -133,18 +136,18 @@ final class File extends AbstractNode
private ?int $numTestedFunctions = null;
/**
* @var array<int, array|array{0: CodeUnitClassType, 1: string}|array{0: CodeUnitFunctionType}|array{0: CodeUnitTraitType, 1: string}>
* @var array<int, array|array{0: Class_, 1: string}|array{0: Function_}|array{0: Trait_, 1: string}>
*/
private array $codeUnitsByLine = [];
/**
* @param array<int, ?list<non-empty-string>> $lineCoverageData
* @param array<string, CodeUnitClassType> $classes
* @param array<string, CodeUnitTraitType> $traits
* @param array<string, CodeUnitFunctionType> $functions
* @param LinesOfCodeType $linesOfCode
* @param array<string, TestType> $testData
* @param array<string, Class_> $classes
* @param array<string, Trait_> $traits
* @param array<string, Function_> $functions
*/
public function __construct(string $name, AbstractNode $parent, array $lineCoverageData, array $functionCoverageData, array $testData, array $classes, array $traits, array $functions, array $linesOfCode)
public function __construct(string $name, AbstractNode $parent, array $lineCoverageData, array $functionCoverageData, array $testData, array $classes, array $traits, array $functions, LinesOfCode $linesOfCode)
{
parent::__construct($name, $parent);
@@ -174,6 +177,9 @@ final class File extends AbstractNode
return $this->functionCoverageData;
}
/**
* @return array<string, TestType>
*/
public function testData(): array
{
return $this->testData;
@@ -203,7 +209,7 @@ final class File extends AbstractNode
return $this->functions;
}
public function linesOfCode(): array
public function linesOfCode(): LinesOfCode
{
return $this->linesOfCode;
}
@@ -360,13 +366,13 @@ final class File extends AbstractNode
}
/**
* @param array<string, CodeUnitClassType> $classes
* @param array<string, CodeUnitTraitType> $traits
* @param array<string, CodeUnitFunctionType> $functions
* @param array<string, Class_> $classes
* @param array<string, Trait_> $traits
* @param array<string, Function_> $functions
*/
private function calculateStatistics(array $classes, array $traits, array $functions): void
{
foreach (range(1, $this->linesOfCode['linesOfCode']) as $lineNumber) {
foreach (range(1, $this->linesOfCode->linesOfCode()) as $lineNumber) {
$this->codeUnitsByLine[$lineNumber] = [];
}
@@ -374,7 +380,7 @@ final class File extends AbstractNode
$this->processTraits($traits);
$this->processFunctions($functions);
foreach (range(1, $this->linesOfCode['linesOfCode']) as $lineNumber) {
foreach (range(1, $this->linesOfCode->linesOfCode()) as $lineNumber) {
if (isset($this->lineCoverageData[$lineNumber])) {
foreach ($this->codeUnitsByLine[$lineNumber] as &$codeUnit) {
$codeUnit['executableLines']++;
@@ -398,24 +404,24 @@ final class File extends AbstractNode
foreach ($this->traits as &$trait) {
foreach ($trait['methods'] as &$method) {
$methodLineCoverage = $method['executableLines'] ? ($method['executedLines'] / $method['executableLines']) * 100 : 100;
$methodBranchCoverage = $method['executableBranches'] ? ($method['executedBranches'] / $method['executableBranches']) * 100 : 0;
$methodPathCoverage = $method['executablePaths'] ? ($method['executedPaths'] / $method['executablePaths']) * 100 : 0;
$methodLineCoverage = $method['executableLines'] > 0 ? ($method['executedLines'] / $method['executableLines']) * 100 : 100;
$methodBranchCoverage = $method['executableBranches'] > 0 ? ($method['executedBranches'] / $method['executableBranches']) * 100 : 0;
$methodPathCoverage = $method['executablePaths'] > 0 ? ($method['executedPaths'] / $method['executablePaths']) * 100 : 0;
$method['coverage'] = $methodBranchCoverage ?: $methodLineCoverage;
$method['crap'] = (new CrapIndex($method['ccn'], $methodPathCoverage ?: $methodLineCoverage))->asString();
$method['coverage'] = $methodBranchCoverage > 0 ? $methodBranchCoverage : $methodLineCoverage;
$method['crap'] = (new CrapIndex($method['ccn'], $methodPathCoverage > 0 ? $methodPathCoverage : $methodLineCoverage))->asString();
$trait['ccn'] += $method['ccn'];
}
unset($method);
$traitLineCoverage = $trait['executableLines'] ? ($trait['executedLines'] / $trait['executableLines']) * 100 : 100;
$traitBranchCoverage = $trait['executableBranches'] ? ($trait['executedBranches'] / $trait['executableBranches']) * 100 : 0;
$traitPathCoverage = $trait['executablePaths'] ? ($trait['executedPaths'] / $trait['executablePaths']) * 100 : 0;
$traitLineCoverage = $trait['executableLines'] > 0 ? ($trait['executedLines'] / $trait['executableLines']) * 100 : 100;
$traitBranchCoverage = $trait['executableBranches'] > 0 ? ($trait['executedBranches'] / $trait['executableBranches']) * 100 : 0;
$traitPathCoverage = $trait['executablePaths'] > 0 ? ($trait['executedPaths'] / $trait['executablePaths']) * 100 : 0;
$trait['coverage'] = $traitBranchCoverage ?: $traitLineCoverage;
$trait['crap'] = (new CrapIndex($trait['ccn'], $traitPathCoverage ?: $traitLineCoverage))->asString();
$trait['coverage'] = $traitBranchCoverage > 0 ? $traitBranchCoverage : $traitLineCoverage;
$trait['crap'] = (new CrapIndex($trait['ccn'], $traitPathCoverage > 0 ? $traitPathCoverage : $traitLineCoverage))->asString();
if ($trait['executableLines'] > 0 && $trait['coverage'] === 100) {
$this->numTestedClasses++;
@@ -426,24 +432,24 @@ final class File extends AbstractNode
foreach ($this->classes as &$class) {
foreach ($class['methods'] as &$method) {
$methodLineCoverage = $method['executableLines'] ? ($method['executedLines'] / $method['executableLines']) * 100 : 100;
$methodBranchCoverage = $method['executableBranches'] ? ($method['executedBranches'] / $method['executableBranches']) * 100 : 0;
$methodPathCoverage = $method['executablePaths'] ? ($method['executedPaths'] / $method['executablePaths']) * 100 : 0;
$methodLineCoverage = $method['executableLines'] > 0 ? ($method['executedLines'] / $method['executableLines']) * 100 : 100;
$methodBranchCoverage = $method['executableBranches'] > 0 ? ($method['executedBranches'] / $method['executableBranches']) * 100 : 0;
$methodPathCoverage = $method['executablePaths'] > 0 ? ($method['executedPaths'] / $method['executablePaths']) * 100 : 0;
$method['coverage'] = $methodBranchCoverage ?: $methodLineCoverage;
$method['crap'] = (new CrapIndex($method['ccn'], $methodPathCoverage ?: $methodLineCoverage))->asString();
$method['coverage'] = $methodBranchCoverage > 0 ? $methodBranchCoverage : $methodLineCoverage;
$method['crap'] = (new CrapIndex($method['ccn'], $methodPathCoverage > 0 ? $methodPathCoverage : $methodLineCoverage))->asString();
$class['ccn'] += $method['ccn'];
}
unset($method);
$classLineCoverage = $class['executableLines'] ? ($class['executedLines'] / $class['executableLines']) * 100 : 100;
$classBranchCoverage = $class['executableBranches'] ? ($class['executedBranches'] / $class['executableBranches']) * 100 : 0;
$classPathCoverage = $class['executablePaths'] ? ($class['executedPaths'] / $class['executablePaths']) * 100 : 0;
$classLineCoverage = $class['executableLines'] > 0 ? ($class['executedLines'] / $class['executableLines']) * 100 : 100;
$classBranchCoverage = $class['executableBranches'] > 0 ? ($class['executedBranches'] / $class['executableBranches']) * 100 : 0;
$classPathCoverage = $class['executablePaths'] > 0 ? ($class['executedPaths'] / $class['executablePaths']) * 100 : 0;
$class['coverage'] = $classBranchCoverage ?: $classLineCoverage;
$class['crap'] = (new CrapIndex($class['ccn'], $classPathCoverage ?: $classLineCoverage))->asString();
$class['coverage'] = $classBranchCoverage > 0 ? $classBranchCoverage : $classLineCoverage;
$class['crap'] = (new CrapIndex($class['ccn'], $classPathCoverage > 0 ? $classPathCoverage : $classLineCoverage))->asString();
if ($class['executableLines'] > 0 && $class['coverage'] === 100) {
$this->numTestedClasses++;
@@ -453,12 +459,12 @@ final class File extends AbstractNode
unset($class);
foreach ($this->functions as &$function) {
$functionLineCoverage = $function['executableLines'] ? ($function['executedLines'] / $function['executableLines']) * 100 : 100;
$functionBranchCoverage = $function['executableBranches'] ? ($function['executedBranches'] / $function['executableBranches']) * 100 : 0;
$functionPathCoverage = $function['executablePaths'] ? ($function['executedPaths'] / $function['executablePaths']) * 100 : 0;
$functionLineCoverage = $function['executableLines'] > 0 ? ($function['executedLines'] / $function['executableLines']) * 100 : 100;
$functionBranchCoverage = $function['executableBranches'] > 0 ? ($function['executedBranches'] / $function['executableBranches']) * 100 : 0;
$functionPathCoverage = $function['executablePaths'] > 0 ? ($function['executedPaths'] / $function['executablePaths']) * 100 : 0;
$function['coverage'] = $functionBranchCoverage ?: $functionLineCoverage;
$function['crap'] = (new CrapIndex($function['ccn'], $functionPathCoverage ?: $functionLineCoverage))->asString();
$function['coverage'] = $functionBranchCoverage > 0 ? $functionBranchCoverage : $functionLineCoverage;
$function['crap'] = (new CrapIndex($function['ccn'], $functionPathCoverage > 0 ? $functionPathCoverage : $functionLineCoverage))->asString();
if ($function['coverage'] === 100) {
$this->numTestedFunctions++;
@@ -467,7 +473,7 @@ final class File extends AbstractNode
}
/**
* @param array<string, CodeUnitClassType> $classes
* @param array<string, Class_> $classes
*/
private function processClasses(array $classes): void
{
@@ -476,9 +482,9 @@ final class File extends AbstractNode
foreach ($classes as $className => $class) {
$this->classes[$className] = [
'className' => $className,
'namespace' => $class['namespace'],
'namespace' => $class->namespace(),
'methods' => [],
'startLine' => $class['startLine'],
'startLine' => $class->startLine(),
'executableLines' => 0,
'executedLines' => 0,
'executableBranches' => 0,
@@ -488,11 +494,11 @@ final class File extends AbstractNode
'ccn' => 0,
'coverage' => 0,
'crap' => 0,
'link' => $link . $class['startLine'],
'link' => $link . $class->startLine(),
];
foreach ($class['methods'] as $methodName => $method) {
$methodData = $this->newMethod($className, $methodName, $method, $link);
foreach ($class->methods() as $methodName => $method) {
$methodData = $this->newMethod($className, $method, $link);
$this->classes[$className]['methods'][$methodName] = $methodData;
$this->classes[$className]['executableBranches'] += $methodData['executableBranches'];
@@ -505,7 +511,7 @@ final class File extends AbstractNode
$this->numExecutablePaths += $methodData['executablePaths'];
$this->numExecutedPaths += $methodData['executedPaths'];
foreach (range($method['startLine'], $method['endLine']) as $lineNumber) {
foreach (range($method->startLine(), $method->endLine()) as $lineNumber) {
$this->codeUnitsByLine[$lineNumber] = [
&$this->classes[$className],
&$this->classes[$className]['methods'][$methodName],
@@ -516,7 +522,7 @@ final class File extends AbstractNode
}
/**
* @param array<string, CodeUnitTraitType> $traits
* @param array<string, Trait_> $traits
*/
private function processTraits(array $traits): void
{
@@ -525,9 +531,9 @@ final class File extends AbstractNode
foreach ($traits as $traitName => $trait) {
$this->traits[$traitName] = [
'traitName' => $traitName,
'namespace' => $trait['namespace'],
'namespace' => $trait->namespace(),
'methods' => [],
'startLine' => $trait['startLine'],
'startLine' => $trait->startLine(),
'executableLines' => 0,
'executedLines' => 0,
'executableBranches' => 0,
@@ -537,11 +543,11 @@ final class File extends AbstractNode
'ccn' => 0,
'coverage' => 0,
'crap' => 0,
'link' => $link . $trait['startLine'],
'link' => $link . $trait->startLine(),
];
foreach ($trait['methods'] as $methodName => $method) {
$methodData = $this->newMethod($traitName, $methodName, $method, $link);
foreach ($trait->methods() as $methodName => $method) {
$methodData = $this->newMethod($traitName, $method, $link);
$this->traits[$traitName]['methods'][$methodName] = $methodData;
$this->traits[$traitName]['executableBranches'] += $methodData['executableBranches'];
@@ -554,7 +560,7 @@ final class File extends AbstractNode
$this->numExecutablePaths += $methodData['executablePaths'];
$this->numExecutedPaths += $methodData['executedPaths'];
foreach (range($method['startLine'], $method['endLine']) as $lineNumber) {
foreach (range($method->startLine(), $method->endLine()) as $lineNumber) {
$this->codeUnitsByLine[$lineNumber] = [
&$this->traits[$traitName],
&$this->traits[$traitName]['methods'][$methodName],
@@ -565,7 +571,7 @@ final class File extends AbstractNode
}
/**
* @param array<string, CodeUnitFunctionType> $functions
* @param array<string, Function_> $functions
*/
private function processFunctions(array $functions): void
{
@@ -574,23 +580,23 @@ final class File extends AbstractNode
foreach ($functions as $functionName => $function) {
$this->functions[$functionName] = [
'functionName' => $functionName,
'namespace' => $function['namespace'],
'signature' => $function['signature'],
'startLine' => $function['startLine'],
'endLine' => $function['endLine'],
'namespace' => $function->namespace(),
'signature' => $function->signature(),
'startLine' => $function->startLine(),
'endLine' => $function->endLine(),
'executableLines' => 0,
'executedLines' => 0,
'executableBranches' => 0,
'executedBranches' => 0,
'executablePaths' => 0,
'executedPaths' => 0,
'ccn' => $function['ccn'],
'ccn' => $function->cyclomaticComplexity(),
'coverage' => 0,
'crap' => 0,
'link' => $link . $function['startLine'],
'link' => $link . $function->startLine(),
];
foreach (range($function['startLine'], $function['endLine']) as $lineNumber) {
foreach (range($function->startLine(), $function->endLine()) as $lineNumber) {
$this->codeUnitsByLine[$lineNumber] = [&$this->functions[$functionName]];
}
@@ -634,31 +640,29 @@ final class File extends AbstractNode
}
/**
* @param CodeUnitMethodType $method
*
* @return ProcessedMethodType
*/
private function newMethod(string $className, string $methodName, array $method, string $link): array
private function newMethod(string $className, Method $method, string $link): array
{
$methodData = [
'methodName' => $methodName,
'visibility' => $method['visibility'],
'signature' => $method['signature'],
'startLine' => $method['startLine'],
'endLine' => $method['endLine'],
'methodName' => $method->name(),
'visibility' => $method->visibility()->value,
'signature' => $method->signature(),
'startLine' => $method->startLine(),
'endLine' => $method->endLine(),
'executableLines' => 0,
'executedLines' => 0,
'executableBranches' => 0,
'executedBranches' => 0,
'executablePaths' => 0,
'executedPaths' => 0,
'ccn' => $method['ccn'],
'ccn' => $method->cyclomaticComplexity(),
'coverage' => 0,
'crap' => 0,
'link' => $link . $method['startLine'],
'link' => $link . $method->startLine(),
];
$key = $className . '->' . $methodName;
$key = $className . '->' . $method->name();
if (isset($this->functionCoverageData[$key]['branches'])) {
$methodData['executableBranches'] = count(
+5 -21
View File
@@ -9,10 +9,13 @@
*/
namespace SebastianBergmann\CodeCoverage\Node;
use function assert;
use function count;
use RecursiveIterator;
/**
* @template-implements RecursiveIterator<int, AbstractNode>
*
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*/
final class Iterator implements RecursiveIterator
@@ -29,57 +32,38 @@ final class Iterator implements RecursiveIterator
$this->nodes = $node->children();
}
/**
* Rewinds the Iterator to the first element.
*/
public function rewind(): void
{
$this->position = 0;
}
/**
* Checks if there is a current element after calls to rewind() or next().
*/
public function valid(): bool
{
return $this->position < count($this->nodes);
}
/**
* Returns the key of the current element.
*/
public function key(): int
{
return $this->position;
}
/**
* Returns the current element.
*/
public function current(): ?AbstractNode
{
return $this->valid() ? $this->nodes[$this->position] : null;
}
/**
* Moves forward to next element.
*/
public function next(): void
{
$this->position++;
}
/**
* Returns the sub iterator for the current element.
*/
public function getChildren(): self
{
assert($this->nodes[$this->position] instanceof Directory);
return new self($this->nodes[$this->position]);
}
/**
* Checks whether the current element has children.
*/
public function hasChildren(): bool
{
return $this->nodes[$this->position] instanceof Directory;
+14 -18
View File
@@ -10,31 +10,31 @@
namespace SebastianBergmann\CodeCoverage\Report;
use function count;
use function dirname;
use function file_put_contents;
use function is_string;
use function ksort;
use function max;
use function range;
use function str_contains;
use function time;
use DOMDocument;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Driver\WriteOperationFailedException;
use SebastianBergmann\CodeCoverage\Node\File;
use SebastianBergmann\CodeCoverage\Util\Filesystem;
use SebastianBergmann\CodeCoverage\Util\Xml;
use SebastianBergmann\CodeCoverage\WriteOperationFailedException;
final class Clover
{
/**
* @param null|non-empty-string $target
* @param null|non-empty-string $name
*
* @throws WriteOperationFailedException
*/
public function process(CodeCoverage $coverage, ?string $target = null, ?string $name = null): string
{
$time = (string) time();
$xmlDocument = new DOMDocument('1.0', 'UTF-8');
$xmlDocument->formatOutput = true;
$xmlDocument = new DOMDocument('1.0', 'UTF-8');
$xmlCoverage = $xmlDocument->createElement('coverage');
$xmlCoverage->setAttribute('generated', $time);
@@ -79,6 +79,7 @@ final class Clover
}
foreach ($class['methods'] as $methodName => $method) {
/** @phpstan-ignore equal.notAllowed */
if ($method['executableLines'] == 0) {
continue;
}
@@ -87,6 +88,7 @@ final class Clover
$classStatements += $method['executableLines'];
$coveredClassStatements += $method['executedLines'];
/** @phpstan-ignore equal.notAllowed */
if ($method['coverage'] == 100) {
$coveredMethods++;
}
@@ -168,8 +170,8 @@ final class Clover
$linesOfCode = $item->linesOfCode();
$xmlMetrics = $xmlDocument->createElement('metrics');
$xmlMetrics->setAttribute('loc', (string) $linesOfCode['linesOfCode']);
$xmlMetrics->setAttribute('ncloc', (string) $linesOfCode['nonCommentLinesOfCode']);
$xmlMetrics->setAttribute('loc', (string) $linesOfCode->linesOfCode());
$xmlMetrics->setAttribute('ncloc', (string) $linesOfCode->nonCommentLinesOfCode());
$xmlMetrics->setAttribute('classes', (string) $item->numberOfClassesAndTraits());
$xmlMetrics->setAttribute('methods', (string) $item->numberOfMethods());
$xmlMetrics->setAttribute('coveredmethods', (string) $item->numberOfTestedMethods());
@@ -201,8 +203,8 @@ final class Clover
$xmlMetrics = $xmlDocument->createElement('metrics');
$xmlMetrics->setAttribute('files', (string) count($report));
$xmlMetrics->setAttribute('loc', (string) $linesOfCode['linesOfCode']);
$xmlMetrics->setAttribute('ncloc', (string) $linesOfCode['nonCommentLinesOfCode']);
$xmlMetrics->setAttribute('loc', (string) $linesOfCode->linesOfCode());
$xmlMetrics->setAttribute('ncloc', (string) $linesOfCode->nonCommentLinesOfCode());
$xmlMetrics->setAttribute('classes', (string) $report->numberOfClassesAndTraits());
$xmlMetrics->setAttribute('methods', (string) $report->numberOfMethods());
$xmlMetrics->setAttribute('coveredmethods', (string) $report->numberOfTestedMethods());
@@ -214,16 +216,10 @@ final class Clover
$xmlMetrics->setAttribute('coveredelements', (string) ($report->numberOfTestedMethods() + $report->numberOfExecutedLines() + $report->numberOfExecutedBranches()));
$xmlProject->appendChild($xmlMetrics);
$buffer = $xmlDocument->saveXML();
$buffer = Xml::asString($xmlDocument);
if ($target !== null) {
if (!str_contains($target, '://')) {
Filesystem::createDirectory(dirname($target));
}
if (@file_put_contents($target, $buffer) === false) {
throw new WriteOperationFailedException($target);
}
Filesystem::write($target, $buffer);
}
return $buffer;
+11 -18
View File
@@ -12,22 +12,22 @@ namespace SebastianBergmann\CodeCoverage\Report;
use const DIRECTORY_SEPARATOR;
use function basename;
use function count;
use function dirname;
use function file_put_contents;
use function preg_match;
use function range;
use function str_contains;
use function str_replace;
use function time;
use DOMImplementation;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Driver\WriteOperationFailedException;
use SebastianBergmann\CodeCoverage\Node\File;
use SebastianBergmann\CodeCoverage\Util\Filesystem;
use SebastianBergmann\CodeCoverage\Util\Xml;
use SebastianBergmann\CodeCoverage\WriteOperationFailedException;
final class Cobertura
{
/**
* @param null|non-empty-string $target
*
* @throws WriteOperationFailedException
*/
public function process(CodeCoverage $coverage, ?string $target = null): string
@@ -44,10 +44,9 @@ final class Cobertura
'http://cobertura.sourceforge.net/xml/coverage-04.dtd',
);
$document = $implementation->createDocument('', '', $documentType);
$document->xmlVersion = '1.0';
$document->encoding = 'UTF-8';
$document->formatOutput = true;
$document = $implementation->createDocument('', '', $documentType);
$document->xmlVersion = '1.0';
$document->encoding = 'UTF-8';
$coverageElement = $document->createElement('coverage');
@@ -153,7 +152,7 @@ final class Cobertura
$linesValid = $method['executableLines'];
$linesCovered = $method['executedLines'];
$lineRate = $linesValid === 0 ? 0 : ($linesCovered / $linesValid);
$lineRate = $linesCovered / $linesValid;
$branchesValid = $method['executableBranches'];
$branchesCovered = $method['executedBranches'];
@@ -228,7 +227,7 @@ final class Cobertura
$linesValid = $function['executableLines'];
$linesCovered = $function['executedLines'];
$lineRate = $linesValid === 0 ? 0 : ($linesCovered / $linesValid);
$lineRate = $linesCovered / $linesValid;
$functionsLinesValid += $linesValid;
$functionsLinesCovered += $linesCovered;
@@ -289,16 +288,10 @@ final class Cobertura
$coverageElement->setAttribute('complexity', (string) $complexity);
$buffer = $document->saveXML();
$buffer = Xml::asString($document);
if ($target !== null) {
if (!str_contains($target, '://')) {
Filesystem::createDirectory(dirname($target));
}
if (@file_put_contents($target, $buffer) === false) {
throw new WriteOperationFailedException($target);
}
Filesystem::write($target, $buffer);
}
return $buffer;
+11 -17
View File
@@ -10,21 +10,19 @@
namespace SebastianBergmann\CodeCoverage\Report;
use function date;
use function dirname;
use function file_put_contents;
use function htmlspecialchars;
use function is_string;
use function round;
use function str_contains;
use DOMDocument;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Driver\WriteOperationFailedException;
use SebastianBergmann\CodeCoverage\Node\File;
use SebastianBergmann\CodeCoverage\Util\Filesystem;
use SebastianBergmann\CodeCoverage\Util\Xml;
use SebastianBergmann\CodeCoverage\WriteOperationFailedException;
final class Crap4j
final readonly class Crap4j
{
private readonly int $threshold;
private int $threshold;
public function __construct(int $threshold = 30)
{
@@ -32,12 +30,14 @@ final class Crap4j
}
/**
* @param null|non-empty-string $target
* @param null|non-empty-string $name
*
* @throws WriteOperationFailedException
*/
public function process(CodeCoverage $coverage, ?string $target = null, ?string $name = null): string
{
$document = new DOMDocument('1.0', 'UTF-8');
$document->formatOutput = true;
$document = new DOMDocument('1.0', 'UTF-8');
$root = $document->createElement('crap_result');
$document->appendChild($root);
@@ -83,7 +83,7 @@ final class Crap4j
$methodNode = $document->createElement('method');
if (!empty($class['namespace'])) {
if ($class['namespace'] !== '') {
$namespace = $class['namespace'];
}
@@ -119,16 +119,10 @@ final class Crap4j
$root->appendChild($stats);
$root->appendChild($methodsNode);
$buffer = $document->saveXML();
$buffer = Xml::asString($document);
if ($target !== null) {
if (!str_contains($target, '://')) {
Filesystem::createDirectory(dirname($target));
}
if (@file_put_contents($target, $buffer) === false) {
throw new WriteOperationFailedException($target);
}
Filesystem::write($target, $buffer);
}
return $buffer;
@@ -12,13 +12,13 @@ namespace SebastianBergmann\CodeCoverage\Report\Html;
/**
* @immutable
*/
final class Colors
final readonly class Colors
{
private readonly string $successLow;
private readonly string $successMedium;
private readonly string $successHigh;
private readonly string $warning;
private readonly string $danger;
private string $successLow;
private string $successMedium;
private string $successHigh;
private string $warning;
private string $danger;
public static function default(): self
{
@@ -15,9 +15,9 @@ use SebastianBergmann\CodeCoverage\InvalidArgumentException;
/**
* @immutable
*/
final class CustomCssFile
final readonly class CustomCssFile
{
private readonly string $path;
private string $path;
public static function default(): self
{
@@ -22,13 +22,13 @@ use SebastianBergmann\CodeCoverage\Util\Filesystem;
use SebastianBergmann\Template\Exception;
use SebastianBergmann\Template\Template;
final class Facade
final readonly class Facade
{
private readonly string $templatePath;
private readonly string $generator;
private readonly Colors $colors;
private readonly Thresholds $thresholds;
private readonly CustomCssFile $customCssFile;
private string $templatePath;
private string $generator;
private Colors $colors;
private Thresholds $thresholds;
private CustomCssFile $customCssFile;
public function __construct(string $generator = '', ?Colors $colors = null, ?Thresholds $thresholds = null, ?CustomCssFile $customCssFile = null)
{
@@ -41,16 +41,17 @@ final class Facade
public function process(CodeCoverage $coverage, string $target): void
{
$target = $this->directory($target);
$report = $coverage->getReport();
$date = date('D M j G:i:s T Y');
$target = $this->directory($target);
$report = $coverage->getReport();
$date = date('D M j G:i:s T Y');
$hasBranchCoverage = $coverage->getData(true)->functionCoverage() !== [];
$dashboard = new Dashboard(
$this->templatePath,
$this->generator,
$date,
$this->thresholds,
$coverage->collectsBranchAndPathCoverage(),
$hasBranchCoverage,
);
$directory = new Directory(
@@ -58,7 +59,7 @@ final class Facade
$this->generator,
$date,
$this->thresholds,
$coverage->collectsBranchAndPathCoverage(),
$hasBranchCoverage,
);
$file = new File(
@@ -66,7 +67,7 @@ final class Facade
$this->generator,
$date,
$this->thresholds,
$coverage->collectsBranchAndPathCoverage(),
$hasBranchCoverage,
);
$directory->render($report, $target . 'index.html');
@@ -97,8 +98,8 @@ final class Facade
{
$dir = $this->directory($target . '_css');
copy($this->templatePath . 'css/billboard.min.css', $dir . 'billboard.min.css');
copy($this->templatePath . 'css/bootstrap.min.css', $dir . 'bootstrap.min.css');
copy($this->templatePath . 'css/nv.d3.min.css', $dir . 'nv.d3.min.css');
copy($this->customCssFile->path(), $dir . 'custom.css');
copy($this->templatePath . 'css/octicons.css', $dir . 'octicons.css');
@@ -107,10 +108,9 @@ final class Facade
copy($this->templatePath . 'icons/file-directory.svg', $dir . 'file-directory.svg');
$dir = $this->directory($target . '_js');
copy($this->templatePath . 'js/billboard.pkgd.min.js', $dir . 'billboard.pkgd.min.js');
copy($this->templatePath . 'js/bootstrap.bundle.min.js', $dir . 'bootstrap.bundle.min.js');
copy($this->templatePath . 'js/d3.min.js', $dir . 'd3.min.js');
copy($this->templatePath . 'js/jquery.min.js', $dir . 'jquery.min.js');
copy($this->templatePath . 'js/nv.d3.min.js', $dir . 'nv.d3.min.js');
copy($this->templatePath . 'js/file.js', $dir . 'file.js');
}
@@ -130,12 +130,14 @@ final class Facade
try {
$template->renderTo($this->directory($target . '_css') . 'style.css');
// @codeCoverageIgnoreStart
} catch (Exception $e) {
throw new FileCouldNotBeWrittenException(
$e->getMessage(),
$e->getCode(),
$e,
);
// @codeCoverageIgnoreEnd
}
}
@@ -44,6 +44,9 @@ abstract class Renderer
$this->hasBranchCoverage = $hasBranchCoverage;
}
/**
* @param array<non-empty-string, float|int|string> $data
*/
protected function renderItemTemplate(Template $template, array $data): string
{
$numSeparator = '&nbsp;/&nbsp;';
@@ -171,8 +174,8 @@ abstract class Renderer
'version' => $this->version,
'runtime' => $this->runtimeString(),
'generator' => $this->generator,
'low_upper_bound' => $this->thresholds->lowUpperBound(),
'high_lower_bound' => $this->thresholds->highLowerBound(),
'low_upper_bound' => (string) $this->thresholds->lowUpperBound(),
'high_lower_bound' => (string) $this->thresholds->highLowerBound(),
],
);
}
@@ -10,21 +10,27 @@
namespace SebastianBergmann\CodeCoverage\Report\Html;
use function array_values;
use function arsort;
use function asort;
use function assert;
use function count;
use function explode;
use function floor;
use function json_encode;
use function sprintf;
use function str_replace;
use function uasort;
use function usort;
use SebastianBergmann\CodeCoverage\FileCouldNotBeWrittenException;
use SebastianBergmann\CodeCoverage\Node\AbstractNode;
use SebastianBergmann\CodeCoverage\Node\Directory as DirectoryNode;
use SebastianBergmann\CodeCoverage\Node\File as FileNode;
use SebastianBergmann\Template\Exception;
use SebastianBergmann\Template\Template;
/**
* @phpstan-import-type ProcessedClassType from FileNode
* @phpstan-import-type ProcessedTraitType from FileNode
*
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*/
final class Dashboard extends Renderer
@@ -81,7 +87,9 @@ final class Dashboard extends Renderer
}
/**
* Returns the data for the Class/Method Complexity charts.
* @param array<string, ProcessedClassType|ProcessedTraitType> $classes
*
* @return array{class: non-empty-string, method: non-empty-string}
*/
private function complexity(array $classes, string $baseLink): array
{
@@ -96,33 +104,39 @@ final class Dashboard extends Renderer
$result['method'][] = [
$method['coverage'],
$method['ccn'],
sprintf(
'<a href="%s">%s</a>',
str_replace($baseLink, '', $method['link']),
$methodName,
),
str_replace($baseLink, '', $method['link']),
$methodName,
$method['crap'],
];
}
$result['class'][] = [
$class['coverage'],
$class['ccn'],
sprintf(
'<a href="%s">%s</a>',
str_replace($baseLink, '', $class['link']),
$className,
),
str_replace($baseLink, '', $class['link']),
$className,
$class['crap'],
];
}
return [
'class' => json_encode($result['class']),
'method' => json_encode($result['method']),
];
usort($result['class'], static fn (mixed $a, mixed $b) => ($a[0] <=> $b[0]));
usort($result['method'], static fn (mixed $a, mixed $b) => ($a[0] <=> $b[0]));
$class = json_encode($result['class']);
assert($class !== false);
$method = json_encode($result['method']);
assert($method !== false);
return ['class' => $class, 'method' => $method];
}
/**
* Returns the data for the Class / Method Coverage Distribution chart.
* @param array<string, ProcessedClassType|ProcessedTraitType> $classes
*
* @return array{class: non-empty-string, method: non-empty-string}
*/
private function coverageDistribution(array $classes): array
{
@@ -181,14 +195,21 @@ final class Dashboard extends Renderer
}
}
return [
'class' => json_encode(array_values($result['class'])),
'method' => json_encode(array_values($result['method'])),
];
$class = json_encode(array_values($result['class']));
assert($class !== false);
$method = json_encode(array_values($result['method']));
assert($method !== false);
return ['class' => $class, 'method' => $method];
}
/**
* Returns the classes / methods with insufficient coverage.
* @param array<string, ProcessedClassType|ProcessedTraitType> $classes
*
* @return array{class: string, method: string}
*/
private function insufficientCoverage(array $classes, string $baseLink): array
{
@@ -242,7 +263,9 @@ final class Dashboard extends Renderer
}
/**
* Returns the project risks according to the CRAP index.
* @param array<string, ProcessedClassType|ProcessedTraitType> $classes
*
* @return array{class: string, method: string}
*/
private function projectRisks(array $classes, string $baseLink): array
{
@@ -259,37 +282,47 @@ final class Dashboard extends Renderer
$key = $className . '::' . $methodName;
}
$methodRisks[$key] = $method['crap'];
$methodRisks[$key] = $method;
}
}
if ($class['coverage'] < $this->thresholds->highLowerBound() &&
$class['ccn'] > count($class['methods'])) {
$classRisks[$className] = $class['crap'];
$classRisks[$className] = $class;
}
}
arsort($classRisks);
arsort($methodRisks);
uasort($classRisks, static function (array $a, array $b)
{
return ((int) ($a['crap']) <=> (int) ($b['crap'])) * -1;
});
uasort($methodRisks, static function (array $a, array $b)
{
return ((int) ($a['crap']) <=> (int) ($b['crap'])) * -1;
});
foreach ($classRisks as $className => $crap) {
foreach ($classRisks as $className => $class) {
$result['class'] .= sprintf(
' <tr><td><a href="%s">%s</a></td><td class="text-right">%d</td></tr>' . "\n",
' <tr><td><a href="%s">%s</a></td><td class="text-right">%.1f%%</td><td class="text-right">%d</td><td class="text-right">%d</td></tr>' . "\n",
str_replace($baseLink, '', $classes[$className]['link']),
$className,
$crap,
$class['coverage'],
$class['ccn'],
$class['crap'],
);
}
foreach ($methodRisks as $methodName => $crap) {
foreach ($methodRisks as $methodName => $methodVals) {
[$class, $method] = explode('::', $methodName);
$result['method'] .= sprintf(
' <tr><td><a href="%s"><abbr title="%s">%s</abbr></a></td><td class="text-right">%d</td></tr>' . "\n",
' <tr><td><a href="%s"><abbr title="%s">%s</abbr></a></td><td class="text-right">%.1f%%</td><td class="text-right">%d</td><td class="text-right">%d</td></tr>' . "\n",
str_replace($baseLink, '', $classes[$class]['methods'][$method]['link']),
$methodName,
$method,
$crap,
$methodVals['coverage'],
$methodVals['ccn'],
$methodVals['crap'],
);
}
@@ -109,6 +109,11 @@ use SebastianBergmann\Template\Exception;
use SebastianBergmann\Template\Template;
/**
* @phpstan-import-type ProcessedClassType from FileNode
* @phpstan-import-type ProcessedTraitType from FileNode
* @phpstan-import-type ProcessedMethodType from FileNode
* @phpstan-import-type ProcessedFunctionType from FileNode
*
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*/
final class File extends Renderer
@@ -116,7 +121,7 @@ final class File extends Renderer
/**
* @var array<int,true>
*/
private const KEYWORD_TOKENS = [
private const array KEYWORD_TOKENS = [
T_ABSTRACT => true,
T_ARRAY => true,
T_AS => true,
@@ -186,8 +191,13 @@ final class File extends Renderer
T_YIELD => true,
T_YIELD_FROM => true,
];
private const int HTML_SPECIAL_CHARS_FLAGS = ENT_COMPAT | ENT_HTML401 | ENT_SUBSTITUTE;
/**
* @var array<non-empty-string, list<string>>
*/
private static array $formattedSourceCache = [];
private int $htmlSpecialCharsFlags = ENT_COMPAT | ENT_HTML401 | ENT_SUBSTITUTE;
public function render(FileNode $node, string $file): void
{
@@ -315,11 +325,14 @@ final class File extends Renderer
return $items;
}
/**
* @param array<string, ProcessedClassType|ProcessedTraitType> $items
*/
private function renderTraitOrClassItems(array $items, Template $template, Template $methodItemTemplate): string
{
$buffer = '';
if (empty($items)) {
if ($items === []) {
return $buffer;
}
@@ -419,9 +432,12 @@ final class File extends Renderer
return $buffer;
}
/**
* @param array<string, ProcessedFunctionType> $functions
*/
private function renderFunctionItems(array $functions, Template $template): string
{
if (empty($functions)) {
if ($functions === []) {
return '';
}
@@ -437,6 +453,9 @@ final class File extends Renderer
return $buffer;
}
/**
* @param ProcessedFunctionType|ProcessedMethodType $item
*/
private function renderFunctionOrMethodItem(Template $template, array $item, string $indent = ''): string
{
$numMethods = 0;
@@ -477,7 +496,7 @@ final class File extends Renderer
'%s<a href="#%d"><abbr title="%s">%s</abbr></a>',
$indent,
$item['startLine'],
htmlspecialchars($item['signature'], $this->htmlSpecialCharsFlags),
htmlspecialchars($item['signature'], self::HTML_SPECIAL_CHARS_FLAGS),
$item['functionName'] ?? $item['methodName'],
),
'numMethods' => $numMethods,
@@ -518,7 +537,7 @@ final class File extends Renderer
$popoverTitle = '';
if (array_key_exists($i, $coverageData)) {
$numTests = ($coverageData[$i] ? count($coverageData[$i]) : 0);
$numTests = ($coverageData[$i] !== null ? count($coverageData[$i]) : 0);
if ($coverageData[$i] === null) {
$trClass = 'warning';
@@ -551,11 +570,11 @@ final class File extends Renderer
$popover = '';
if (!empty($popoverTitle)) {
if ($popoverTitle !== '') {
$popover = sprintf(
' data-bs-title="%s" data-bs-content="%s" data-bs-placement="top" data-bs-html="true"',
$popoverTitle,
htmlspecialchars($popoverContent, $this->htmlSpecialCharsFlags),
htmlspecialchars($popoverContent, self::HTML_SPECIAL_CHARS_FLAGS),
);
}
@@ -580,7 +599,6 @@ final class File extends Renderer
$lineData = [];
/** @var int $line */
foreach (array_keys($codeLines) as $line) {
$lineData[$line + 1] = [
'includedInBranches' => 0,
@@ -642,7 +660,7 @@ final class File extends Renderer
$popover = sprintf(
' data-bs-title="%s" data-bs-content="%s" data-bs-placement="top" data-bs-html="true"',
$popoverTitle,
htmlspecialchars($popoverContent, $this->htmlSpecialCharsFlags),
htmlspecialchars($popoverContent, self::HTML_SPECIAL_CHARS_FLAGS),
);
}
@@ -667,7 +685,6 @@ final class File extends Renderer
$lineData = [];
/** @var int $line */
foreach (array_keys($codeLines) as $line) {
$lineData[$line + 1] = [
'includedInPaths' => [],
@@ -732,7 +749,7 @@ final class File extends Renderer
$popover = sprintf(
' data-bs-title="%s" data-bs-content="%s" data-bs-placement="top" data-bs-html="true"',
$popoverTitle,
htmlspecialchars($popoverContent, $this->htmlSpecialCharsFlags),
htmlspecialchars($popoverContent, self::HTML_SPECIAL_CHARS_FLAGS),
);
}
@@ -769,7 +786,7 @@ final class File extends Renderer
}
if ($branchStructure !== '') { // don't show empty branches
$branches .= '<h5 class="structure-heading"><a name="' . htmlspecialchars($methodName, $this->htmlSpecialCharsFlags) . '">' . $this->abbreviateMethodName($methodName) . '</a></h5>' . "\n";
$branches .= '<h5 class="structure-heading"><a name="' . htmlspecialchars($methodName, self::HTML_SPECIAL_CHARS_FLAGS) . '">' . $this->abbreviateMethodName($methodName) . '</a></h5>' . "\n";
$branches .= $branchStructure;
}
}
@@ -779,6 +796,9 @@ final class File extends Renderer
return $branchesTemplate->render();
}
/**
* @param list<string> $codeLines
*/
private function renderBranchLines(array $branch, array $codeLines, array $testData): string
{
$linesTemplate = new Template($this->templatePath . 'lines.html.dist', '{{', '}}');
@@ -826,11 +846,11 @@ final class File extends Renderer
$popover = '';
if (!empty($popoverTitle)) {
if ($popoverTitle !== '') {
$popover = sprintf(
' data-bs-title="%s" data-bs-content="%s" data-bs-placement="top" data-bs-html="true"',
$popoverTitle,
htmlspecialchars($popoverContent, $this->htmlSpecialCharsFlags),
htmlspecialchars($popoverContent, self::HTML_SPECIAL_CHARS_FLAGS),
);
}
@@ -875,7 +895,7 @@ final class File extends Renderer
}
if ($pathStructure !== '') {
$paths .= '<h5 class="structure-heading"><a name="' . htmlspecialchars($methodName, $this->htmlSpecialCharsFlags) . '">' . $this->abbreviateMethodName($methodName) . '</a></h5>' . "\n";
$paths .= '<h5 class="structure-heading"><a name="' . htmlspecialchars($methodName, self::HTML_SPECIAL_CHARS_FLAGS) . '">' . $this->abbreviateMethodName($methodName) . '</a></h5>' . "\n";
$paths .= $pathStructure;
}
}
@@ -885,6 +905,9 @@ final class File extends Renderer
return $pathsTemplate->render();
}
/**
* @param list<string> $codeLines
*/
private function renderPathLines(array $path, array $branches, array $codeLines, array $testData): string
{
$linesTemplate = new Template($this->templatePath . 'lines.html.dist', '{{', '}}');
@@ -941,11 +964,11 @@ final class File extends Renderer
$popover = '';
if (!empty($popoverTitle)) {
if ($popoverTitle !== '') {
$popover = sprintf(
' data-bs-title="%s" data-bs-content="%s" data-bs-placement="top" data-bs-html="true"',
$popoverTitle,
htmlspecialchars($popoverContent, $this->htmlSpecialCharsFlags),
htmlspecialchars($popoverContent, self::HTML_SPECIAL_CHARS_FLAGS),
);
}
@@ -966,7 +989,7 @@ final class File extends Renderer
{
$template->setVar(
[
'lineNumber' => $lineNumber,
'lineNumber' => (string) $lineNumber,
'lineContent' => $lineContent,
'class' => $class,
'popover' => $popover,
@@ -976,6 +999,11 @@ final class File extends Renderer
return $template->render();
}
/**
* @param non-empty-string $file
*
* @return list<string>
*/
private function loadFile(string $file): array
{
if (isset(self::$formattedSourceCache[$file])) {
@@ -996,14 +1024,14 @@ final class File extends Renderer
if ($token === '"' && $tokens[$j - 1] !== '\\') {
$result[$i] .= sprintf(
'<span class="string">%s</span>',
htmlspecialchars($token, $this->htmlSpecialCharsFlags),
htmlspecialchars($token, self::HTML_SPECIAL_CHARS_FLAGS),
);
$stringFlag = !$stringFlag;
} else {
$result[$i] .= sprintf(
'<span class="keyword">%s</span>',
htmlspecialchars($token, $this->htmlSpecialCharsFlags),
htmlspecialchars($token, self::HTML_SPECIAL_CHARS_FLAGS),
);
}
@@ -1015,7 +1043,7 @@ final class File extends Renderer
$value = str_replace(
["\t", ' '],
['&nbsp;&nbsp;&nbsp;&nbsp;', '&nbsp;'],
htmlspecialchars($value, $this->htmlSpecialCharsFlags),
htmlspecialchars($value, self::HTML_SPECIAL_CHARS_FLAGS),
);
if ($value === "\n") {
@@ -1114,7 +1142,7 @@ final class File extends Renderer
return sprintf(
'<li%s>%s</li>',
$testCSS,
htmlspecialchars($test, $this->htmlSpecialCharsFlags),
htmlspecialchars($test, self::HTML_SPECIAL_CHARS_FLAGS),
);
}
File diff suppressed because one or more lines are too long
@@ -1,13 +1,76 @@
:root {
--phpunit-breadcrumbs: var(--bs-gray-200);
--phpunit-success-bar: #28a745;
--phpunit-success-high: {{success-high}};
--phpunit-success-medium: {{success-medium}};
--phpunit-success-low: {{success-low}};
--phpunit-warning: {{warning}};
--phpunit-warning-bar: #ffc107;
--phpunit-danger: {{danger}};
--phpunit-danger-bar: #dc3545;
/* Implementing an auto-selection of dark/light theme via: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/light-dark */
color-scheme: light dark;
/* PHPUnit ligh/dark colors */
--phpunit-breadcrumbs: light-dark(var(--bs-gray-200), var(--bs-gray-800));
--phpunit-success-bar: light-dark(#28a745 ,#1f8135);
--phpunit-success-high: light-dark(#99cb84, #3d5c4e);
--phpunit-success-medium: light-dark(#c3e3b5,#3c6051);
--phpunit-success-low: light-dark(#dff0d8, #2d4431);
--phpunit-warning: light-dark(#fcf8e3, #3e3408);
--phpunit-warning-bar: light-dark(#ffc107 ,#c19406);
--phpunit-danger: light-dark(#f2dede, #42221e);
--phpunit-danger-bar: light-dark(#dc3545, #a62633);
/* Bootstrap v5.3 default colors (ligth, dark) */
--bs-body-bg-rgb: light-dark((255, 255, 255), (33, 37, 41));
--bs-body-bg: light-dark(#fff, #212529);
--bs-body-color-rgb: light-dark(33, 37, 41, 222, 226, 230);
--bs-body-color: light-dark(#212529, #dee2e6);
--bs-border-color-translucent: light-dark(rgba(0, 0, 0, 0.175), rgba(255, 255, 255, 0.15));
--bs-border-color: light-dark(#dee2e6, #495057);
--bs-code-color: light-dark(#d63384, #e685b5);
--bs-danger-bg-subtle: light-dark(#f8d7da, #2c0b0e);
--bs-danger-border-subtle: light-dark(#f1aeb5, #842029);
--bs-danger-text-emphasis: light-dark(#58151c, #ea868f);
--bs-dark-bg-subtle: light-dark(#ced4da, #1a1d20);
--bs-dark-border-subtle: light-dark(#adb5bd, #343a40);
--bs-dark-text-emphasis: light-dark(#495057, #dee2e6);
--bs-emphasis-color-rgb: light-dark((0, 0, 0), (255, 255, 255));
--bs-emphasis-color: light-dark(#000, #fff);
--bs-form-invalid-border-color: light-dark(#dc3545, #ea868f);
--bs-form-invalid-color: light-dark(#dc3545, #ea868f);
--bs-form-valid-border-color: light-dark(#198754, #75b798);
--bs-form-valid-color: light-dark(#198754, #75b798);
--bs-highlight-bg: light-dark(#fff3cd, #664d03);
--bs-highlight-color: light-dark(#212529, #dee2e6);
--bs-info-bg-subtle: light-dark(#cff4fc, #032830);
--bs-info-border-subtle: light-dark(#9eeaf9, #087990);
--bs-info-text-emphasis: light-dark(#055160, #6edff6);
--bs-light-bg-subtle: light-dark(#fcfcfd, #343a40);
--bs-light-border-subtle: light-dark(#e9ecef, #495057);
--bs-light-text-emphasis: light-dark(#495057, #f8f9fa);
--bs-link-color-rgb: light-dark((13, 110, 253), (110, 168, 254));
--bs-link-color: light-dark(#0d6efd, #6ea8fe);
--bs-link-hover-color-rgb: light-dark((10, 88, 202), (139, 185, 254));
--bs-link-hover-color: light-dark(#0a58ca, #8bb9fe);
--bs-primary-bg-subtle: light-dark(#cfe2ff, #031633);
--bs-primary-border-subtle: light-dark(#9ec5fe, #084298);
--bs-primary-text-emphasis: light-dark(#052c65, #6ea8fe);
--bs-secondary-bg-rgb: light-dark((233, 236, 239), (52, 58, 64));
--bs-secondary-bg-subtle: light-dark(#e2e3e5, #161719);
--bs-secondary-bg: light-dark(#e9ecef, #343a40);
--bs-secondary-border-subtle: light-dark(#c4c8cb, #41464b);
--bs-secondary-color-rgb: light-dark((33, 37, 41), (222, 226, 230));
--bs-secondary-color: light-dark(rgba(33, 37, 41, 0.75), rgba(222, 226, 230, 0.75));
--bs-secondary-text-emphasis: light-dark(#2b2f32, #a7acb1);
--bs-success-bg-subtle: light-dark(#d1e7dd, #051b11);
--bs-success-border-subtle: light-dark(#a3cfbb, #0f5132);
--bs-success-text-emphasis: light-dark(#0a3622, #75b798);
--bs-tertiary-bg-rgb: light-dark(248, 249, 250, 43, 48, 53);
--bs-tertiary-bg: light-dark(#f8f9fa, #2b3035);
--bs-tertiary-color-rgb: light-dark((33, 37, 41), (222, 226, 230));
--bs-tertiary-color: light-dark(rgba(33, 37, 41, 0.5), rgba(222, 226, 230, 0.5));
--bs-warning-bg-subtle: light-dark(#fff3cd, #332701);
--bs-warning-border-subtle: light-dark(#ffe69c, #997404);
--bs-warning-text-emphasis: light-dark(#664d03, #ffda6a);
}
@media (prefers-color-scheme: dark) {
/* Invert icon's colors on dark mode to improve readability */
img.octicon { filter: invert(1); }
}
body {
@@ -198,4 +261,4 @@ table#code td:first-of-type a {
.progress-bar.bg-danger {
background-color: var(--phpunit-danger-bar) !important;
}
}
@@ -5,7 +5,7 @@
<title>Dashboard for {{full_path}}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="{{path_to_root}}_css/bootstrap.min.css?v={{version}}" rel="stylesheet" type="text/css">
<link href="{{path_to_root}}_css/nv.d3.min.css?v={{version}}" rel="stylesheet" type="text/css">
<link href="{{path_to_root}}_css/billboard.min.css?v={{version}}" rel="stylesheet" type="text/css">
<link href="{{path_to_root}}_css/style.css?v={{version}}" rel="stylesheet" type="text/css">
<link href="{{path_to_root}}_css/custom.css" rel="stylesheet" type="text/css">
</head>
@@ -32,7 +32,7 @@
<div class="row">
<div class="col-md-6">
<h3>Coverage Distribution</h3>
<div id="classCoverageDistribution" style="height: 300px;">
<div id="classesCoverageDistribution" style="height: 300px;">
<svg></svg>
</div>
</div>
@@ -45,7 +45,7 @@
</div>
<div class="row">
<div class="col-md-6">
<h3>Insufficient Coverage</h3>
<h3 style="margin-top: 2rem;">Insufficient Coverage</h3>
<div class="scrollbox">
<table class="table">
<thead>
@@ -61,12 +61,14 @@
</div>
</div>
<div class="col-md-6">
<h3>Project Risks</h3>
<h3 style="margin-top: 2rem;">Project Risks</h3>
<div class="scrollbox">
<table class="table">
<thead>
<tr>
<th>Class</th>
<th class="text-right">Coverage</th>
<th class="text-right">Complexity</th>
<th class="text-right"><abbr title="Change Risk Anti-Patterns (CRAP) Index">CRAP</abbr></th>
</tr>
</thead>
@@ -79,13 +81,13 @@
</div>
<div class="row">
<div class="col-md-12">
<h2>Methods</h2>
<h2 style="margin-top: 3rem;">Methods</h2>
</div>
</div>
<div class="row">
<div class="col-md-6">
<h3>Coverage Distribution</h3>
<div id="methodCoverageDistribution" style="height: 300px;">
<div id="methodsCoverageDistribution" style="height: 300px;">
<svg></svg>
</div>
</div>
@@ -98,7 +100,7 @@
</div>
<div class="row">
<div class="col-md-6">
<h3>Insufficient Coverage</h3>
<h3 style="margin-top: 2rem;">Insufficient Coverage</h3>
<div class="scrollbox">
<table class="table">
<thead>
@@ -114,12 +116,14 @@
</div>
</div>
<div class="col-md-6">
<h3>Project Risks</h3>
<h3 style="margin-top: 2rem;">Project Risks</h3>
<div class="scrollbox">
<table class="table">
<thead>
<tr>
<th>Method</th>
<th class="text-right">Coverage</th>
<th class="text-right">Complexity</th>
<th class="text-right"><abbr title="Change Risk Anti-Patterns (CRAP) Index">CRAP</abbr></th>
</tr>
</thead>
@@ -137,145 +141,159 @@
</p>
</footer>
</div>
<script src="{{path_to_root}}_js/jquery.min.js?v={{version}}" type="text/javascript"></script>
<script src="{{path_to_root}}_js/d3.min.js?v={{version}}" type="text/javascript"></script>
<script src="{{path_to_root}}_js/nv.d3.min.js?v={{version}}" type="text/javascript"></script>
<script type="text/javascript">
$(document).ready(function() {
nv.addGraph(function() {
var chart = nv.models.multiBarChart();
chart.tooltips(false)
.showControls(false)
.showLegend(false)
.reduceXTicks(false)
.staggerLabels(true)
.yAxis.tickFormat(d3.format('d'));
d3.select('#classCoverageDistribution svg')
.datum(getCoverageDistributionData({{class_coverage_distribution}}, "Class Coverage"))
.transition().duration(500).call(chart);
nv.utils.windowResize(chart.update);
return chart;
<script src="{{path_to_root}}_js/billboard.pkgd.min.js?v={{version}}" type="text/javascript"></script>
<script type="application/json" id="complexity_class">{{complexity_class}}</script>
<script type="application/json" id="complexity_method">{{complexity_method}}</script>
<script type="text/javascript" defer>
const barLabels = [
'0%',
'0-10%',
'10-20%',
'20-30%',
'30-40%',
'40-50%',
'50-60%',
'60-70%',
'70-80%',
'80-90%',
'90-100%',
'100%'
];
const barConfig = (name, fullName, values) => ({
axis: {
x: {
type: "category",
categories: barLabels,
},
y: {
label: {
text: `#${name}`,
position: 'outer-top',
},
},
},
bindto: `#${name}CoverageDistribution`,
data: {
columns: [
[fullName].concat(values),
],
colors: {
[fullName]: "rgba(69, 114, 167, 0.75)",
},
type: "bar",
},
bar: {
width: {
ratio: 0.9,
},
},
grid: {
focus: {
show: false,
},
x: {
show: true,
},
y: {
show: true,
},
},
legend: {
show: false,
},
tooltip: {
contents: function (data) {
return `<table class="bb-tooltip"><tbody>
<tr><th colspan="2">Coverage ${barLabels[data[0].x]}</th></tr>
<tr><td class="value">${data[0].value} ${name}</td></tr>
</tbody></table>`;
},
grouped: false,
},
});
bb.generate(
barConfig('classes', "Class Coverage", {{class_coverage_distribution}})
);
bb.generate(
barConfig('methods', "Method Coverage", {{method_coverage_distribution}})
);
nv.addGraph(function() {
var chart = nv.models.multiBarChart();
chart.tooltips(false)
.showControls(false)
.showLegend(false)
.reduceXTicks(false)
.staggerLabels(true)
.yAxis.tickFormat(d3.format('d'));
d3.select('#methodCoverageDistribution svg')
.datum(getCoverageDistributionData({{method_coverage_distribution}}, "Method Coverage"))
.transition().duration(500).call(chart);
nv.utils.windowResize(chart.update);
return chart;
const scatterConfig = (name, complexityData) => ({
axis: {
x: {
label: {
text: 'Code Coverage (in percent)',
position: 'outer-right',
},
tick: {
values: [0, 20, 40, 60, 80, 100],
},
},
y: {
label: {
text: 'Cyclomatic Complexity',
position: 'outer-top',
},
min: 0,
padding: {
bottom: 0,
top: 5,
},
},
},
bindto: `#${name}Complexity`,
data: {
columns: [
["complexity_x"].concat(complexityData.map(d => d[0])),
["complexity"].concat(complexityData.map(d => d[1])),
],
onclick: function(data, element) {
window.location = complexityData[data.index][2];
},
type: "scatter",
xs: {
"complexity": "complexity_x",
},
},
grid: {
focus: {
show: true,
y: true,
},
x: {
show: true,
},
y: {
show: true,
},
},
legend: {
show: false,
},
tooltip: {
contents: function (data) {
const coverage = Math.round(data[0].x);
const complexity = data[0].value;
const targetName = complexityData[data[0].index][3];
const crap = complexityData[data[0].index][4];
return `<table class="bb-tooltip"><tbody>
<tr><th colspan="2">${targetName}</th></tr>
<tr class="bb-tooltip-name-complexity"><td>Coverage</td><td class="name">${coverage}%</td></tr>
<tr class="bb-tooltip-name-complexity"><td>Complexity</td><td class="value">${complexity}</td></tr>
<tr class="bb-tooltip-name-complexity"><td>Crap</td><td class="value">${crap}</td></tr>
</tbody></table>`;
},
grouped: false,
},
});
function getCoverageDistributionData(data, label) {
var labels = [
'0%',
'0-10%',
'10-20%',
'20-30%',
'30-40%',
'40-50%',
'50-60%',
'60-70%',
'70-80%',
'80-90%',
'90-100%',
'100%'
];
var values = [];
$.each(labels, function(key) {
values.push({x: labels[key], y: data[key]});
});
return [
{
key: label,
values: values,
color: "#4572A7"
}
];
}
nv.addGraph(function() {
var chart = nv.models.scatterChart()
.showDistX(true)
.showDistY(true)
.showLegend(false)
.forceX([0, 100]);
chart.tooltipContent(function(graph) {
return '<p>' + graph.point.class + '</p>';
});
chart.xAxis.axisLabel('Code Coverage (in percent)');
chart.yAxis.axisLabel('Cyclomatic Complexity');
d3.select('#classComplexity svg')
.datum(getComplexityData({{complexity_class}}, 'Class Complexity'))
.transition()
.duration(500)
.call(chart);
nv.utils.windowResize(chart.update);
return chart;
});
nv.addGraph(function() {
var chart = nv.models.scatterChart()
.showDistX(true)
.showDistY(true)
.showLegend(false)
.forceX([0, 100]);
chart.tooltipContent(function(graph) {
return '<p>' + graph.point.class + '</p>';
});
chart.xAxis.axisLabel('Code Coverage (in percent)');
chart.yAxis.axisLabel('Method Complexity');
d3.select('#methodComplexity svg')
.datum(getComplexityData({{complexity_method}}, 'Method Complexity'))
.transition()
.duration(500)
.call(chart);
nv.utils.windowResize(chart.update);
return chart;
});
function getComplexityData(data, label) {
var values = [];
$.each(data, function(key) {
var value = Math.round(data[key][0]*100) / 100;
values.push({
x: value,
y: data[key][1],
class: data[key][2],
size: 0.05,
shape: 'diamond'
});
});
return [
{
key: label,
values: values,
color: "#4572A7"
}
];
}
});
const classComplexityData = JSON.parse(document.getElementById('complexity_class').textContent);
bb.generate(
scatterConfig("class", classComplexityData)
);
const methodComplexityData = JSON.parse(document.getElementById('complexity_method').textContent);
bb.generate(
scatterConfig("method", methodComplexityData),
);
</script>
</body>
</html>
@@ -5,7 +5,7 @@
<title>Dashboard for {{full_path}}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="{{path_to_root}}_css/bootstrap.min.css?v={{version}}" rel="stylesheet" type="text/css">
<link href="{{path_to_root}}_css/nv.d3.min.css?v={{version}}" rel="stylesheet" type="text/css">
<link href="{{path_to_root}}_css/billboard.min.css?v={{version}}" rel="stylesheet" type="text/css">
<link href="{{path_to_root}}_css/style.css?v={{version}}" rel="stylesheet" type="text/css">
<link href="{{path_to_root}}_css/custom.css" rel="stylesheet" type="text/css">
</head>
@@ -32,7 +32,7 @@
<div class="row">
<div class="col-md-6">
<h3>Coverage Distribution</h3>
<div id="classCoverageDistribution" style="height: 300px;">
<div id="classesCoverageDistribution" style="height: 300px;">
<svg></svg>
</div>
</div>
@@ -45,7 +45,7 @@
</div>
<div class="row">
<div class="col-md-6">
<h3>Insufficient Coverage</h3>
<h3 style="margin-top: 2rem;">Insufficient Coverage</h3>
<div class="scrollbox">
<table class="table">
<thead>
@@ -61,7 +61,7 @@
</div>
</div>
<div class="col-md-6">
<h3>Project Risks</h3>
<h3 style="margin-top: 2rem;">Project Risks</h3>
<div class="scrollbox">
<table class="table">
<thead>
@@ -79,13 +79,13 @@
</div>
<div class="row">
<div class="col-md-12">
<h2>Methods</h2>
<h2 style="margin-top: 3rem;">Methods</h2>
</div>
</div>
<div class="row">
<div class="col-md-6">
<h3>Coverage Distribution</h3>
<div id="methodCoverageDistribution" style="height: 300px;">
<div id="methodsCoverageDistribution" style="height: 300px;">
<svg></svg>
</div>
</div>
@@ -98,7 +98,7 @@
</div>
<div class="row">
<div class="col-md-6">
<h3>Insufficient Coverage</h3>
<h3 style="margin-top: 2rem;">Insufficient Coverage</h3>
<div class="scrollbox">
<table class="table">
<thead>
@@ -114,7 +114,7 @@
</div>
</div>
<div class="col-md-6">
<h3>Project Risks</h3>
<h3 style="margin-top: 2rem;">Project Risks</h3>
<div class="scrollbox">
<table class="table">
<thead>
@@ -137,145 +137,157 @@
</p>
</footer>
</div>
<script src="{{path_to_root}}_js/jquery.min.js?v={{version}}" type="text/javascript"></script>
<script src="{{path_to_root}}_js/d3.min.js?v={{version}}" type="text/javascript"></script>
<script src="{{path_to_root}}_js/nv.d3.min.js?v={{version}}" type="text/javascript"></script>
<script type="text/javascript">
$(document).ready(function() {
nv.addGraph(function() {
var chart = nv.models.multiBarChart();
chart.tooltips(false)
.showControls(false)
.showLegend(false)
.reduceXTicks(false)
.staggerLabels(true)
.yAxis.tickFormat(d3.format('d'));
d3.select('#classCoverageDistribution svg')
.datum(getCoverageDistributionData({{class_coverage_distribution}}, "Class Coverage"))
.transition().duration(500).call(chart);
nv.utils.windowResize(chart.update);
return chart;
<script src="_js/billboard.pkgd.min.js?v={{version}}" type="text/javascript"></script>
<script type="application/json" id="complexity_class">{{complexity_class}}</script>
<script type="application/json" id="complexity_method">{{complexity_method}}</script>
<script type="text/javascript" defer>
const barLabels = [
'0%',
'0-10%',
'10-20%',
'20-30%',
'30-40%',
'40-50%',
'50-60%',
'60-70%',
'70-80%',
'80-90%',
'90-100%',
'100%'
];
const barConfig = (name, fullName, values) => ({
axis: {
x: {
type: "category",
categories: barLabels,
},
y: {
label: {
text: `#${name}`,
position: 'outer-top',
},
},
},
bindto: `#${name}CoverageDistribution`,
data: {
columns: [
[fullName].concat(values),
],
colors: {
[fullName]: "rgba(69, 114, 167, 0.75)",
},
type: "bar",
},
bar: {
width: {
ratio: 0.9,
},
},
grid: {
focus: {
show: false,
},
x: {
show: true,
},
y: {
show: true,
},
},
legend: {
show: false,
},
tooltip: {
contents: function (data) {
return `<table class="bb-tooltip"><tbody>
<tr><th colspan="2">Coverage ${barLabels[data[0].x]}</th></tr>
<tr><td class="value">${data[0].value} ${name}</td></tr>
</tbody></table>`;
},
grouped: false,
},
});
bb.generate(
barConfig('classes', "Class Coverage", {{class_coverage_distribution}})
);
bb.generate(
barConfig('methods', "Method Coverage", {{method_coverage_distribution}})
);
nv.addGraph(function() {
var chart = nv.models.multiBarChart();
chart.tooltips(false)
.showControls(false)
.showLegend(false)
.reduceXTicks(false)
.staggerLabels(true)
.yAxis.tickFormat(d3.format('d'));
d3.select('#methodCoverageDistribution svg')
.datum(getCoverageDistributionData({{method_coverage_distribution}}, "Method Coverage"))
.transition().duration(500).call(chart);
nv.utils.windowResize(chart.update);
return chart;
const scatterConfig = (name, complexityData) => ({
axis: {
x: {
label: {
text: 'Code Coverage (in percent)',
position: 'outer-right',
},
tick: {
values: [0, 20, 40, 60, 80, 100],
},
},
y: {
label: {
text: 'Cyclomatic Complexity',
position: 'outer-top',
},
min: 0,
padding: {
bottom: 0,
top: 5,
},
},
},
bindto: `#${name}Complexity`,
data: {
columns: [
["complexity_x"].concat(complexityData.map(d => d[0])),
["complexity"].concat(complexityData.map(d => d[1])),
],
onclick: function(data, element) {
window.location = complexityData[data.index][2];
},
type: "scatter",
xs: {
"complexity": "complexity_x",
},
},
grid: {
focus: {
show: true,
y: true,
},
x: {
show: true,
},
y: {
show: true,
},
},
legend: {
show: false,
},
tooltip: {
contents: function (data) {
const coverage = Math.round(data[0].x);
const complexity = data[0].value;
const targetName = complexityData[data[0].index][3];
return `<table class="bb-tooltip"><tbody>
<tr><th colspan="2">${targetName}</th></tr>
<tr class="bb-tooltip-name-complexity"><td>Coverage</td><td class="name">${coverage}%</td></tr>
<tr class="bb-tooltip-name-complexity"><td>Complexity</td><td class="value">${complexity}</td></tr>
</tbody></table>`;
},
grouped: false,
},
});
function getCoverageDistributionData(data, label) {
var labels = [
'0%',
'0-10%',
'10-20%',
'20-30%',
'30-40%',
'40-50%',
'50-60%',
'60-70%',
'70-80%',
'80-90%',
'90-100%',
'100%'
];
var values = [];
$.each(labels, function(key) {
values.push({x: labels[key], y: data[key]});
});
return [
{
key: label,
values: values,
color: "#4572A7"
}
];
}
nv.addGraph(function() {
var chart = nv.models.scatterChart()
.showDistX(true)
.showDistY(true)
.showLegend(false)
.forceX([0, 100]);
chart.tooltipContent(function(graph) {
return '<p>' + graph.point.class + '</p>';
});
chart.xAxis.axisLabel('Code Coverage (in percent)');
chart.yAxis.axisLabel('Cyclomatic Complexity');
d3.select('#classComplexity svg')
.datum(getComplexityData({{complexity_class}}, 'Class Complexity'))
.transition()
.duration(500)
.call(chart);
nv.utils.windowResize(chart.update);
return chart;
});
nv.addGraph(function() {
var chart = nv.models.scatterChart()
.showDistX(true)
.showDistY(true)
.showLegend(false)
.forceX([0, 100]);
chart.tooltipContent(function(graph) {
return '<p>' + graph.point.class + '</p>';
});
chart.xAxis.axisLabel('Code Coverage (in percent)');
chart.yAxis.axisLabel('Method Complexity');
d3.select('#methodComplexity svg')
.datum(getComplexityData({{complexity_method}}, 'Method Complexity'))
.transition()
.duration(500)
.call(chart);
nv.utils.windowResize(chart.update);
return chart;
});
function getComplexityData(data, label) {
var values = [];
$.each(data, function(key) {
var value = Math.round(data[key][0]*100) / 100;
values.push({
x: value,
y: data[key][1],
class: data[key][2],
size: 0.05,
shape: 'diamond'
});
});
return [
{
key: label,
values: values,
color: "#4572A7"
}
];
}
});
const classComplexityData = JSON.parse(document.getElementById('complexity_class').textContent);
bb.generate(
scatterConfig("class", classComplexityData)
);
const methodComplexityData = JSON.parse(document.getElementById('complexity_method').textContent);
bb.generate(
scatterConfig("method", methodComplexityData),
);
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7 -11
View File
@@ -10,16 +10,18 @@
namespace SebastianBergmann\CodeCoverage\Report;
use const PHP_EOL;
use function dirname;
use function file_put_contents;
use function serialize;
use function str_contains;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Driver\WriteOperationFailedException;
use SebastianBergmann\CodeCoverage\Util\Filesystem;
use SebastianBergmann\CodeCoverage\WriteOperationFailedException;
final class PHP
{
/**
* @param null|non-empty-string $target
*
* @throws WriteOperationFailedException
*/
public function process(CodeCoverage $coverage, ?string $target = null): string
{
$coverage->clearCache();
@@ -28,13 +30,7 @@ final class PHP
return \unserialize(<<<'END_OF_COVERAGE_SERIALIZATION'" . PHP_EOL . serialize($coverage) . PHP_EOL . 'END_OF_COVERAGE_SERIALIZATION' . PHP_EOL . ');';
if ($target !== null) {
if (!str_contains($target, '://')) {
Filesystem::createDirectory(dirname($target));
}
if (@file_put_contents($target, $buffer) === false) {
throw new WriteOperationFailedException($target);
}
Filesystem::write($target, $buffer);
}
return $buffer;
+9 -25
View File
@@ -23,30 +23,11 @@ use SebastianBergmann\CodeCoverage\Util\Percentage;
final class Text
{
/**
* @var string
*/
private const COLOR_GREEN = "\x1b[30;42m";
/**
* @var string
*/
private const COLOR_YELLOW = "\x1b[30;43m";
/**
* @var string
*/
private const COLOR_RED = "\x1b[37;41m";
/**
* @var string
*/
private const COLOR_HEADER = "\x1b[1;37;40m";
/**
* @var string
*/
private const COLOR_RESET = "\x1b[0m";
private const string COLOR_GREEN = "\x1b[30;42m";
private const string COLOR_YELLOW = "\x1b[30;43m";
private const string COLOR_RED = "\x1b[37;41m";
private const string COLOR_HEADER = "\x1b[1;37;40m";
private const string COLOR_RESET = "\x1b[0m";
private readonly Thresholds $thresholds;
private readonly bool $showUncoveredFiles;
private readonly bool $showOnlySummary;
@@ -60,7 +41,7 @@ final class Text
public function process(CodeCoverage $coverage, bool $showColors = false): string
{
$hasBranchCoverage = !empty($coverage->getData(true)->functionCoverage());
$hasBranchCoverage = $coverage->getData(true)->functionCoverage() !== [];
$output = PHP_EOL . PHP_EOL;
$report = $coverage->getReport();
@@ -210,6 +191,7 @@ final class Text
$classMethods = 0;
foreach ($class['methods'] as $method) {
/** @phpstan-ignore equal.notAllowed */
if ($method['executableLines'] == 0) {
continue;
}
@@ -222,6 +204,7 @@ final class Text
$classExecutablePaths += $method['executablePaths'];
$classExecutedPaths += $method['executedPaths'];
/** @phpstan-ignore equal.notAllowed */
if ($method['coverage'] == 100) {
$coveredMethods++;
}
@@ -251,6 +234,7 @@ final class Text
$resetColor = '';
foreach ($classCoverage as $fullQualifiedPath => $classInfo) {
/** @phpstan-ignore notEqual.notAllowed */
if ($this->showUncoveredFiles || $classInfo['statementsCovered'] != 0) {
if ($showColors) {
$methodColor = $this->coverageColor($classInfo['methodsCovered'], $classInfo['methodCount']);
@@ -14,10 +14,10 @@ use SebastianBergmann\CodeCoverage\InvalidArgumentException;
/**
* @immutable
*/
final class Thresholds
final readonly class Thresholds
{
private readonly int $lowUpperBound;
private readonly int $highLowerBound;
private int $lowUpperBound;
private int $highLowerBound;
public static function default(): self
{
@@ -18,9 +18,9 @@ use SebastianBergmann\Environment\Runtime;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*/
final class BuildInformation
final readonly class BuildInformation
{
private readonly DOMElement $contextNode;
private DOMElement $contextNode;
public function __construct(DOMElement $contextNode)
{
@@ -66,7 +66,7 @@ final class BuildInformation
$name,
)->item(0);
if (!$node) {
if ($node === null) {
$node = $this->contextNode->appendChild(
$this->contextNode->ownerDocument->createElementNS(
'https://schema.phpunit.de/coverage/1.0',
@@ -28,7 +28,7 @@ final class Coverage
$this->writer = new XMLWriter;
$this->writer->openMemory();
$this->writer->startElementNS(null, $context->nodeName, 'https://schema.phpunit.de/coverage/1.0');
$this->writer->startElementNs(null, $context->nodeName, 'https://schema.phpunit.de/coverage/1.0');
$this->writer->writeAttribute('nr', $line);
}
@@ -38,7 +38,9 @@ final class Coverage
public function addTest(string $test): void
{
if ($this->finalized) {
// @codeCoverageIgnoreStart
throw new ReportAlreadyFinalizedException;
// @codeCoverageIgnoreEnd
}
$this->writer->startElement('covered');
@@ -10,34 +10,37 @@
namespace SebastianBergmann\CodeCoverage\Report\Xml;
use const DIRECTORY_SEPARATOR;
use const PHP_EOL;
use function count;
use function dirname;
use function file_get_contents;
use function file_put_contents;
use function is_array;
use function is_dir;
use function is_file;
use function is_writable;
use function libxml_clear_errors;
use function libxml_get_errors;
use function libxml_use_internal_errors;
use function sprintf;
use function strlen;
use function substr;
use DateTimeImmutable;
use DOMDocument;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Driver\PathExistsButIsNotDirectoryException;
use SebastianBergmann\CodeCoverage\Driver\WriteOperationFailedException;
use SebastianBergmann\CodeCoverage\Node\AbstractNode;
use SebastianBergmann\CodeCoverage\Node\Directory as DirectoryNode;
use SebastianBergmann\CodeCoverage\Node\File;
use SebastianBergmann\CodeCoverage\Node\File as FileNode;
use SebastianBergmann\CodeCoverage\Util\Filesystem as DirectoryUtil;
use SebastianBergmann\CodeCoverage\PathExistsButIsNotDirectoryException;
use SebastianBergmann\CodeCoverage\Util\Filesystem;
use SebastianBergmann\CodeCoverage\Util\Xml;
use SebastianBergmann\CodeCoverage\Version;
use SebastianBergmann\CodeCoverage\WriteOperationFailedException;
use SebastianBergmann\CodeCoverage\XmlException;
use SebastianBergmann\Environment\Runtime;
/**
* @phpstan-import-type ProcessedClassType from File
* @phpstan-import-type ProcessedTraitType from File
* @phpstan-import-type ProcessedFunctionType from File
* @phpstan-import-type TestType from CodeCoverage
*/
final class Facade
{
private string $target;
@@ -89,6 +92,7 @@ final class Facade
private function initTargetDirectory(string $directory): void
{
if (is_file($directory)) {
// @codeCoverageIgnoreStart
if (!is_dir($directory)) {
throw new PathExistsButIsNotDirectoryException($directory);
}
@@ -96,9 +100,10 @@ final class Facade
if (!is_writable($directory)) {
throw new WriteOperationFailedException($directory);
}
// @codeCoverageIgnoreEnd
}
DirectoryUtil::createDirectory($directory);
Filesystem::createDirectory($directory);
}
/**
@@ -175,6 +180,9 @@ final class Facade
$this->saveDocument($fileReport->asDom(), $file->id());
}
/**
* @param ProcessedClassType|ProcessedTraitType $unit
*/
private function processUnit(array $unit, Report $report): void
{
if (isset($unit['className'])) {
@@ -205,6 +213,9 @@ final class Facade
}
}
/**
* @param ProcessedFunctionType $function
*/
private function processFunction(array $function, Report $report): void
{
$functionObject = $report->functionObject($function['functionName']);
@@ -215,6 +226,9 @@ final class Facade
$functionObject->setTotals((string) $function['executableLines'], (string) $function['executedLines'], (string) $function['coverage']);
}
/**
* @param array<string, TestType> $tests
*/
private function processTests(array $tests): void
{
$testsObject = $this->project->tests();
@@ -229,9 +243,9 @@ final class Facade
$loc = $node->linesOfCode();
$totals->setNumLines(
$loc['linesOfCode'],
$loc['commentLinesOfCode'],
$loc['nonCommentLinesOfCode'],
$loc->linesOfCode(),
$loc->commentLinesOfCode(),
$loc->nonCommentLinesOfCode(),
$node->numberOfExecutableLines(),
$node->numberOfExecutedLines(),
);
@@ -269,36 +283,8 @@ final class Facade
{
$filename = sprintf('%s/%s.xml', $this->targetDirectory(), $name);
$document->formatOutput = true;
$document->preserveWhiteSpace = false;
$this->initTargetDirectory(dirname($filename));
file_put_contents($filename, $this->documentAsString($document));
}
/**
* @throws XmlException
*
* @see https://bugs.php.net/bug.php?id=79191
*/
private function documentAsString(DOMDocument $document): string
{
$xmlErrorHandling = libxml_use_internal_errors(true);
$xml = $document->saveXML();
if ($xml === false) {
$message = 'Unable to generate the XML';
foreach (libxml_get_errors() as $error) {
$message .= PHP_EOL . $error->message;
}
throw new XmlException($message);
}
libxml_clear_errors();
libxml_use_internal_errors($xmlErrorHandling);
return $xml;
Filesystem::write($filename, Xml::asString($document));
}
}
@@ -9,6 +9,7 @@
*/
namespace SebastianBergmann\CodeCoverage\Report\Xml;
use function assert;
use DOMDocument;
use DOMElement;
@@ -30,7 +31,7 @@ class File
{
$totalsContainer = $this->contextNode->firstChild;
if (!$totalsContainer) {
if ($totalsContainer === null) {
$totalsContainer = $this->contextNode->appendChild(
$this->dom->createElementNS(
'https://schema.phpunit.de/coverage/1.0',
@@ -39,6 +40,8 @@ class File
);
}
assert($totalsContainer instanceof DOMElement);
return new Totals($totalsContainer);
}
@@ -49,7 +52,7 @@ class File
'coverage',
)->item(0);
if (!$coverage) {
if ($coverage === null) {
$coverage = $this->contextNode->appendChild(
$this->dom->createElementNS(
'https://schema.phpunit.de/coverage/1.0',
@@ -65,6 +68,8 @@ class File
),
);
assert($lineNode instanceof DOMElement);
return new Coverage($lineNode, $line);
}
@@ -14,9 +14,9 @@ use DOMElement;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*/
final class Method
final readonly class Method
{
private readonly DOMElement $contextNode;
private DOMElement $contextNode;
public function __construct(DOMElement $context, string $name)
{
@@ -9,6 +9,7 @@
*/
namespace SebastianBergmann\CodeCoverage\Report\Xml;
use function assert;
use DOMDocument;
use DOMElement;
@@ -34,7 +35,7 @@ abstract class Node
{
$totalsContainer = $this->contextNode()->firstChild;
if (!$totalsContainer) {
if ($totalsContainer === null) {
$totalsContainer = $this->contextNode()->appendChild(
$this->dom->createElementNS(
'https://schema.phpunit.de/coverage/1.0',
@@ -43,6 +44,8 @@ abstract class Node
);
}
assert($totalsContainer instanceof DOMElement);
return new Totals($totalsContainer);
}
@@ -9,13 +9,18 @@
*/
namespace SebastianBergmann\CodeCoverage\Report\Xml;
use function assert;
use DOMDocument;
use DOMElement;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*/
final class Project extends Node
{
/**
* @phpstan-ignore constructor.missingParentCall
*/
public function __construct(string $directory)
{
$this->init();
@@ -34,7 +39,7 @@ final class Project extends Node
'build',
)->item(0);
if (!$buildNode) {
if ($buildNode === null) {
$buildNode = $this->dom()->documentElement->appendChild(
$this->dom()->createElementNS(
'https://schema.phpunit.de/coverage/1.0',
@@ -43,6 +48,8 @@ final class Project extends Node
);
}
assert($buildNode instanceof DOMElement);
return new BuildInformation($buildNode);
}
@@ -53,7 +60,7 @@ final class Project extends Node
'tests',
)->item(0);
if (!$testsNode) {
if ($testsNode === null) {
$testsNode = $this->contextNode()->appendChild(
$this->dom()->createElementNS(
'https://schema.phpunit.de/coverage/1.0',
@@ -62,6 +69,8 @@ final class Project extends Node
);
}
assert($testsNode instanceof DOMElement);
return new Tests($testsNode);
}
@@ -9,9 +9,11 @@
*/
namespace SebastianBergmann\CodeCoverage\Report\Xml;
use function assert;
use function basename;
use function dirname;
use DOMDocument;
use DOMElement;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
@@ -38,7 +40,7 @@ final class Report extends File
return $this->dom();
}
public function functionObject($name): Method
public function functionObject(string $name): Method
{
$node = $this->contextNode()->appendChild(
$this->dom()->createElementNS(
@@ -47,15 +49,17 @@ final class Report extends File
),
);
assert($node instanceof DOMElement);
return new Method($node, $name);
}
public function classObject($name): Unit
public function classObject(string $name): Unit
{
return $this->unitObject('class', $name);
}
public function traitObject($name): Unit
public function traitObject(string $name): Unit
{
return $this->unitObject('trait', $name);
}
@@ -67,7 +71,7 @@ final class Report extends File
'source',
)->item(0);
if (!$source) {
if ($source === null) {
$source = $this->contextNode()->appendChild(
$this->dom()->createElementNS(
'https://schema.phpunit.de/coverage/1.0',
@@ -76,6 +80,8 @@ final class Report extends File
);
}
assert($source instanceof DOMElement);
return new Source($source);
}
@@ -85,7 +91,7 @@ final class Report extends File
$this->contextNode()->setAttribute('path', dirname($name));
}
private function unitObject(string $tagName, $name): Unit
private function unitObject(string $tagName, string $name): Unit
{
$node = $this->contextNode()->appendChild(
$this->dom()->createElementNS(
@@ -94,6 +100,8 @@ final class Report extends File
),
);
assert($node instanceof DOMElement);
return new Unit($node, $name);
}
}
@@ -17,9 +17,9 @@ use TheSeer\Tokenizer\XMLSerializer;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*/
final class Source
final readonly class Source
{
private readonly DOMElement $context;
private DOMElement $context;
public function __construct(DOMElement $context)
{
@@ -11,15 +11,16 @@ namespace SebastianBergmann\CodeCoverage\Report\Xml;
use function assert;
use DOMElement;
use SebastianBergmann\CodeCoverage\CodeCoverage;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*
* @phpstan-import-type TestType from \SebastianBergmann\CodeCoverage\CodeCoverage
* @phpstan-import-type TestType from CodeCoverage
*/
final class Tests
final readonly class Tests
{
private readonly DOMElement $contextNode;
private DOMElement $contextNode;
public function __construct(DOMElement $context)
{
@@ -11,25 +11,22 @@ namespace SebastianBergmann\CodeCoverage\Report\Xml;
use function sprintf;
use DOMElement;
use DOMNode;
use SebastianBergmann\CodeCoverage\Util\Percentage;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*/
final class Totals
final readonly class Totals
{
private readonly DOMNode $container;
private readonly DOMElement $linesNode;
private readonly DOMElement $methodsNode;
private readonly DOMElement $functionsNode;
private readonly DOMElement $classesNode;
private readonly DOMElement $traitsNode;
private DOMElement $linesNode;
private DOMElement $methodsNode;
private DOMElement $functionsNode;
private DOMElement $classesNode;
private DOMElement $traitsNode;
public function __construct(DOMElement $container)
{
$this->container = $container;
$dom = $container->ownerDocument;
$dom = $container->ownerDocument;
$this->linesNode = $dom->createElementNS(
'https://schema.phpunit.de/coverage/1.0',
@@ -63,11 +60,6 @@ final class Totals
$container->appendChild($this->traitsNode);
}
public function container(): DOMNode
{
return $this->container;
}
public function setNumLines(int $loc, int $cloc, int $ncloc, int $executable, int $executed): void
{
$this->linesNode->setAttribute('total', (string) $loc);
@@ -15,9 +15,9 @@ use DOMElement;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*/
final class Unit
final readonly class Unit
{
private readonly DOMElement $contextNode;
private DOMElement $contextNode;
public function __construct(DOMElement $context, string $name)
{
@@ -45,7 +45,7 @@ final class Unit
'namespace',
)->item(0);
if (!$node) {
if ($node === null) {
$node = $this->contextNode->appendChild(
$this->contextNode->ownerDocument->createElementNS(
'https://schema.phpunit.de/coverage/1.0',
@@ -68,6 +68,8 @@ final class Unit
),
);
assert($node instanceof DOMElement);
return new Method($node, $name);
}
@@ -9,24 +9,36 @@
*/
namespace SebastianBergmann\CodeCoverage\StaticAnalysis;
use function file_get_contents;
use SebastianBergmann\CodeCoverage\Filter;
final class CacheWarmer
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*/
final readonly class CacheWarmer
{
public function warmCache(string $cacheDirectory, bool $useAnnotationsForIgnoringCode, bool $ignoreDeprecatedCode, Filter $filter): void
/**
* @return array{cacheHits: non-negative-int, cacheMisses: non-negative-int}
*/
public function warmCache(string $cacheDirectory, bool $useAnnotationsForIgnoringCode, bool $ignoreDeprecatedCode, Filter $filter): array
{
$analyser = new CachingFileAnalyser(
$analyser = new CachingSourceAnalyser(
$cacheDirectory,
new ParsingFileAnalyser(
$useAnnotationsForIgnoringCode,
$ignoreDeprecatedCode,
),
$useAnnotationsForIgnoringCode,
$ignoreDeprecatedCode,
new ParsingSourceAnalyser,
);
foreach ($filter->files() as $file) {
$analyser->process($file);
$analyser->analyse(
$file,
file_get_contents($file),
$useAnnotationsForIgnoringCode,
$ignoreDeprecatedCode,
);
}
return [
'cacheHits' => $analyser->cacheHits(),
'cacheMisses' => $analyser->cacheMisses(),
];
}
}
@@ -1,184 +0,0 @@
<?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\StaticAnalysis;
use const DIRECTORY_SEPARATOR;
use function file_get_contents;
use function file_put_contents;
use function implode;
use function is_file;
use function md5;
use function serialize;
use function unserialize;
use SebastianBergmann\CodeCoverage\Util\Filesystem;
use SebastianBergmann\CodeCoverage\Version;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*
* @phpstan-import-type CodeUnitFunctionType from \SebastianBergmann\CodeCoverage\StaticAnalysis\CodeUnitFindingVisitor
* @phpstan-import-type CodeUnitMethodType from \SebastianBergmann\CodeCoverage\StaticAnalysis\CodeUnitFindingVisitor
* @phpstan-import-type CodeUnitClassType from \SebastianBergmann\CodeCoverage\StaticAnalysis\CodeUnitFindingVisitor
* @phpstan-import-type CodeUnitTraitType from \SebastianBergmann\CodeCoverage\StaticAnalysis\CodeUnitFindingVisitor
* @phpstan-import-type LinesOfCodeType from \SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser
* @phpstan-import-type LinesType from \SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser
*/
final class CachingFileAnalyser implements FileAnalyser
{
private readonly string $directory;
private readonly FileAnalyser $analyser;
private readonly bool $useAnnotationsForIgnoringCode;
private readonly bool $ignoreDeprecatedCode;
private array $cache = [];
public function __construct(string $directory, FileAnalyser $analyser, bool $useAnnotationsForIgnoringCode, bool $ignoreDeprecatedCode)
{
Filesystem::createDirectory($directory);
$this->analyser = $analyser;
$this->directory = $directory;
$this->useAnnotationsForIgnoringCode = $useAnnotationsForIgnoringCode;
$this->ignoreDeprecatedCode = $ignoreDeprecatedCode;
}
/**
* @return array<string, CodeUnitClassType>
*/
public function classesIn(string $filename): array
{
if (!isset($this->cache[$filename])) {
$this->process($filename);
}
return $this->cache[$filename]['classesIn'];
}
/**
* @return array<string, CodeUnitTraitType>
*/
public function traitsIn(string $filename): array
{
if (!isset($this->cache[$filename])) {
$this->process($filename);
}
return $this->cache[$filename]['traitsIn'];
}
/**
* @return array<string, CodeUnitFunctionType>
*/
public function functionsIn(string $filename): array
{
if (!isset($this->cache[$filename])) {
$this->process($filename);
}
return $this->cache[$filename]['functionsIn'];
}
/**
* @return LinesOfCodeType
*/
public function linesOfCodeFor(string $filename): array
{
if (!isset($this->cache[$filename])) {
$this->process($filename);
}
return $this->cache[$filename]['linesOfCodeFor'];
}
/**
* @return LinesType
*/
public function executableLinesIn(string $filename): array
{
if (!isset($this->cache[$filename])) {
$this->process($filename);
}
return $this->cache[$filename]['executableLinesIn'];
}
/**
* @return LinesType
*/
public function ignoredLinesFor(string $filename): array
{
if (!isset($this->cache[$filename])) {
$this->process($filename);
}
return $this->cache[$filename]['ignoredLinesFor'];
}
public function process(string $filename): void
{
$cache = $this->read($filename);
if ($cache !== false) {
$this->cache[$filename] = $cache;
return;
}
$this->cache[$filename] = [
'classesIn' => $this->analyser->classesIn($filename),
'traitsIn' => $this->analyser->traitsIn($filename),
'functionsIn' => $this->analyser->functionsIn($filename),
'linesOfCodeFor' => $this->analyser->linesOfCodeFor($filename),
'ignoredLinesFor' => $this->analyser->ignoredLinesFor($filename),
'executableLinesIn' => $this->analyser->executableLinesIn($filename),
];
$this->write($filename, $this->cache[$filename]);
}
private function read(string $filename): array|false
{
$cacheFile = $this->cacheFile($filename);
if (!is_file($cacheFile)) {
return false;
}
return unserialize(
file_get_contents($cacheFile),
['allowed_classes' => false],
);
}
private function write(string $filename, array $data): void
{
file_put_contents(
$this->cacheFile($filename),
serialize($data),
);
}
private function cacheFile(string $filename): string
{
$cacheKey = md5(
implode(
"\0",
[
$filename,
file_get_contents($filename),
Version::id(),
$this->useAnnotationsForIgnoringCode,
$this->ignoreDeprecatedCode,
],
),
);
return $this->directory . DIRECTORY_SEPARATOR . $cacheKey;
}
}
@@ -1,360 +0,0 @@
<?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\StaticAnalysis;
use function assert;
use function implode;
use function rtrim;
use function trim;
use PhpParser\Node;
use PhpParser\Node\ComplexType;
use PhpParser\Node\Identifier;
use PhpParser\Node\IntersectionType;
use PhpParser\Node\Name;
use PhpParser\Node\NullableType;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Enum_;
use PhpParser\Node\Stmt\Function_;
use PhpParser\Node\Stmt\Interface_;
use PhpParser\Node\Stmt\Trait_;
use PhpParser\Node\UnionType;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;
use SebastianBergmann\Complexity\CyclomaticComplexityCalculatingVisitor;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*
* @phpstan-type CodeUnitFunctionType = array{
* name: string,
* namespacedName: string,
* namespace: string,
* signature: string,
* startLine: int,
* endLine: int,
* ccn: int
* }
* @phpstan-type CodeUnitMethodType = array{
* methodName: string,
* signature: string,
* visibility: string,
* startLine: int,
* endLine: int,
* ccn: int
* }
* @phpstan-type CodeUnitClassType = array{
* name: string,
* namespacedName: string,
* namespace: string,
* startLine: int,
* endLine: int,
* methods: array<string, CodeUnitMethodType>
* }
* @phpstan-type CodeUnitTraitType = array{
* name: string,
* namespacedName: string,
* namespace: string,
* startLine: int,
* endLine: int,
* methods: array<string, CodeUnitMethodType>
* }
*/
final class CodeUnitFindingVisitor extends NodeVisitorAbstract
{
/**
* @var array<string, CodeUnitClassType>
*/
private array $classes = [];
/**
* @var array<string, CodeUnitTraitType>
*/
private array $traits = [];
/**
* @var array<string, CodeUnitFunctionType>
*/
private array $functions = [];
public function enterNode(Node $node): void
{
if ($node instanceof Class_) {
if ($node->isAnonymous()) {
return;
}
$this->processClass($node);
}
if ($node instanceof Trait_) {
$this->processTrait($node);
}
if (!$node instanceof ClassMethod && !$node instanceof Function_) {
return;
}
if ($node instanceof ClassMethod) {
$parentNode = $node->getAttribute('parent');
if ($parentNode instanceof Class_ && $parentNode->isAnonymous()) {
return;
}
$this->processMethod($node);
return;
}
$this->processFunction($node);
}
/**
* @return array<string, CodeUnitClassType>
*/
public function classes(): array
{
return $this->classes;
}
/**
* @return array<string, CodeUnitTraitType>
*/
public function traits(): array
{
return $this->traits;
}
/**
* @return array<string, CodeUnitFunctionType>
*/
public function functions(): array
{
return $this->functions;
}
private function cyclomaticComplexity(ClassMethod|Function_ $node): int
{
$nodes = $node->getStmts();
if ($nodes === null) {
return 0;
}
$traverser = new NodeTraverser;
$cyclomaticComplexityCalculatingVisitor = new CyclomaticComplexityCalculatingVisitor;
$traverser->addVisitor($cyclomaticComplexityCalculatingVisitor);
/* @noinspection UnusedFunctionResultInspection */
$traverser->traverse($nodes);
return $cyclomaticComplexityCalculatingVisitor->cyclomaticComplexity();
}
private function signature(ClassMethod|Function_ $node): string
{
$signature = ($node->returnsByRef() ? '&' : '') . $node->name->toString() . '(';
$parameters = [];
foreach ($node->getParams() as $parameter) {
assert(isset($parameter->var->name));
$parameterAsString = '';
if ($parameter->type !== null) {
$parameterAsString = $this->type($parameter->type) . ' ';
}
$parameterAsString .= '$' . $parameter->var->name;
/* @todo Handle default values */
$parameters[] = $parameterAsString;
}
$signature .= implode(', ', $parameters) . ')';
$returnType = $node->getReturnType();
if ($returnType !== null) {
$signature .= ': ' . $this->type($returnType);
}
return $signature;
}
private function type(ComplexType|Identifier|Name $type): string
{
if ($type instanceof NullableType) {
return '?' . $type->type;
}
if ($type instanceof UnionType) {
return $this->unionTypeAsString($type);
}
if ($type instanceof IntersectionType) {
return $this->intersectionTypeAsString($type);
}
return $type->toString();
}
private function visibility(ClassMethod $node): string
{
if ($node->isPrivate()) {
return 'private';
}
if ($node->isProtected()) {
return 'protected';
}
return 'public';
}
private function processClass(Class_ $node): void
{
$name = $node->name->toString();
$namespacedName = $node->namespacedName->toString();
$this->classes[$namespacedName] = [
'name' => $name,
'namespacedName' => $namespacedName,
'namespace' => $this->namespace($namespacedName, $name),
'startLine' => $node->getStartLine(),
'endLine' => $node->getEndLine(),
'methods' => [],
];
}
private function processTrait(Trait_ $node): void
{
$name = $node->name->toString();
$namespacedName = $node->namespacedName->toString();
$this->traits[$namespacedName] = [
'name' => $name,
'namespacedName' => $namespacedName,
'namespace' => $this->namespace($namespacedName, $name),
'startLine' => $node->getStartLine(),
'endLine' => $node->getEndLine(),
'methods' => [],
];
}
private function processMethod(ClassMethod $node): void
{
$parentNode = $node->getAttribute('parent');
if ($parentNode instanceof Interface_) {
return;
}
assert($parentNode instanceof Class_ || $parentNode instanceof Trait_ || $parentNode instanceof Enum_);
assert(isset($parentNode->name));
assert(isset($parentNode->namespacedName));
assert($parentNode->namespacedName instanceof Name);
$parentName = $parentNode->name->toString();
$parentNamespacedName = $parentNode->namespacedName->toString();
if ($parentNode instanceof Class_) {
$storage = &$this->classes;
} else {
$storage = &$this->traits;
}
if (!isset($storage[$parentNamespacedName])) {
$storage[$parentNamespacedName] = [
'name' => $parentName,
'namespacedName' => $parentNamespacedName,
'namespace' => $this->namespace($parentNamespacedName, $parentName),
'startLine' => $parentNode->getStartLine(),
'endLine' => $parentNode->getEndLine(),
'methods' => [],
];
}
$storage[$parentNamespacedName]['methods'][$node->name->toString()] = [
'methodName' => $node->name->toString(),
'signature' => $this->signature($node),
'visibility' => $this->visibility($node),
'startLine' => $node->getStartLine(),
'endLine' => $node->getEndLine(),
'ccn' => $this->cyclomaticComplexity($node),
];
}
private function processFunction(Function_ $node): void
{
assert(isset($node->name));
assert(isset($node->namespacedName));
assert($node->namespacedName instanceof Name);
$name = $node->name->toString();
$namespacedName = $node->namespacedName->toString();
$this->functions[$namespacedName] = [
'name' => $name,
'namespacedName' => $namespacedName,
'namespace' => $this->namespace($namespacedName, $name),
'signature' => $this->signature($node),
'startLine' => $node->getStartLine(),
'endLine' => $node->getEndLine(),
'ccn' => $this->cyclomaticComplexity($node),
];
}
private function namespace(string $namespacedName, string $name): string
{
return trim(rtrim($namespacedName, $name), '\\');
}
private function unionTypeAsString(UnionType $node): string
{
$types = [];
foreach ($node->types as $type) {
if ($type instanceof IntersectionType) {
$types[] = '(' . $this->intersectionTypeAsString($type) . ')';
continue;
}
$types[] = $this->typeAsString($type);
}
return implode('|', $types);
}
private function intersectionTypeAsString(IntersectionType $node): string
{
$types = [];
foreach ($node->types as $type) {
$types[] = $this->typeAsString($type);
}
return implode('&', $types);
}
private function typeAsString(Identifier|Name $node): string
{
if ($node instanceof Name) {
return $node->toCodeString();
}
return $node->toString();
}
}
@@ -1,398 +0,0 @@
<?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\StaticAnalysis;
use function array_diff_key;
use function assert;
use function count;
use function current;
use function end;
use function explode;
use function max;
use function preg_match;
use function preg_quote;
use function range;
use function reset;
use function sprintf;
use PhpParser\Node;
use PhpParser\NodeVisitorAbstract;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*
* @phpstan-import-type LinesType from \SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser
*/
final class ExecutableLinesFindingVisitor extends NodeVisitorAbstract
{
private int $nextBranch = 0;
private readonly string $source;
/**
* @var LinesType
*/
private array $executableLinesGroupedByBranch = [];
/**
* @var array<int, bool>
*/
private array $unsets = [];
/**
* @var array<int, string>
*/
private array $commentsToCheckForUnset = [];
public function __construct(string $source)
{
$this->source = $source;
}
public function enterNode(Node $node): void
{
foreach ($node->getComments() as $comment) {
$commentLine = $comment->getStartLine();
if (!isset($this->executableLinesGroupedByBranch[$commentLine])) {
continue;
}
foreach (explode("\n", $comment->getText()) as $text) {
$this->commentsToCheckForUnset[$commentLine] = $text;
$commentLine++;
}
}
if ($node instanceof Node\Scalar\String_ ||
$node instanceof Node\Scalar\EncapsedStringPart) {
$startLine = $node->getStartLine() + 1;
$endLine = $node->getEndLine() - 1;
if ($startLine <= $endLine) {
foreach (range($startLine, $endLine) as $line) {
unset($this->executableLinesGroupedByBranch[$line]);
}
}
return;
}
if ($node instanceof Node\Stmt\Interface_) {
foreach (range($node->getStartLine(), $node->getEndLine()) as $line) {
$this->unsets[$line] = true;
}
return;
}
if ($node instanceof Node\Stmt\Declare_ ||
$node instanceof Node\Stmt\DeclareDeclare ||
$node instanceof Node\Stmt\Else_ ||
$node instanceof Node\Stmt\EnumCase ||
$node instanceof Node\Stmt\Finally_ ||
$node instanceof Node\Stmt\GroupUse ||
$node instanceof Node\Stmt\Label ||
$node instanceof Node\Stmt\Namespace_ ||
$node instanceof Node\Stmt\Nop ||
$node instanceof Node\Stmt\Switch_ ||
$node instanceof Node\Stmt\TryCatch ||
$node instanceof Node\Stmt\Use_ ||
$node instanceof Node\Stmt\UseUse ||
$node instanceof Node\Expr\ConstFetch ||
$node instanceof Node\Expr\Variable ||
$node instanceof Node\Expr\Throw_ ||
$node instanceof Node\ComplexType ||
$node instanceof Node\Const_ ||
$node instanceof Node\Identifier ||
$node instanceof Node\Name ||
$node instanceof Node\Param ||
$node instanceof Node\Scalar) {
return;
}
if ($node instanceof Node\Expr\Match_) {
foreach ($node->arms as $arm) {
$this->setLineBranch(
$arm->body->getStartLine(),
$arm->body->getEndLine(),
++$this->nextBranch,
);
}
return;
}
if ($node instanceof Node\Stmt\Expression && $node->expr instanceof Node\Expr\Throw_) {
$this->setLineBranch($node->expr->expr->getEndLine(), $node->expr->expr->getEndLine(), ++$this->nextBranch);
return;
}
if ($node instanceof Node\Stmt\Enum_ ||
$node instanceof Node\Stmt\Function_ ||
$node instanceof Node\Stmt\Class_ ||
$node instanceof Node\Stmt\ClassMethod ||
$node instanceof Node\Expr\Closure ||
$node instanceof Node\Stmt\Trait_) {
if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) {
$unsets = [];
foreach ($node->getParams() as $param) {
foreach (range($param->getStartLine(), $param->getEndLine()) as $line) {
$unsets[$line] = true;
}
}
unset($unsets[$node->getEndLine()]);
$this->unsets += $unsets;
}
$isConcreteClassLike = $node instanceof Node\Stmt\Enum_ || $node instanceof Node\Stmt\Class_ || $node instanceof Node\Stmt\Trait_;
if (null !== $node->stmts) {
foreach ($node->stmts as $stmt) {
if ($stmt instanceof Node\Stmt\Nop) {
continue;
}
foreach (range($stmt->getStartLine(), $stmt->getEndLine()) as $line) {
unset($this->executableLinesGroupedByBranch[$line]);
if (
$isConcreteClassLike &&
!$stmt instanceof Node\Stmt\ClassMethod
) {
$this->unsets[$line] = true;
}
}
}
}
if ($isConcreteClassLike) {
return;
}
$hasEmptyBody = [] === $node->stmts ||
null === $node->stmts ||
(
1 === count($node->stmts) &&
$node->stmts[0] instanceof Node\Stmt\Nop
);
if ($hasEmptyBody) {
if ($node->getEndLine() === $node->getStartLine() && isset($this->executableLinesGroupedByBranch[$node->getStartLine()])) {
return;
}
$this->setLineBranch($node->getEndLine(), $node->getEndLine(), ++$this->nextBranch);
return;
}
return;
}
if ($node instanceof Node\Expr\ArrowFunction) {
$startLine = max(
$node->getStartLine() + 1,
$node->expr->getStartLine(),
);
$endLine = $node->expr->getEndLine();
if ($endLine < $startLine) {
return;
}
$this->setLineBranch($startLine, $endLine, ++$this->nextBranch);
return;
}
if ($node instanceof Node\Expr\Ternary) {
if (null !== $node->if &&
$node->getStartLine() !== $node->if->getEndLine()) {
$this->setLineBranch($node->if->getStartLine(), $node->if->getEndLine(), ++$this->nextBranch);
}
if ($node->getStartLine() !== $node->else->getEndLine()) {
$this->setLineBranch($node->else->getStartLine(), $node->else->getEndLine(), ++$this->nextBranch);
}
return;
}
if ($node instanceof Node\Expr\BinaryOp\Coalesce) {
if ($node->getStartLine() !== $node->getEndLine()) {
$this->setLineBranch($node->getEndLine(), $node->getEndLine(), ++$this->nextBranch);
}
return;
}
if ($node instanceof Node\Stmt\If_ ||
$node instanceof Node\Stmt\ElseIf_ ||
$node instanceof Node\Stmt\Case_) {
if (null === $node->cond) {
return;
}
$this->setLineBranch(
$node->cond->getStartLine(),
$node->cond->getStartLine(),
++$this->nextBranch,
);
return;
}
if ($node instanceof Node\Stmt\For_) {
$startLine = null;
$endLine = null;
if ([] !== $node->init) {
$startLine = $node->init[0]->getStartLine();
end($node->init);
$endLine = current($node->init)->getEndLine();
reset($node->init);
}
if ([] !== $node->cond) {
if (null === $startLine) {
$startLine = $node->cond[0]->getStartLine();
}
end($node->cond);
$endLine = current($node->cond)->getEndLine();
reset($node->cond);
}
if ([] !== $node->loop) {
if (null === $startLine) {
$startLine = $node->loop[0]->getStartLine();
}
end($node->loop);
$endLine = current($node->loop)->getEndLine();
reset($node->loop);
}
if (null === $startLine || null === $endLine) {
return;
}
$this->setLineBranch(
$startLine,
$endLine,
++$this->nextBranch,
);
return;
}
if ($node instanceof Node\Stmt\Foreach_) {
$this->setLineBranch(
$node->expr->getStartLine(),
$node->valueVar->getEndLine(),
++$this->nextBranch,
);
return;
}
if ($node instanceof Node\Stmt\While_ ||
$node instanceof Node\Stmt\Do_) {
$this->setLineBranch(
$node->cond->getStartLine(),
$node->cond->getEndLine(),
++$this->nextBranch,
);
return;
}
if ($node instanceof Node\Stmt\Catch_) {
assert([] !== $node->types);
$startLine = $node->types[0]->getStartLine();
end($node->types);
$endLine = current($node->types)->getEndLine();
$this->setLineBranch(
$startLine,
$endLine,
++$this->nextBranch,
);
return;
}
if ($node instanceof Node\Expr\CallLike) {
if (isset($this->executableLinesGroupedByBranch[$node->getStartLine()])) {
$branch = $this->executableLinesGroupedByBranch[$node->getStartLine()];
} else {
$branch = ++$this->nextBranch;
}
$this->setLineBranch($node->getStartLine(), $node->getEndLine(), $branch);
return;
}
if (isset($this->executableLinesGroupedByBranch[$node->getStartLine()])) {
return;
}
$this->setLineBranch($node->getStartLine(), $node->getEndLine(), ++$this->nextBranch);
}
public function afterTraverse(array $nodes): void
{
$lines = explode("\n", $this->source);
foreach ($lines as $lineNumber => $line) {
$lineNumber++;
if (1 === preg_match('/^\s*$/', $line) ||
(
isset($this->commentsToCheckForUnset[$lineNumber]) &&
1 === preg_match(sprintf('/^\s*%s\s*$/', preg_quote($this->commentsToCheckForUnset[$lineNumber], '/')), $line)
)) {
unset($this->executableLinesGroupedByBranch[$lineNumber]);
}
}
$this->executableLinesGroupedByBranch = array_diff_key(
$this->executableLinesGroupedByBranch,
$this->unsets,
);
}
/**
* @return LinesType
*/
public function executableLinesGroupedByBranch(): array
{
return $this->executableLinesGroupedByBranch;
}
private function setLineBranch(int $start, int $end, int $branch): void
{
foreach (range($start, $end) as $line) {
$this->executableLinesGroupedByBranch[$line] = $branch;
}
}
}
@@ -9,50 +9,45 @@
*/
namespace SebastianBergmann\CodeCoverage\StaticAnalysis;
use function file_get_contents;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*
* @phpstan-import-type CodeUnitFunctionType from \SebastianBergmann\CodeCoverage\StaticAnalysis\CodeUnitFindingVisitor
* @phpstan-import-type CodeUnitMethodType from \SebastianBergmann\CodeCoverage\StaticAnalysis\CodeUnitFindingVisitor
* @phpstan-import-type CodeUnitClassType from \SebastianBergmann\CodeCoverage\StaticAnalysis\CodeUnitFindingVisitor
* @phpstan-import-type CodeUnitTraitType from \SebastianBergmann\CodeCoverage\StaticAnalysis\CodeUnitFindingVisitor
*
* @phpstan-type LinesOfCodeType = array{
* linesOfCode: int,
* commentLinesOfCode: int,
* nonCommentLinesOfCode: int
* }
* @phpstan-type LinesType = array<int, int>
*/
interface FileAnalyser
final class FileAnalyser
{
/**
* @return array<string, CodeUnitClassType>
*/
public function classesIn(string $filename): array;
private readonly SourceAnalyser $sourceAnalyser;
private readonly bool $useAnnotationsForIgnoringCode;
private readonly bool $ignoreDeprecatedCode;
/**
* @return array<string, CodeUnitTraitType>
* @var array<non-empty-string, AnalysisResult>
*/
public function traitsIn(string $filename): array;
private array $cache = [];
public function __construct(SourceAnalyser $sourceAnalyser, bool $useAnnotationsForIgnoringCode, bool $ignoreDeprecatedCode)
{
$this->sourceAnalyser = $sourceAnalyser;
$this->useAnnotationsForIgnoringCode = $useAnnotationsForIgnoringCode;
$this->ignoreDeprecatedCode = $ignoreDeprecatedCode;
}
/**
* @return array<string, CodeUnitFunctionType>
* @param non-empty-string $sourceCodeFile
*/
public function functionsIn(string $filename): array;
public function analyse(string $sourceCodeFile): AnalysisResult
{
if (isset($this->cache[$sourceCodeFile])) {
return $this->cache[$sourceCodeFile];
}
/**
* @return LinesOfCodeType
*/
public function linesOfCodeFor(string $filename): array;
$this->cache[$sourceCodeFile] = $this->sourceAnalyser->analyse(
$sourceCodeFile,
file_get_contents($sourceCodeFile),
$this->useAnnotationsForIgnoringCode,
$this->ignoreDeprecatedCode,
);
/**
* @return LinesType
*/
public function executableLinesIn(string $filename): array;
/**
* @return LinesType
*/
public function ignoredLinesFor(string $filename): array;
return $this->cache[$sourceCodeFile];
}
}
@@ -1,121 +0,0 @@
<?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\StaticAnalysis;
use function assert;
use function str_contains;
use PhpParser\Node;
use PhpParser\Node\Attribute;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Enum_;
use PhpParser\Node\Stmt\Function_;
use PhpParser\Node\Stmt\Interface_;
use PhpParser\Node\Stmt\Trait_;
use PhpParser\NodeVisitorAbstract;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*/
final class IgnoredLinesFindingVisitor extends NodeVisitorAbstract
{
/**
* @var array<int>
*/
private array $ignoredLines = [];
private readonly bool $useAnnotationsForIgnoringCode;
private readonly bool $ignoreDeprecated;
public function __construct(bool $useAnnotationsForIgnoringCode, bool $ignoreDeprecated)
{
$this->useAnnotationsForIgnoringCode = $useAnnotationsForIgnoringCode;
$this->ignoreDeprecated = $ignoreDeprecated;
}
public function enterNode(Node $node): void
{
if (!$node instanceof Class_ &&
!$node instanceof Trait_ &&
!$node instanceof Interface_ &&
!$node instanceof Enum_ &&
!$node instanceof ClassMethod &&
!$node instanceof Function_ &&
!$node instanceof Attribute) {
return;
}
if ($node instanceof Class_ && $node->isAnonymous()) {
return;
}
if ($node instanceof Class_ ||
$node instanceof Trait_ ||
$node instanceof Interface_ ||
$node instanceof Attribute) {
$this->ignoredLines[] = $node->getStartLine();
assert($node->name !== null);
// Workaround for https://github.com/nikic/PHP-Parser/issues/886
$this->ignoredLines[] = $node->name->getStartLine();
}
if (!$this->useAnnotationsForIgnoringCode) {
return;
}
if ($node instanceof Interface_) {
return;
}
if ($node instanceof Attribute &&
$node->name->toString() === 'PHPUnit\Framework\Attributes\CodeCoverageIgnore') {
$attributeGroup = $node->getAttribute('parent');
$attributedNode = $attributeGroup->getAttribute('parent');
for ($line = $attributedNode->getStartLine(); $line <= $attributedNode->getEndLine(); $line++) {
$this->ignoredLines[] = $line;
}
return;
}
$this->processDocComment($node);
}
/**
* @return array<int>
*/
public function ignoredLines(): array
{
return $this->ignoredLines;
}
private function processDocComment(Node $node): void
{
$docComment = $node->getDocComment();
if ($docComment === null) {
return;
}
if (str_contains($docComment->getText(), '@codeCoverageIgnore')) {
for ($line = $node->getStartLine(); $line <= $node->getEndLine(); $line++) {
$this->ignoredLines[] = $line;
}
}
if ($this->ignoreDeprecated && str_contains($docComment->getText(), '@deprecated')) {
for ($line = $node->getStartLine(); $line <= $node->getEndLine(); $line++) {
$this->ignoredLines[] = $line;
}
}
}
}
@@ -1,267 +0,0 @@
<?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\StaticAnalysis;
use const T_COMMENT;
use const T_DOC_COMMENT;
use function array_merge;
use function array_unique;
use function assert;
use function file_get_contents;
use function is_array;
use function max;
use function range;
use function sort;
use function sprintf;
use function substr_count;
use function token_get_all;
use function trim;
use PhpParser\Error;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\NameResolver;
use PhpParser\NodeVisitor\ParentConnectingVisitor;
use PhpParser\ParserFactory;
use SebastianBergmann\CodeCoverage\ParserException;
use SebastianBergmann\LinesOfCode\LineCountingVisitor;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*
* @phpstan-import-type CodeUnitFunctionType from \SebastianBergmann\CodeCoverage\StaticAnalysis\CodeUnitFindingVisitor
* @phpstan-import-type CodeUnitMethodType from \SebastianBergmann\CodeCoverage\StaticAnalysis\CodeUnitFindingVisitor
* @phpstan-import-type CodeUnitClassType from \SebastianBergmann\CodeCoverage\StaticAnalysis\CodeUnitFindingVisitor
* @phpstan-import-type CodeUnitTraitType from \SebastianBergmann\CodeCoverage\StaticAnalysis\CodeUnitFindingVisitor
* @phpstan-import-type LinesOfCodeType from \SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser
* @phpstan-import-type LinesType from \SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser
*/
final class ParsingFileAnalyser implements FileAnalyser
{
/**
* @var array<string, array<string, CodeUnitClassType>>
*/
private array $classes = [];
/**
* @var array<string, array<string, CodeUnitTraitType>>
*/
private array $traits = [];
/**
* @var array<string, array<string, CodeUnitFunctionType>>
*/
private array $functions = [];
/**
* @var array<string, LinesOfCodeType>
*/
private array $linesOfCode = [];
/**
* @var array<string, LinesType>
*/
private array $ignoredLines = [];
/**
* @var array<string, LinesType>
*/
private array $executableLines = [];
private readonly bool $useAnnotationsForIgnoringCode;
private readonly bool $ignoreDeprecatedCode;
public function __construct(bool $useAnnotationsForIgnoringCode, bool $ignoreDeprecatedCode)
{
$this->useAnnotationsForIgnoringCode = $useAnnotationsForIgnoringCode;
$this->ignoreDeprecatedCode = $ignoreDeprecatedCode;
}
/**
* @return array<string, CodeUnitClassType>
*/
public function classesIn(string $filename): array
{
$this->analyse($filename);
return $this->classes[$filename];
}
/**
* @return array<string, CodeUnitTraitType>
*/
public function traitsIn(string $filename): array
{
$this->analyse($filename);
return $this->traits[$filename];
}
/**
* @return array<string, CodeUnitFunctionType>
*/
public function functionsIn(string $filename): array
{
$this->analyse($filename);
return $this->functions[$filename];
}
/**
* @return LinesOfCodeType
*/
public function linesOfCodeFor(string $filename): array
{
$this->analyse($filename);
return $this->linesOfCode[$filename];
}
/**
* @return LinesType
*/
public function executableLinesIn(string $filename): array
{
$this->analyse($filename);
return $this->executableLines[$filename];
}
/**
* @return LinesType
*/
public function ignoredLinesFor(string $filename): array
{
$this->analyse($filename);
return $this->ignoredLines[$filename];
}
/**
* @throws ParserException
*/
private function analyse(string $filename): void
{
if (isset($this->classes[$filename])) {
return;
}
$source = file_get_contents($filename);
$linesOfCode = max(substr_count($source, "\n") + 1, substr_count($source, "\r") + 1);
if ($linesOfCode === 0 && !empty($source)) {
$linesOfCode = 1;
}
assert($linesOfCode > 0);
$parser = (new ParserFactory)->createForHostVersion();
try {
$nodes = $parser->parse($source);
assert($nodes !== null);
$traverser = new NodeTraverser;
$codeUnitFindingVisitor = new CodeUnitFindingVisitor;
$lineCountingVisitor = new LineCountingVisitor($linesOfCode);
$ignoredLinesFindingVisitor = new IgnoredLinesFindingVisitor($this->useAnnotationsForIgnoringCode, $this->ignoreDeprecatedCode);
$executableLinesFindingVisitor = new ExecutableLinesFindingVisitor($source);
$traverser->addVisitor(new NameResolver);
$traverser->addVisitor(new ParentConnectingVisitor);
$traverser->addVisitor($codeUnitFindingVisitor);
$traverser->addVisitor($lineCountingVisitor);
$traverser->addVisitor($ignoredLinesFindingVisitor);
$traverser->addVisitor($executableLinesFindingVisitor);
/* @noinspection UnusedFunctionResultInspection */
$traverser->traverse($nodes);
// @codeCoverageIgnoreStart
} catch (Error $error) {
throw new ParserException(
sprintf(
'Cannot parse %s: %s',
$filename,
$error->getMessage(),
),
$error->getCode(),
$error,
);
}
// @codeCoverageIgnoreEnd
$this->classes[$filename] = $codeUnitFindingVisitor->classes();
$this->traits[$filename] = $codeUnitFindingVisitor->traits();
$this->functions[$filename] = $codeUnitFindingVisitor->functions();
$this->executableLines[$filename] = $executableLinesFindingVisitor->executableLinesGroupedByBranch();
$this->ignoredLines[$filename] = [];
$this->findLinesIgnoredByLineBasedAnnotations($filename, $source, $this->useAnnotationsForIgnoringCode);
$this->ignoredLines[$filename] = array_unique(
array_merge(
$this->ignoredLines[$filename],
$ignoredLinesFindingVisitor->ignoredLines(),
),
);
sort($this->ignoredLines[$filename]);
$result = $lineCountingVisitor->result();
$this->linesOfCode[$filename] = [
'linesOfCode' => $result->linesOfCode(),
'commentLinesOfCode' => $result->commentLinesOfCode(),
'nonCommentLinesOfCode' => $result->nonCommentLinesOfCode(),
];
}
private function findLinesIgnoredByLineBasedAnnotations(string $filename, string $source, bool $useAnnotationsForIgnoringCode): void
{
if (!$useAnnotationsForIgnoringCode) {
return;
}
$start = false;
foreach (token_get_all($source) as $token) {
if (!is_array($token) ||
!(T_COMMENT === $token[0] || T_DOC_COMMENT === $token[0])) {
continue;
}
$comment = trim($token[1]);
if ($comment === '// @codeCoverageIgnore' ||
$comment === '//@codeCoverageIgnore') {
$this->ignoredLines[$filename][] = $token[2];
continue;
}
if ($comment === '// @codeCoverageIgnoreStart' ||
$comment === '//@codeCoverageIgnoreStart') {
$start = $token[2];
continue;
}
if ($comment === '// @codeCoverageIgnoreEnd' ||
$comment === '//@codeCoverageIgnoreEnd') {
if (false === $start) {
$start = $token[2];
}
$this->ignoredLines[$filename] = array_merge(
$this->ignoredLines[$filename],
range($start, $token[2]),
);
}
}
}
}
@@ -9,9 +9,13 @@
*/
namespace SebastianBergmann\CodeCoverage\Util;
use function dirname;
use function file_put_contents;
use function is_dir;
use function mkdir;
use function sprintf;
use function str_contains;
use SebastianBergmann\CodeCoverage\WriteOperationFailedException;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
@@ -34,4 +38,20 @@ final class Filesystem
);
}
}
/**
* @param non-empty-string $target
*
* @throws WriteOperationFailedException
*/
public static function write(string $target, string $buffer): void
{
if (!str_contains($target, '://')) {
self::createDirectory(dirname($target));
}
if (@file_put_contents($target, $buffer) === false) {
throw new WriteOperationFailedException($target);
}
}
}
@@ -14,10 +14,10 @@ use function sprintf;
/**
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
*/
final class Percentage
final readonly class Percentage
{
private readonly float $fraction;
private readonly float $total;
private float $fraction;
private float $total;
public static function fromFractionAndTotal(float $fraction, float $total): self
{
+1 -1
View File
@@ -19,7 +19,7 @@ final class Version
public static function id(): string
{
if (self::$version === '') {
self::$version = (new VersionId('11.0.11', dirname(__DIR__)))->asString();
self::$version = (new VersionId('12.4.0', dirname(__DIR__)))->asString();
}
return self::$version;