create merge clover coverage command

This commit is contained in:
2023-09-21 14:57:44 +07:00
parent d00d552447
commit 82f26e10e9
20 changed files with 1084 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Dannecron\CoverageMerger\Clover\Dto;
class Accumulator
{
/** @var array<string, FileDto> $files */
private array $files;
public function __construct()
{
$this->files = [];
}
/**
* @return array<string, FileDto>
*/
public function getFiles(): array
{
return $this->files;
}
public function addFile(string $fileName, FileDto $file): self
{
/** @var FileDto|null $existedFile */
$existedFile = $this->getFiles()[$fileName] ?? null;
if ($existedFile !== null) {
$existedFile->mergeFile($file);
return $this;
}
$merged = \array_merge($this->files, [$fileName => $file]);
$this->files = $merged;
return $this;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Dannecron\CoverageMerger\Clover\Dto;
class ClassDto
{
/**
* @param array<string, string> $properties
*/
public function __construct(
private readonly array $properties,
) {
}
public function getProperties(): array
{
return $this->properties;
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Dannecron\CoverageMerger\Clover\Dto;
class FileDto
{
/** @var array<string, ClassDto> */
private array $classes;
/** @var array<int, LineDto> */
private array $lines;
public function __construct(
public readonly ?string $packageName = null,
) {
$this->classes = [];
$this->lines = [];
}
/**
* @return array<string, ClassDto>
*/
public function getClasses(): array
{
return $this->classes;
}
/**
* @return array<int, LineDto>
*/
public function getLines(): array
{
return $this->lines;
}
public function hasClass(string $name): bool
{
return (bool) ($this->classes[$name] ?? false);
}
public function hasLine(int $number): bool
{
return (bool) ($this->lines[$number] ?? false);
}
public function mergeFile(FileDto $otherFile): self
{
$mergedClasses = \array_merge($otherFile->getClasses(), $this->classes);
$this->classes = $mergedClasses;
foreach ($otherFile->getLines() as $number => $line) {
$this->mergeLine($number, $line);
}
return $this;
}
public function mergeClass(string $name, ClassDto $class): self
{
if ($this->hasClass($name) === false) {
$this->classes[$name] = $class;
}
return $this;
}
public function mergeLine(int $number, LineDto $line): self
{
if ($this->hasLine($number) === false) {
$this->lines[$number] = $line;
return $this;
}
$existedLine = $this->lines[$number];
$existedLine->merge($line);
return $this;
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Dannecron\CoverageMerger\Clover\Dto;
class LineDto
{
/**
* @param array<string, string> $properties Other properties on the line.
* E.g. name, visibility, complexity, crap
* @param int $count Number of hits on this line
*/
public function __construct(
private array $properties,
private int $count,
) {
}
public function getCount(): int
{
return $this->count;
}
public function getProperties(): array
{
return $this->properties;
}
public function getNum(): ?int
{
return isset($this->properties['num'])
? (int) $this->properties['num']
: null;
}
public function merge(LineDto $otherLine): self
{
$this->properties = \array_merge($otherLine->getProperties(), $this->properties);
$this->count += $otherLine->getCount();
return $this;
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Dannecron\CoverageMerger\Clover\Dto;
class Metrics
{
public function __construct(
public readonly int $statementCount,
public readonly int $coveredStatementCount,
public readonly int $conditionalCount,
public readonly int $coveredConditionalCount,
public readonly int $methodCount,
public readonly int $coveredMethodCount,
public readonly int $classCount,
public readonly int $fileCount = 0,
public int $packageCount = 0,
) {
}
/**
* Return the number of elements
* @return int
*/
public function getElementCount(): int
{
return $this->statementCount
+ $this->conditionalCount
+ $this->methodCount;
}
/**
* Return the number of covered elements.
* @return int
*/
public function getCoveredElementCount(): int
{
return $this->coveredStatementCount
+ $this->coveredConditionalCount
+ $this->coveredMethodCount;
}
/**
* Merge another set of metrics into new one.
* @param Metrics $metrics
* @return Metrics
*/
public function merge(Metrics $metrics): Metrics
{
$statementCount = $this->statementCount + $metrics->statementCount;
$coveredStatementCount = $this->coveredStatementCount + $metrics->coveredStatementCount;
$conditionalCount = $this->conditionalCount + $metrics->conditionalCount;
$coveredConditionalCount = $this->coveredConditionalCount + $metrics->coveredConditionalCount;
$methodCount = $this->methodCount + $metrics->methodCount;
$coveredMethodCount = $this->coveredMethodCount + $metrics->coveredMethodCount;
$classCount = $this->classCount + $metrics->classCount;
$fileCount = $this->fileCount + $metrics->fileCount;
$packageCount = $this->packageCount + $metrics->packageCount;
return new Metrics(
$statementCount,
$coveredStatementCount,
$conditionalCount,
$coveredConditionalCount,
$methodCount,
$coveredMethodCount,
$classCount,
$fileCount,
$packageCount,
);
}
public static function makeEmpty(): self
{
return new self(0, 0, 0, 0, 0, 0, 0);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Dannecron\CoverageMerger\Clover;
interface ElementsDictionary
{
final public const ELEMENT_NAME_COVERAGE = 'coverage';
final public const ELEMENT_NAME_PROJECT = 'project';
final public const ELEMENT_NAME_PACKAGE = 'package';
final public const ELEMENT_NAME_FILE = 'file';
final public const ELEMENT_NAME_CLASS = 'class';
final public const ELEMENT_NAME_LINE = 'line';
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Dannecron\CoverageMerger\Clover\Exceptions;
class HandleException extends \Exception
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Dannecron\CoverageMerger\Clover\Exceptions;
class ParseException extends HandleException
{
}

152
src/Clover/Handler.php Normal file
View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace Dannecron\CoverageMerger\Clover;
use Dannecron\CoverageMerger\Clover\Dto\Accumulator;
use Dannecron\CoverageMerger\Clover\Exceptions\HandleException;
use Dannecron\CoverageMerger\Clover\Exceptions\ParseException;
class Handler implements ElementsDictionary
{
public function __construct(
private readonly Parser $parser,
) {
}
/**
* @param \SimpleXMLElement ...$documents
* @return Accumulator
* @throws HandleException
*/
public function handle(\SimpleXMLElement ...$documents): Accumulator
{
$accumulator = new Accumulator();
foreach ($documents as $document) {
$accumulator = $this->handleSingleDocument($document, $accumulator);
}
return $accumulator;
}
/**
* @param \SimpleXMLElement $document
* @param Accumulator|null $accumulator
* @return Accumulator
* @throws HandleException
*/
public function handleSingleDocument(
\SimpleXMLElement $document,
?Accumulator $accumulator = null
): Accumulator {
if ($accumulator === null) {
$accumulator = new Accumulator();
}
$name = $document->getName();
if ($name !== self::ELEMENT_NAME_COVERAGE) {
throw new HandleException('Unexpected element: not coverage');
}
foreach ($document->children() as $project) {
$accumulator = $this->handleProject($project, $accumulator);
}
return $accumulator;
}
/**
* @param \SimpleXMLElement $project
* @param Accumulator $accumulator
* @return Accumulator
* @throws HandleException
*/
private function handleProject(\SimpleXMLElement $project, Accumulator $accumulator): Accumulator
{
$name = $project->getName();
if ($name !== self::ELEMENT_NAME_PROJECT) {
throw new HandleException('Unexpected element: not project');
}
return $this->handleItems($project->children(), $accumulator);
}
/**
* @param \SimpleXMLElement $items
* @param Accumulator $accumulator
* @param string|null $packageName
* @return Accumulator
* @throws ParseException
*/
private function handleItems(
\SimpleXMLElement $items,
Accumulator $accumulator,
?string $packageName = null,
): Accumulator {
foreach ($items as $item) {
$accumulator = $this->handleItem($item, $accumulator, $packageName);
}
return $accumulator;
}
/**
* @param \SimpleXMLElement $item
* @param Accumulator $accumulator
* @param string|null $packageName
* @return Accumulator
* @throws ParseException
*/
private function handleItem(
\SimpleXMLElement $item,
Accumulator $accumulator,
?string $packageName = null,
): Accumulator {
$name = $item->getName();
if ($name === self::ELEMENT_NAME_PACKAGE) {
$attributes = $this->parser->getAttributes($item);
$attributePackageName = $attributes['name'] ?? null;
// Don't return here so that the package's files are still parsed regardless
if ($attributePackageName === null) {
return $accumulator;
}
return $this->handleItems($item->children(), $accumulator, (string) $attributePackageName);
}
if ($name === self::ELEMENT_NAME_FILE) {
return $this->handleFile($item, $accumulator, $packageName);
}
return $accumulator;
}
/**
* @param \SimpleXMLElement $xmlFile
* @param Accumulator $accumulator
* @param string|null $packageName
* @return Accumulator
* @throws ParseException
*/
private function handleFile(
\SimpleXMLElement $xmlFile,
Accumulator $accumulator,
?string $packageName = null,
): Accumulator {
$attributes = $this->parser->getAttributes($xmlFile);
$fileName = $attributes['name'] ?? null;
if ($fileName === null) {
return $accumulator;
}
$file = $this->parser->parseFile($xmlFile, $packageName);
return $accumulator->addFile((string) $fileName, $file);
}
}

89
src/Clover/Parser.php Normal file
View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Dannecron\CoverageMerger\Clover;
use Dannecron\CoverageMerger\Clover\Dto\ClassDto;
use Dannecron\CoverageMerger\Clover\Dto\FileDto;
use Dannecron\CoverageMerger\Clover\Dto\LineDto;
use Dannecron\CoverageMerger\Clover\Exceptions\ParseException;
class Parser implements ElementsDictionary
{
/**
* @param \SimpleXMLElement $xml
* @param string|null $packageName
* @return FileDto
* @throws ParseException
*/
public function parseFile(\SimpleXMLElement $xml, ?string $packageName = null): FileDto
{
$file = new FileDto($packageName);
foreach ($xml->children() as $child) {
$file = $this->parseChildXml($child, $file);
}
return $file;
}
public function parseClass(\SimpleXMLElement $xml): ClassDto
{
$properties = $this->getAttributes($xml);
$properties = \array_map(static fn (mixed $val): string => (string) $val, $properties);
return new ClassDto($properties);
}
/**
* @param \SimpleXMLElement $xml
* @return LineDto
* @throws ParseException
*/
public function parseLine(\SimpleXMLElement $xml): LineDto
{
$properties = $this->getAttributes($xml);
$properties = \array_map(static fn (mixed $val): string => (string) $val, $properties);
$count = $properties['count'] ?? null;
if ($count === null) {
throw new ParseException('Unable to parse line, missing count attribute');
}
unset($properties['count']);
return new LineDto($properties, (int) $count);
}
public function getAttributes(\SimpleXMLElement $xml): array
{
return ((array) $xml->attributes())['@attributes'] ?? [];
}
/**
* @param \SimpleXMLElement $childXml
* @param FileDto $file
* @return FileDto
* @throws ParseException
*/
private function parseChildXml(\SimpleXMLElement $childXml, FileDto $file): FileDto
{
$name = $childXml->getName();
$attributes = $this->getAttributes($childXml);
if ($name === self::ELEMENT_NAME_CLASS) {
$name = $attributes['name'] ?? '';
$file->mergeClass((string) $name, $this->parseClass($childXml));
return $file;
}
if ($name === self::ELEMENT_NAME_LINE) {
$file->mergeLine((int) $attributes['num'], $this->parseLine($childXml));
}
return $file;
}
}

239
src/Clover/Renderer.php Normal file
View File

@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace Dannecron\CoverageMerger\Clover;
use Dannecron\CoverageMerger\Clover\Dto\Accumulator;
use Dannecron\CoverageMerger\Clover\Dto\ClassDto;
use Dannecron\CoverageMerger\Clover\Dto\FileDto;
use Dannecron\CoverageMerger\Clover\Dto\LineDto;
use Dannecron\CoverageMerger\Clover\Dto\Metrics;
class Renderer
{
/**
* @param Accumulator $accumulator
* @return array
* @throws \DOMException
*/
public function renderAccumulator(Accumulator $accumulator): array
{
$files = $accumulator->getFiles();
\ksort($files);
$xmlDocument = new \DOMDocument('1.0', 'UTF-8');
$xmlCoverage = $xmlDocument->createElement('coverage');
$xmlCoverage->setAttribute('generated', (string)\time());
$xmlDocument->appendChild($xmlCoverage);
$xmlProject = $xmlDocument->createElement('project');
$xmlProject->setAttribute('timestamp', (string)\time());
$xmlCoverage->appendChild($xmlProject);
$projectMetrics = Metrics::makeEmpty();
$packages = [];
foreach ($files as $name => $file) {
[$xmlFile, $fileMetrics] = $this->renderFile($xmlDocument, $file, $name);
$projectMetrics = $projectMetrics->merge($fileMetrics);
$packageName = $file->packageName;
if ($packageName === null) {
$xmlProject->appendChild($xmlFile);
continue;
}
$existedPackage = $packages[$packageName] ?? null;
if ($existedPackage !== null) {
$existedPackage[0]->appendChild($xmlFile);
$existedPackage[1] = $existedPackage[1]->merge($fileMetrics);
$packages[$packageName] = $existedPackage;
continue;
}
$xmlPackage = $xmlDocument->createElement('package');
$xmlPackage->setAttribute('name', $packageName);
$xmlProject->appendChild($xmlPackage);
$xmlPackage->appendChild($xmlFile);
$packageMetrics = Metrics::makeEmpty();
$packageMetrics->packageCount = 1;
$packageMetrics = $packageMetrics->merge($fileMetrics);
$packages[$packageName] = [$xmlPackage, $packageMetrics];
}
foreach ($packages as $package) {
/** @var Metrics $packageMetrics */
$packageMetrics = $package[1];
$package[0]->appendChild($this->renderMetricsPackage($xmlDocument, $packageMetrics));
}
$xmlProject->appendChild($this->renderMetricsProject($xmlDocument, $projectMetrics));
return [$xmlDocument->saveXML(), $projectMetrics];
}
/**
* Create an XML element to represent these metrics under a file.
* @param \DOMDocument $xmlDocument The parent document
* @param Metrics $metrics
* @return \DOMElement
* @throws \DOMException
*/
private function renderMetrics(\DOMDocument $xmlDocument, Metrics $metrics): \DOMElement
{
$xmlMetrics = $xmlDocument->createElement('metrics');
// We can't know the complexity, just set 0
// (attribute required by the clover xml schema)
$xmlMetrics->setAttribute('complexity', '0');
$xmlMetrics->setAttribute('elements', (string) $metrics->getElementCount());
$xmlMetrics->setAttribute('coveredelements', (string) $metrics->getCoveredElementCount());
$xmlMetrics->setAttribute('conditionals', (string) $metrics->conditionalCount);
$xmlMetrics->setAttribute('coveredconditionals', (string) $metrics->coveredConditionalCount);
$xmlMetrics->setAttribute('statements', (string) $metrics->statementCount);
$xmlMetrics->setAttribute('coveredstatements', (string) $metrics->coveredStatementCount);
$xmlMetrics->setAttribute('methods', (string) $metrics->methodCount);
$xmlMetrics->setAttribute('coveredmethods', (string) $metrics->coveredMethodCount);
$xmlMetrics->setAttribute('classes', (string) $metrics->classCount);
return $xmlMetrics;
}
/**
* Create an XML element to represent these metrics under a package.
* Contains all the attributes of the file context plus the number of files.
* @param \DOMDocument $xmlDocument The parent document.
* @param Metrics $metrics
* @return \DOMElement
* @throws \DOMException
*/
private function renderMetricsPackage(\DOMDocument $xmlDocument, Metrics $metrics): \DOMElement
{
$xmlMetrics = $this->renderMetrics($xmlDocument, $metrics);
$xmlMetrics->setAttribute('files', (string) $metrics->fileCount);
return $xmlMetrics;
}
/**
* Create an XML element to represent these metrics under a project.
* Contains all the attributes of the package context plus the number of packages.
* @param \DOMDocument $xmlDocument The parent document.
* @param Metrics $metrics
* @return \DOMElement
* @throws \DOMException
*/
private function renderMetricsProject(\DOMDocument $xmlDocument, Metrics $metrics): \DOMElement
{
$xmlMetrics = $this->renderMetricsPackage($xmlDocument, $metrics);
$xmlMetrics->setAttribute('packages', (string) $metrics->packageCount);
return $xmlMetrics;
}
/**
* @param \DOMDocument $document
* @param FileDto $fileDto
* @param string $name
* @return array{0:\DOMElement,1:Metrics}
* @throws \DOMException
*/
private function renderFile(\DOMDocument $document, FileDto $fileDto, string $name): array
{
$xmlFile = $document->createElement('file');
$xmlFile->setAttribute('name', $name);
$classes = $fileDto->getClasses();
$lines = $fileDto->getLines();
// Metric counts
$statementCount = 0;
$coveredStatementCount = 0;
$conditionalCount = 0;
$coveredConditionalCount = 0;
$methodCount = 0;
$coveredMethodCount = 0;
$classCount = \count($classes);
foreach ($classes as $class) {
$xmlFile->appendChild($this->renderClass($document, $class));
}
foreach ($lines as $line) {
$xmlFile->appendChild($this->renderLine($document, $line));
$properties = $line->getProperties();
$covered = $line->getCount() > 0;
$type = $properties['type'] ?? 'stmt';
if ($type === 'method') {
$methodCount++;
if ($covered) {
$coveredMethodCount++;
}
} elseif ($type === 'stmt') {
$statementCount++;
if ($covered) {
$coveredStatementCount++;
}
} elseif ($type === 'cond') {
$conditionalCount++;
if ($covered) {
$coveredConditionalCount++;
}
}
}
$metrics = new Metrics(
$statementCount,
$coveredStatementCount,
$conditionalCount,
$coveredConditionalCount,
$methodCount,
$coveredMethodCount,
$classCount,
1
);
$xmlFile->appendChild($this->renderMetrics($document, $metrics));
return [$xmlFile, $metrics];
}
/**
* @param \DOMDocument $document
* @param ClassDto $class
* @return \DOMElement
* @throws \DOMException
*/
private function renderClass(\DOMDocument $document, ClassDto $class): \DOMElement
{
$xmlClass = $document->createElement('class');
foreach ($class->getProperties() as $key => $value) {
$xmlClass->setAttribute($key, $value);
}
return $xmlClass;
}
/**
* @param \DOMDocument $document
* @param LineDto $line
* @return \DOMElement
* @throws \DOMException
*/
private function renderLine(\DOMDocument $document, LineDto $line): \DOMElement
{
$xmlLine = $document->createElement('line');
foreach ($line->getProperties() as $key => $value) {
$xmlLine->setAttribute($key, $value);
}
$xmlLine->setAttribute('count', (string) $line->getCount());
return $xmlLine;
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Dannecron\CoverageMerger\Command;
use Ahc\Cli\Application as App;
use Ahc\Cli\Input\Command as CliCommand;
use Ahc\Cli\IO\Interactor;
use Dannecron\CoverageMerger\Command\Exceptions\ExecuteException;
use Dannecron\CoverageMerger\Command\Exceptions\InvalidArgumentException;
use Dannecron\CoverageMerger\Clover\Handler;
use Dannecron\CoverageMerger\Clover\Renderer;
/**
* @property-read int $verbosity
*/
final class CloverMergeCommand extends CliCommand
{
private readonly Handler $handler;
private readonly Renderer $renderer;
public function __construct(Handler $handler, Renderer $renderer, ?App $_app = null)
{
parent::__construct('clover', 'Merge clover coverage files into single one', false, $_app);
$this->arguments('[files...]')
->option('-w|--workdir', 'Path to workdir, to work with relative paths in files')
->option('-o|--output', 'Path to result file', null, './merged.xml')
->option('-s|--stats', 'Print calculated statistic', null, false);
$this->handler = $handler;
$this->renderer = $renderer;
}
/**
* @param Interactor $io
* @return void
* @throws InvalidArgumentException
*/
public function interact(Interactor $io): void
{
$isVerbose = $this->verbosity >= 1;
$values = $this->values(false);
$files = $values['files'] ?? null;
$workdir = $values['workdir'] ?? null;
if ($files === null || \count($files) === 0) {
throw new InvalidArgumentException('files', $isVerbose);
}
if ($workdir !== null && \is_dir($workdir) === false) {
throw new InvalidArgumentException('workdir', $isVerbose);
}
}
/**
* @throws ExecuteException
*/
public function execute(array $files, string $output, ?string $workdir, bool $stats): int
{
$isVerbose = $this->verbosity >= 1;
$fullOutputPath = $output;
if ($workdir !== null) {
$fullOutputPath = "{$workdir}/{$output}";
}
$documentsCollection = \array_map(static function (string $path) use ($workdir): \SimpleXMLElement {
$fullPath = $path;
if ($workdir !== null) {
$fullPath = "{$workdir}/{$path}";
}
$file = new \SplFileInfo($fullPath);
if ($file->isFile() === false || $file->isReadable() === false) {
throw new ExecuteException("File {$fullPath} does not exists or not readable");
}
$document = \simplexml_load_file(
$file->getPathname(),
\SimpleXMLElement::class,
LIBXML_NOWARNING | LIBXML_NOERROR,
);
if ($document === false) {
throw new ExecuteException("Unable to parse file {$file->getPathname()}");
}
return $document;
}, $files);
try {
$accumulator = $this->handler->handle(...$documentsCollection);
[$xml, $metrics] = $this->renderer->renderAccumulator($accumulator);
} catch (\Throwable $exception) {
throw new ExecuteException($exception->getMessage(), $isVerbose, $exception);
}
$writeResult = \file_put_contents($fullOutputPath, $xml);
if ($writeResult === false) {
throw new ExecuteException('Unable to write to given output file', $isVerbose);
}
if ($stats === false) {
return 0;
}
$filesDiscovered = $metrics->fileCount;
$elementCount = $metrics->getElementCount();
$coveredElementCount = $metrics->getCoveredElementCount();
if ($elementCount === 0) {
$coveragePercentage = 0;
} else {
$coveragePercentage = 100 * $coveredElementCount / $elementCount;
}
$io = $this->io();
$io->info(\sprintf("Files Discovered: %d", $filesDiscovered), true);
$io->info(
\sprintf("Final Coverage: %d/%d (%.2f%%)", $coveredElementCount, $elementCount, $coveragePercentage),
true,
);
return 0;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Dannecron\CoverageMerger\Command\Exceptions;
class CommandException extends \Exception
{
private readonly bool $isVerbose;
public function __construct(string $message, int $code = 0, bool $isVerbose = false, ?\Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
$this->isVerbose = $isVerbose;
}
public function isVerbose(): bool
{
return $this->isVerbose;
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Dannecron\CoverageMerger\Command\Exceptions;
class ExecuteException extends CommandException
{
public const CODE = 2;
public function __construct(string $message, bool $isVerbose = false, ?\Throwable $previous = null)
{
parent::__construct($message, self::CODE, $isVerbose, $previous);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Dannecron\CoverageMerger\Command\Exceptions;
class InvalidArgumentException extends CommandException
{
public const CODE = 1;
public function __construct(string $argument, bool $isVerbose = false, ?\Throwable $previous = null)
{
parent::__construct("Invalid argument {$argument}", self::CODE, $isVerbose, $previous);
}
}