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')
+ );
+ }
+}