diff --git a/.travis.yml b/.travis.yml index 8bceea85b..4459eb803 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,10 @@ language: php php: - 5.6 +install: + - pecl install xdebug + - php --version + before_script: - COMPOSER_ROOT_VERSION=dev-master composer install --prefer-source diff --git a/src/CodeCoverage.php b/src/CodeCoverage.php index d1ac2e9e7..a85030fb9 100644 --- a/src/CodeCoverage.php +++ b/src/CodeCoverage.php @@ -84,22 +84,32 @@ class PHP_CodeCoverage /** * Constructor. * - * @param PHP_CodeCoverage_Driver $driver - * @param PHP_CodeCoverage_Filter $filter - * @throws PHP_CodeCoverage_RuntimeException + * @param PHP_CodeCoverage_Driver $driver + * @param PHP_CodeCoverage_Filter $filter + * @param null|bool $pathCoverage `null` enables path coverage if supported. + * @throws PHP_CodeCoverage_InvalidArgumentException */ - public function __construct(PHP_CodeCoverage_Driver $driver = null, PHP_CodeCoverage_Filter $filter = null) + public function __construct(PHP_CodeCoverage_Driver $driver = null, PHP_CodeCoverage_Filter $filter = null, $pathCoverage = null) { + if ($pathCoverage === null) { + $pathCoverage = version_compare(phpversion('xdebug'), '2.3.2', '>='); + } elseif (!is_bool($pathCoverage)) { + throw PHP_CodeCoverage_InvalidArgumentException::create( + 3, + 'boolean' + ); + } + if ($driver === null) { - $driver = $this->selectDriver(); + $driver = $this->selectDriver($pathCoverage); } if ($filter === null) { $filter = new PHP_CodeCoverage_Filter; } - $this->driver = $driver; - $this->filter = $filter; + $this->driver = $driver; + $this->filter = $filter; } /** @@ -307,15 +317,37 @@ public function append(array $data, $id = null, $append = true, $linesToBeCovere $this->tests[$id] = ['size' => $size, 'status' => $status]; - foreach ($data as $file => $lines) { + foreach ($data as $file => $fileData) { if (!$this->filter->isFile($file)) { continue; } - foreach ($lines as $k => $v) { - if ($v == PHP_CodeCoverage_Driver::LINE_EXECUTED) { - if (empty($this->data[$file][$k]) || !in_array($id, $this->data[$file][$k])) { - $this->data[$file][$k][] = $id; + foreach ($fileData['lines'] as $function => $functionCoverage) { + if ($functionCoverage === PHP_CodeCoverage_Driver::LINE_EXECUTED) { + $lineData = &$this->data[$file]['lines'][$function]; + if ($lineData === null) { + $lineData = [ + 'pathCovered' => false, + 'tests' => [$id], + ]; + } elseif (!in_array($id, $lineData['tests'])) { + $lineData['tests'][] = $id; + } + } + } + + foreach ($fileData['functions'] as $function => $functionCoverage) { + foreach ($functionCoverage['branches'] as $branch => $branchCoverage) { + if ($branchCoverage['hit'] === 1){ + $this->data[$file]['branches'][$function][$branch]['hit'] = 1; + if (!in_array($id, $this->data[$file]['branches'][$function][$branch]['tests'])) { + $this->data[$file]['branches'][$function][$branch]['tests'][] = $id; + } + } + } + foreach ($functionCoverage['paths'] as $path => $pathCoverage) { + if ($pathCoverage['hit'] === 1 && $this->data[$file]['paths'][$function][$path]['hit'] === 0){ + $this->data[$file]['paths'][$function][$path]['hit'] = 1; } } } @@ -333,22 +365,25 @@ public function merge(PHP_CodeCoverage $that) array_merge($this->filter->getWhitelistedFiles(), $that->filter()->getWhitelistedFiles()) ); - foreach ($that->data as $file => $lines) { + foreach ($that->getData() as $file => $fileData) { if (!isset($this->data[$file])) { - if (!$this->filter->isFiltered($file)) { - $this->data[$file] = $lines; + if (!$that->filter()->isFiltered($file)) { + $this->data[$file] = $fileData; } continue; } - foreach ($lines as $line => $data) { + foreach ($fileData['lines'] as $line => $data) { if ($data !== null) { - if (!isset($this->data[$file][$line])) { - $this->data[$file][$line] = $data; + if (!isset($this->data[$file]['lines'][$line])) { + $this->data[$file]['lines'][$line] = $data; } else { - $this->data[$file][$line] = array_unique( - array_merge($this->data[$file][$line], $data) + if ($data['pathCovered']) { + $this->data[$file]['lines'][$line]['pathCovered'] = $data['pathCovered']; + } + $this->data[$file]['lines'][$line]['tests'] = array_unique( + array_merge($this->data[$file]['lines'][$line]['tests'], $data['tests']) ); } } @@ -486,7 +521,10 @@ private function applyCoversAnnotationFilter(array &$data, $linesToBeCovered, ar { if ($linesToBeCovered === false || ($this->forceCoversAnnotation && empty($linesToBeCovered))) { - $data = []; + $data = [ + 'lines' => [], + 'functions' => [], + ]; return; } @@ -508,8 +546,8 @@ private function applyCoversAnnotationFilter(array &$data, $linesToBeCovered, ar foreach (array_keys($data) as $filename) { $_linesToBeCovered = array_flip($linesToBeCovered[$filename]); - $data[$filename] = array_intersect_key( - $data[$filename], + $data[$filename]['lines'] = array_intersect_key( + $data[$filename]['lines'], $_linesToBeCovered ); } @@ -542,7 +580,7 @@ private function applyIgnoredLinesFilter(array &$data) } foreach ($this->getLinesToBeIgnored($filename) as $line) { - unset($data[$filename][$line]); + unset($data[$filename]['lines'][$line]); } } } @@ -553,12 +591,45 @@ private function applyIgnoredLinesFilter(array &$data) */ private function initializeFilesThatAreSeenTheFirstTime(array $data) { - foreach ($data as $file => $lines) { - if ($this->filter->isFile($file) && !isset($this->data[$file])) { - $this->data[$file] = []; + foreach ($data as $file => $fileData) { + if (!$this->filter->isFile($file) || isset($this->data[$file])) { + continue; + } - foreach ($lines as $k => $v) { - $this->data[$file][$k] = $v == -2 ? null : []; + $this->data[$file] = [ + 'lines' => [], + 'branches' =>[], + 'paths' => [], + ]; + + foreach ($fileData['lines'] as $lineNumber => $flag) { + if ($flag === PHP_CodeCoverage_Driver::LINE_NOT_EXECUTABLE) { + $this->data[$file]['lines'][$lineNumber] = null; + } else { + $this->data[$file]['lines'][$lineNumber] = [ + 'pathCovered' => false, + 'tests' => [], + ]; + } + } + + foreach ($fileData['functions'] as $functionName => $functionData) { + $this->data[$file]['branches'][$functionName] = []; + $this->data[$file]['paths'][$functionName] = $functionData['paths']; + + foreach ($functionData['branches'] as $index => $branch) { + $this->data[$file]['branches'][$functionName][$index] = [ + 'hit' => $branch['hit'], + 'line_start' => $branch['line_start'], + 'line_end' => $branch['line_end'], + 'tests' => [] + ]; + + for ($i = $branch['line_start']; $i < $branch['line_end']; $i++) { + if (isset($this->data[$file]['lines'][$i])) { + $this->data[$file]['lines'][$i]['pathCovered'] = (bool) $branch['hit']; + } + } } } } @@ -587,12 +658,15 @@ private function addUncoveredFilesFromWhitelist() $uncoveredFiles ); } else { - $data[$uncoveredFile] = []; + $data[$uncoveredFile] = [ + 'lines' => [], + 'functions' => [], + ]; $lines = count(file($uncoveredFile)); for ($i = 1; $i <= $lines; $i++) { - $data[$uncoveredFile][$i] = PHP_CodeCoverage_Driver::LINE_NOT_EXECUTED; + $data[$uncoveredFile]['lines'][$i] = PHP_CodeCoverage_Driver::LINE_NOT_EXECUTED; } } } @@ -815,8 +889,8 @@ private function performUnintentionallyCoveredCodeCheck(array &$data, array $lin $message = ''; - foreach ($data as $file => $_data) { - foreach ($_data as $line => $flag) { + foreach ($data as $file => $fileData) { + foreach ($fileData['lines'] as $line => $flag) { if ($flag == 1 && (!isset($allowedLines[$file]) || !isset($allowedLines[$file][$line]))) { @@ -878,10 +952,11 @@ private function getAllowedLines(array $linesToBeCovered, array $linesToBeUsed) } /** + * @param bool $pathCoverage * @return PHP_CodeCoverage_Driver * @throws PHP_CodeCoverage_RuntimeException */ - private function selectDriver() + private function selectDriver($pathCoverage) { $runtime = new Runtime; @@ -894,7 +969,7 @@ private function selectDriver() } elseif ($runtime->isPHPDBG()) { return new PHP_CodeCoverage_Driver_PHPDBG; } else { - return new PHP_CodeCoverage_Driver_Xdebug; + return new PHP_CodeCoverage_Driver_Xdebug($pathCoverage); } } } diff --git a/src/CodeCoverage/Driver/Xdebug.php b/src/CodeCoverage/Driver/Xdebug.php index ed9a7035b..5f0e4588d 100644 --- a/src/CodeCoverage/Driver/Xdebug.php +++ b/src/CodeCoverage/Driver/Xdebug.php @@ -17,9 +17,14 @@ class PHP_CodeCoverage_Driver_Xdebug implements PHP_CodeCoverage_Driver { /** - * Constructor. + * @var int */ - public function __construct() + private $flags; + + /** + * @param bool $pathCoverage + */ + public function __construct($pathCoverage = false) { if (!extension_loaded('xdebug')) { throw new PHP_CodeCoverage_RuntimeException('This driver requires Xdebug'); @@ -31,6 +36,18 @@ public function __construct() 'xdebug.coverage_enable=On has to be set in php.ini' ); } + + $this->flags = XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE; + + if ($pathCoverage) { + if (version_compare(phpversion('xdebug'), '2.3.2', '<')) { + throw new PHP_CodeCoverage_RuntimeException( + 'Path coverage requires Xdebug 2.3.2 (or newer)' + ); + } + + $this->flags |= XDEBUG_CC_BRANCH_CHECK; + } } /** @@ -38,7 +55,7 @@ public function __construct() */ public function start() { - xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE); + xdebug_start_code_coverage($this->flags); } /** @@ -62,14 +79,21 @@ public function stop() private function cleanup(array $data) { foreach (array_keys($data) as $file) { - unset($data[$file][0]); + if (!isset($data[$file]['lines'])) { + $data[$file] = ['lines' => $data[$file]]; + } + if (!isset($data[$file]['functions'])) { + $data[$file]['functions'] = []; + } + + unset($data[$file]['lines'][0]); if ($file != 'xdebug://debug-eval' && file_exists($file)) { $numLines = $this->getNumberOfLinesInFile($file); - foreach (array_keys($data[$file]) as $line) { + foreach (array_keys($data[$file]['lines']) as $line) { if ($line > $numLines) { - unset($data[$file][$line]); + unset($data[$file]['lines'][$line]); } } } diff --git a/src/CodeCoverage/Report/Clover.php b/src/CodeCoverage/Report/Clover.php index 6c8fda190..431dc9ec8 100644 --- a/src/CodeCoverage/Report/Clover.php +++ b/src/CodeCoverage/Report/Clover.php @@ -78,8 +78,8 @@ public function process(PHP_CodeCoverage $coverage, $target = null, $name = null for ($i = $method['startLine']; $i <= $method['endLine']; $i++) { - if (isset($coverage[$i]) && ($coverage[$i] !== null)) { - $methodCount = max($methodCount, count($coverage[$i])); + if (isset($coverage['lines'][$i])) { + $methodCount = max($methodCount, count($coverage['lines'][$i]['tests'])); } } @@ -135,8 +135,8 @@ public function process(PHP_CodeCoverage $coverage, $target = null, $name = null $xmlMetrics->setAttribute('complexity', $class['ccn']); $xmlMetrics->setAttribute('methods', $classMethods); $xmlMetrics->setAttribute('coveredmethods', $coveredMethods); - $xmlMetrics->setAttribute('conditionals', 0); - $xmlMetrics->setAttribute('coveredconditionals', 0); + $xmlMetrics->setAttribute('conditionals', $class['executablePaths']); + $xmlMetrics->setAttribute('coveredconditionals', $class['executedPaths']); $xmlMetrics->setAttribute('statements', $classStatements); $xmlMetrics->setAttribute( 'coveredstatements', @@ -157,13 +157,14 @@ public function process(PHP_CodeCoverage $coverage, $target = null, $name = null $xmlClass->appendChild($xmlMetrics); } - foreach ($coverage as $line => $data) { + foreach ($coverage['lines'] as $line => $data) { if ($data === null || isset($lines[$line])) { continue; } $lines[$line] = [ - 'count' => count($data), 'type' => 'stmt' + 'count' => count($data['tests']), + 'type' => 'stmt', ]; } @@ -205,8 +206,8 @@ public function process(PHP_CodeCoverage $coverage, $target = null, $name = null 'coveredmethods', $item->getNumTestedMethods() ); - $xmlMetrics->setAttribute('conditionals', 0); - $xmlMetrics->setAttribute('coveredconditionals', 0); + $xmlMetrics->setAttribute('conditionals', $item->getNumExecutablePaths()); + $xmlMetrics->setAttribute('coveredconditionals', $item->getNumExecutedPaths()); $xmlMetrics->setAttribute( 'statements', $item->getNumExecutableLines() @@ -258,8 +259,8 @@ public function process(PHP_CodeCoverage $coverage, $target = null, $name = null 'coveredmethods', $report->getNumTestedMethods() ); - $xmlMetrics->setAttribute('conditionals', 0); - $xmlMetrics->setAttribute('coveredconditionals', 0); + $xmlMetrics->setAttribute('conditionals', $report->getNumExecutablePaths()); + $xmlMetrics->setAttribute('coveredconditionals', $report->getNumExecutedPaths()); $xmlMetrics->setAttribute( 'statements', $report->getNumExecutableLines() diff --git a/src/CodeCoverage/Report/HTML/Renderer.php b/src/CodeCoverage/Report/HTML/Renderer.php index e669cd789..b441281fb 100644 --- a/src/CodeCoverage/Report/HTML/Renderer.php +++ b/src/CodeCoverage/Report/HTML/Renderer.php @@ -124,6 +124,22 @@ protected function renderItemTemplate(Text_Template $template, array $data) $data['linesExecutedPercentAsString'] = '100.00%'; } + if ($data['numExecutablePaths'] > 0) { + $pathsLevel = $this->getColorLevel($data['pathsExecutedPercent']); + + $pathsNumber = $data['numExecutedPaths'] . $numSeparator . + $data['numExecutablePaths']; + + $pathsBar = $this->getCoverageBar( + $data['pathsExecutedPercent'] + ); + } else { + $pathsLevel = 'success'; + $pathsNumber = '0' . $numSeparator . '0'; + $pathsBar = $this->getCoverageBar(100); + $data['pathsExecutedPercentAsString'] = '100.00%'; + } + $template->setVar( [ 'icon' => isset($data['icon']) ? $data['icon'] : '', @@ -133,6 +149,10 @@ protected function renderItemTemplate(Text_Template $template, array $data) 'lines_executed_percent' => $data['linesExecutedPercentAsString'], 'lines_level' => $linesLevel, 'lines_number' => $linesNumber, + 'paths_bar' => $pathsBar, + 'paths_executed_percent' => $data['pathsExecutedPercentAsString'], + 'paths_level' => $pathsLevel, + 'paths_number' => $pathsNumber, 'methods_bar' => $methodsBar, 'methods_tested_percent' => $data['testedMethodsPercentAsString'], 'methods_level' => $methodsLevel, diff --git a/src/CodeCoverage/Report/HTML/Renderer/Directory.php b/src/CodeCoverage/Report/HTML/Renderer/Directory.php index b7c0d0d61..71ca727d8 100644 --- a/src/CodeCoverage/Report/HTML/Renderer/Directory.php +++ b/src/CodeCoverage/Report/HTML/Renderer/Directory.php @@ -61,6 +61,10 @@ protected function renderItem(PHP_CodeCoverage_Report_Node $item, $total = false 'linesExecutedPercentAsString' => $item->getLineExecutedPercent(), 'numExecutedLines' => $item->getNumExecutedLines(), 'numExecutableLines' => $item->getNumExecutableLines(), + 'pathsExecutedPercent' => $item->getPathExecutedPercent(false), + 'pathsExecutedPercentAsString' => $item->getPathExecutedPercent(), + 'numExecutedPaths' => $item->getNumExecutedPaths(), + 'numExecutablePaths' => $item->getNumExecutablePaths(), 'testedMethodsPercent' => $item->getTestedMethodsPercent(false), 'testedMethodsPercentAsString' => $item->getTestedMethodsPercent(), 'testedClassesPercent' => $item->getTestedClassesAndTraitsPercent(false), diff --git a/src/CodeCoverage/Report/HTML/Renderer/File.php b/src/CodeCoverage/Report/HTML/Renderer/File.php index 5ab20c5bd..7e7ed40ab 100644 --- a/src/CodeCoverage/Report/HTML/Renderer/File.php +++ b/src/CodeCoverage/Report/HTML/Renderer/File.php @@ -90,6 +90,10 @@ protected function renderItems(PHP_CodeCoverage_Report_Node_File $node) 'linesExecutedPercentAsString' => $node->getLineExecutedPercent(), 'numExecutedLines' => $node->getNumExecutedLines(), 'numExecutableLines' => $node->getNumExecutableLines(), + 'pathsExecutedPercent' => $node->getPathExecutedPercent(false), + 'pathsExecutedPercentAsString' => $node->getPathExecutedPercent(), + 'numExecutedPaths' => $node->getNumExecutedPaths(), + 'numExecutablePaths' => $node->getNumExecutablePaths(), 'testedMethodsPercent' => $node->getTestedMethodsPercent(false), 'testedMethodsPercentAsString' => $node->getTestedMethodsPercent(), 'testedClassesPercent' => $node->getTestedClassesAndTraitsPercent(false), @@ -162,6 +166,18 @@ protected function renderTraitOrClassItems(array $items, Text_Template $template ), 'numExecutedLines' => $item['executedLines'], 'numExecutableLines' => $item['executableLines'], + 'pathsExecutedPercent' => PHP_CodeCoverage_Util::percent( + $item['executedPaths'], + $item['executablePaths'], + false + ), + 'pathsExecutedPercentAsString' => PHP_CodeCoverage_Util::percent( + $item['executedPaths'], + $item['executablePaths'], + true + ), + 'numExecutedPaths' => $item['executedPaths'], + 'numExecutablePaths' => $item['executablePaths'], 'testedMethodsPercent' => PHP_CodeCoverage_Util::percent( $numTestedMethods, $numMethods, @@ -253,6 +269,18 @@ protected function renderFunctionOrMethodItem(Text_Template $template, array $it ), 'numExecutedLines' => $item['executedLines'], 'numExecutableLines' => $item['executableLines'], + 'pathsExecutedPercent' => PHP_CodeCoverage_Util::percent( + $item['executedPaths'], + $item['executablePaths'], + false + ), + 'pathsExecutedPercentAsString' => PHP_CodeCoverage_Util::percent( + $item['executedPaths'], + $item['executablePaths'], + true + ), + 'numExecutedPaths' => $item['executedPaths'], + 'numExecutablePaths' => $item['executablePaths'], 'testedMethodsPercent' => PHP_CodeCoverage_Util::percent( $numTestedItems, 1, @@ -285,24 +313,25 @@ protected function renderSource(PHP_CodeCoverage_Report_Node_File $node) $popoverContent = ''; $popoverTitle = ''; - if (array_key_exists($i, $coverageData)) { - $numTests = count($coverageData[$i]); + if (array_key_exists($i, $coverageData['lines'])) { + $lineData = $coverageData['lines'][$i]; - if ($coverageData[$i] === null) { + if ($lineData === null) { $trClass = ' class="warning"'; - } elseif ($numTests == 0) { + } elseif (empty($lineData['tests'])) { $trClass = ' class="danger"'; } else { $lineCss = 'covered-by-large-tests'; $popoverContent = '