mirror of
https://github.com/Dannecron/coverage-merger.git
synced 2025-12-25 15:52:34 +03:00
create merge clover coverage command
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/.idea/
|
||||
/vendor/
|
||||
|
||||
/.phpunit.result.cache
|
||||
/composer.lock
|
||||
41
composer.json
Normal file
41
composer.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "dannecron/coverage-merger",
|
||||
"description": "Merge coverage files into one",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Dannecron"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.1.0",
|
||||
"ext-dom": "*",
|
||||
"ext-libxml": "*",
|
||||
"ext-simplexml": "*",
|
||||
"adhocore/cli": "^1.6.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "1.6.6",
|
||||
"pestphp/pest": "2.18.2",
|
||||
"squizlabs/php_codesniffer": "3.7.2"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Dannecron\\CoverageMerger\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"bin": "merger",
|
||||
"minimum-stability": "stable",
|
||||
"config": {
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": false
|
||||
}
|
||||
}
|
||||
}
|
||||
51
merger
Executable file
51
merger
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Dannecron\CoverageMerger\Command\Exceptions\CommandException;
|
||||
use Dannecron\CoverageMerger\Command\CloverMergeCommand;
|
||||
use Dannecron\CoverageMerger\Clover;
|
||||
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
$app = new \Ahc\Cli\Application('clover-merger', '1.0.0');
|
||||
|
||||
$app->onException(static function (\Throwable $exception, int $exitCode) use ($app): void {
|
||||
$io = $app->io();
|
||||
|
||||
$io->error($exception->getMessage(), true);
|
||||
|
||||
if (($exception instanceof CommandException) === false) {
|
||||
exit($exitCode);
|
||||
}
|
||||
|
||||
if ($exception->isVerbose() === false) {
|
||||
exit($exception->getCode());
|
||||
}
|
||||
|
||||
$trace = $exception->getTrace();
|
||||
$io->table(\array_map(static function (array $traceStep): array {
|
||||
$methodOrFunc = \array_key_exists('class', $traceStep)
|
||||
? "{$traceStep['class']}::{$traceStep['function']}"
|
||||
: $traceStep['function'];
|
||||
|
||||
return [
|
||||
'method/func' => $methodOrFunc,
|
||||
'file' => "{$traceStep['file']}:{$traceStep['line']}",
|
||||
|
||||
];
|
||||
}, $trace));
|
||||
|
||||
exit($exception->getCode());
|
||||
});
|
||||
|
||||
$app->add(
|
||||
new CloverMergeCommand(
|
||||
new Clover\Handler(new Clover\Parser()),
|
||||
new Clover\Renderer(),
|
||||
$app,
|
||||
),
|
||||
);
|
||||
|
||||
$app->handle($_SERVER['argv']);
|
||||
8
phpcs.xml
Normal file
8
phpcs.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0"?>
|
||||
<ruleset name="psr-12">
|
||||
<rule ref="PSR12" />
|
||||
|
||||
<exclude-pattern>./vendor/*</exclude-pattern>
|
||||
<exclude-pattern>./tests/data/*</exclude-pattern>
|
||||
<exclude-pattern>./*\.(xml|js)$</exclude-pattern>
|
||||
</ruleset>
|
||||
18
phpunit.xml
Normal file
18
phpunit.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.3/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="tests">
|
||||
<directory suffix="Test.php">./tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory suffix=".php">./src</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
41
src/Clover/Dto/Accumulator.php
Normal file
41
src/Clover/Dto/Accumulator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
21
src/Clover/Dto/ClassDto.php
Normal file
21
src/Clover/Dto/ClassDto.php
Normal 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;
|
||||
}
|
||||
}
|
||||
81
src/Clover/Dto/FileDto.php
Normal file
81
src/Clover/Dto/FileDto.php
Normal 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;
|
||||
}
|
||||
}
|
||||
44
src/Clover/Dto/LineDto.php
Normal file
44
src/Clover/Dto/LineDto.php
Normal 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;
|
||||
}
|
||||
}
|
||||
78
src/Clover/Dto/Metrics.php
Normal file
78
src/Clover/Dto/Metrics.php
Normal 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);
|
||||
}
|
||||
}
|
||||
15
src/Clover/ElementsDictionary.php
Normal file
15
src/Clover/ElementsDictionary.php
Normal 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';
|
||||
}
|
||||
9
src/Clover/Exceptions/HandleException.php
Normal file
9
src/Clover/Exceptions/HandleException.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Dannecron\CoverageMerger\Clover\Exceptions;
|
||||
|
||||
class HandleException extends \Exception
|
||||
{
|
||||
}
|
||||
9
src/Clover/Exceptions/ParseException.php
Normal file
9
src/Clover/Exceptions/ParseException.php
Normal 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
152
src/Clover/Handler.php
Normal 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
89
src/Clover/Parser.php
Normal 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
239
src/Clover/Renderer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
131
src/Command/CloverMergeCommand.php
Normal file
131
src/Command/CloverMergeCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
22
src/Command/Exceptions/CommandException.php
Normal file
22
src/Command/Exceptions/CommandException.php
Normal 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;
|
||||
}
|
||||
}
|
||||
15
src/Command/Exceptions/ExecuteException.php
Normal file
15
src/Command/Exceptions/ExecuteException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
15
src/Command/Exceptions/InvalidArgumentException.php
Normal file
15
src/Command/Exceptions/InvalidArgumentException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user