diff --git a/src/Report/Cobertura.php b/src/Report/Cobertura.php new file mode 100644 index 000000000..4685d7cba --- /dev/null +++ b/src/Report/Cobertura.php @@ -0,0 +1,210 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Report; + +use SebastianBergmann\CodeCoverage\CodeCoverage; +use SebastianBergmann\CodeCoverage\Node\Directory; +use SebastianBergmann\CodeCoverage\Node\File; + +final class Cobertura +{ + /** + * Process coverage and generate a Cobertura report. + */ + public function process(CodeCoverage $coverage, string $packageName): string + { + $document = $this->createBaseDocument(); + + $elClasses = $document->createElement('classes'); + + $coverageFiles = $coverage->getReport()->getFiles(); + + $validLineClasses = 0; + $coveredLineClasses = 0; + + /** @var File $file */ + foreach ($coverageFiles as $file) { + if (!$file instanceof File) { + continue; + } + + $coverageData = $file->getCoverageData(); + $coverageClasses = $file->getClassesAndTraits(); + $currentFileName = $file->getPath(); + + foreach ($coverageClasses as $class) { + if (!empty($class['package']['namespace'])) { + $namespace = $class['package']['namespace']; + $className = $namespace . '\\' . $class['className']; + } else { + $className = $class['className']; + } + + $linesValid = $class['executableLines']; + $linesCovered = $class['executedLines']; + + $validLineClasses += $linesValid; + $coveredLineClasses += $linesCovered; + + $classLineRate = $linesValid === 0 ? 0 : ($linesCovered / $linesValid); + $methods = $document->createElement('methods'); + $classLines = $document->createElement('lines'); + $coverageLines = $coverageData; + + $elClass = $document->createElement('class'); + $elClass->setAttribute('name', $className); + $elClass->setAttribute('complexity', (string) $class['ccn']); + $elClass->setAttribute('branch-rate', '0'); + $elClass->setAttribute('line-rate', (string) $classLineRate); + $elClass->setAttribute('filename', $currentFileName); + + if ($linesValid !== 0) { + $classMethods = $class['methods']; + } else { + $classMethods = []; // Nothing to cover, skip processing + } + + foreach ($classMethods as $method) { + $methodLinesStart = $method['startLine']; + $methodLinesEnd = $method['endLine']; + + $methodLinesValid = $method['executableLines']; + $methodLinesExecuted = $method['executedLines']; + $methodLines = $document->createElement('lines'); + + foreach ($coverageLines as $lineNumber => $coveredBy) { + if ($lineNumber < $methodLinesStart || $lineNumber >= $methodLinesEnd) { + continue; + } + + $lineHits = $coveredBy === null ? '0' : (string) \count($coveredBy); + + $elLine = $document->createElement('line'); + $elLine->setAttribute('number', (string) $lineNumber); + $elLine->setAttribute('hits', $lineHits); + + $classLine = $elLine->cloneNode(); + + $methodLines->appendChild($elLine); + $classLines->appendChild($classLine); + } + + $methodLineRate = 0; + + if ($methodLinesValid !== 0) { + $methodLineRate = $methodLinesExecuted / $methodLinesValid; + } + + $elMethod = $document->createElement('method'); + $elMethod->setAttribute('name', $method['methodName']); + $elMethod->setAttribute('signature', $method['signature']); + $elMethod->setAttribute('complexity', (string) $method['ccn']); + $elMethod->setAttribute('branch-rate', '0'); + $elMethod->setAttribute('line-rate', (string) $methodLineRate); + + $elMethod->appendChild($methodLines); + $methods->appendChild($elMethod); + } + + $elClass->appendChild($methods); + + $elClass->appendChild($classLines); + + $elClasses->appendChild($elClass); + } + } + + $report = $coverage->getReport(); + + $coveragePath = $report->getPath(); + + $combinedComplexity = $this->getCombinedComplexity($report); + + $totalLineRate = $validLineClasses === 0 ? 0 : ($coveredLineClasses / $validLineClasses); + + $package = $document->createElement('package'); + $package->setAttribute('name', $packageName); + $package->setAttribute('line-rate', (string) $totalLineRate); + $package->setAttribute('branch-rate', '0'); + $package->setAttribute('complexity', (string) $combinedComplexity); + + $package->appendChild($elClasses); + + $elPackages = $document->createElement('packages'); + $elPackages->appendChild($package); + + $elSources = $document->createElement('sources'); + $source = $document->createElement('source', $coveragePath); + $elSources->appendChild($source); + + $elCoverage = $document->createElement('coverage'); + $elCoverage->setAttribute('lines-valid', (string) $validLineClasses); + $elCoverage->setAttribute('lines-covered', (string) $coveredLineClasses); + $elCoverage->setAttribute('line-rate', (string) $totalLineRate); + + // The following are in place to please DTD validation + $elCoverage->setAttribute('branches-valid', '0'); + $elCoverage->setAttribute('branches-covered', '0'); + $elCoverage->setAttribute('branch-rate', '0'); + + $elCoverage->setAttribute('timestamp', (string) $_SERVER['REQUEST_TIME']); + $elCoverage->setAttribute('complexity', (string) $combinedComplexity); + $elCoverage->setAttribute('version', '0.1'); + + $elCoverage->appendChild($elSources); + $elCoverage->appendChild($elPackages); + + $document->appendChild($elCoverage); + + if ($document->validate() === false) { + throw new \RuntimeException('Could not generate Cobertura report, malformed report document generated'); + } + + $buffer = $document->saveXML(); + + return $buffer; + } + + /** + * Create a base document for reporting. + */ + private function createBaseDocument(): \DOMDocument + { + $impl = new \DOMImplementation(); + + $dtd = $impl->createDocumentType( + 'coverage', + '', + 'http://cobertura.sourceforge.net/xml/coverage-04.dtd' + ); + + $document = $impl->createDocument('', '', $dtd); + $document->xmlVersion = '1.0'; + $document->encoding = 'UTF-8'; + $document->formatOutput = true; + $document->preserveWhiteSpace = false; + + return $document; + } + + /** + * Get combined code complexity for the coverage report. + */ + private function getCombinedComplexity(Directory $report): int + { + $combined = 0; + + foreach ($report->getClasses() as $class) { + $combined += (int) $class['ccn']; + } + + return $combined; + } +} diff --git a/tests/_files/BankAccount-cobertura.xml b/tests/_files/BankAccount-cobertura.xml new file mode 100644 index 000000000..ae8fafb0e --- /dev/null +++ b/tests/_files/BankAccount-cobertura.xml @@ -0,0 +1,53 @@ + + + + + %s/tests/_files + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/_files/class-with-anonymous-function-cobertura.xml b/tests/_files/class-with-anonymous-function-cobertura.xml new file mode 100644 index 000000000..5f02ddf49 --- /dev/null +++ b/tests/_files/class-with-anonymous-function-cobertura.xml @@ -0,0 +1,37 @@ + + + + + %s/tests/_files + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/_files/ignored-lines-cobertura.xml b/tests/_files/ignored-lines-cobertura.xml new file mode 100644 index 000000000..852daecb7 --- /dev/null +++ b/tests/_files/ignored-lines-cobertura.xml @@ -0,0 +1,21 @@ + + + + + %s/tests/_files + + + + + + + + + + + + + + + + diff --git a/tests/tests/CoberturaTest.php b/tests/tests/CoberturaTest.php new file mode 100644 index 000000000..78e12f3b5 --- /dev/null +++ b/tests/tests/CoberturaTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Report; + +use SebastianBergmann\CodeCoverage\TestCase; + +/** + * @covers \SebastianBergmann\CodeCoverage\Report\Cobertura + */ +class CoberturaTest extends TestCase +{ + public function testCloverForBankAccountTest(): void + { + $cobertura = new Cobertura; + + $this->assertStringMatchesFormatFile( + TEST_FILES_PATH . 'BankAccount-cobertura.xml', + $cobertura->process($this->getCoverageForBankAccount(), 'BankAccount') + ); + } + + public function testCloverForFileWithIgnoredLines(): void + { + $cobertura = new Cobertura; + + $this->assertStringMatchesFormatFile( + TEST_FILES_PATH . 'ignored-lines-cobertura.xml', + $cobertura->process($this->getCoverageForFileWithIgnoredLines(), 'ignored') + ); + } + + public function testCloverForClassWithAnonymousFunction(): void + { + $cobertura = new Cobertura; + + $this->assertStringMatchesFormatFile( + TEST_FILES_PATH . 'class-with-anonymous-function-cobertura.xml', + $cobertura->process($this->getCoverageForClassWithAnonymousFunction(), 'anonymous') + ); + } +}