Skip to content

Commit 59f5c88

Browse files
Initial work on visualizing path coverage
1 parent 7c66467 commit 59f5c88

6 files changed

Lines changed: 396 additions & 6 deletions

File tree

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
<?php declare(strict_types=1);
2+
/*
3+
* This file is part of phpunit/php-code-coverage.
4+
*
5+
* (c) Sebastian Bergmann <sebastian@phpunit.de>
6+
*
7+
* For the full copyright and license information, please view the LICENSE
8+
* file that was distributed with this source code.
9+
*/
10+
namespace SebastianBergmann\CodeCoverage\Report\Html;
11+
12+
use function count;
13+
use function fclose;
14+
use function fwrite;
15+
use function implode;
16+
use function max;
17+
use function min;
18+
use function preg_replace;
19+
use function proc_close;
20+
use function proc_open;
21+
use function sprintf;
22+
use function stream_get_contents;
23+
use function stream_set_blocking;
24+
use function strlen;
25+
use function substr;
26+
use SebastianBergmann\CodeCoverage\Data\ProcessedFunctionCoverageData;
27+
use SebastianBergmann\CodeCoverage\Data\ProcessedPathCoverageData;
28+
29+
/**
30+
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
31+
*
32+
* @no-named-arguments Parameter names are not covered by the backward compatibility promise for phpunit/php-code-coverage
33+
*/
34+
final class ControlFlowGraph
35+
{
36+
public const int XDEBUG_EXIT_BRANCH = 2147483645;
37+
private ?bool $dotAvailable = null;
38+
private readonly Colors $colors;
39+
40+
public function __construct(Colors $colors)
41+
{
42+
$this->colors = $colors;
43+
}
44+
45+
/**
46+
* @param null|array<int, ProcessedPathCoverageData> $paths
47+
*/
48+
public function renderSvg(ProcessedFunctionCoverageData $methodData, ?array $paths = null): string
49+
{
50+
$dot = $this->generateDot($methodData, $paths);
51+
52+
return $this->dotToSvg($dot);
53+
}
54+
55+
/**
56+
* @param null|array<int, ProcessedPathCoverageData> $paths
57+
*/
58+
private function generateDot(ProcessedFunctionCoverageData $methodData, ?array $paths = null): string
59+
{
60+
$coveredFill = $this->colors->successLow();
61+
$coveredBorder = $this->colors->successBar();
62+
$uncoveredFill = $this->colors->danger();
63+
$uncoveredEdge = $this->colors->dangerBar();
64+
65+
$dot = "digraph {\n";
66+
$dot .= " rankdir=TB;\n";
67+
$dot .= ' node [shape=box, style=filled, fontname="sans-serif", fontsize=11];' . "\n";
68+
$dot .= ' entry [label="entry", shape=oval, style=filled, fillcolor="#f5f5f5", color="#999999"];' . "\n";
69+
70+
$hasExit = false;
71+
$firstBranchId = null;
72+
73+
foreach ($methodData->branches as $branchId => $branch) {
74+
if ($firstBranchId === null) {
75+
$firstBranchId = $branchId;
76+
}
77+
78+
foreach ($branch->out as $destBranchId) {
79+
if ($destBranchId === self::XDEBUG_EXIT_BRANCH) {
80+
$hasExit = true;
81+
}
82+
}
83+
84+
$lineStart = min($branch->line_start, $branch->line_end);
85+
$lineEnd = max($branch->line_start, $branch->line_end);
86+
$label = $lineStart === $lineEnd
87+
? sprintf('L%d', $lineStart)
88+
: sprintf('L%d-L%d', $lineStart, $lineEnd);
89+
90+
if ($branch->hit !== []) {
91+
$fillColor = $coveredFill;
92+
$color = $coveredBorder;
93+
} else {
94+
$fillColor = $uncoveredFill;
95+
$color = $uncoveredEdge;
96+
}
97+
98+
$dot .= sprintf(
99+
' b%d [label="%s", fillcolor="%s", color="%s"];' . "\n",
100+
$branchId,
101+
$label,
102+
$fillColor,
103+
$color,
104+
);
105+
}
106+
107+
if ($hasExit) {
108+
$dot .= ' exit [label="exit", shape=oval, style=filled, fillcolor="#f5f5f5", color="#999999"];' . "\n";
109+
}
110+
111+
if ($firstBranchId !== null) {
112+
$dot .= sprintf(" entry -> b%d;\n", $firstBranchId);
113+
}
114+
115+
$edgePathClasses = $this->buildEdgePathClasses($methodData, $paths);
116+
117+
foreach ($methodData->branches as $branchId => $branch) {
118+
foreach ($branch->out as $edgeIndex => $destBranchId) {
119+
$destNode = $destBranchId === self::XDEBUG_EXIT_BRANCH
120+
? 'exit'
121+
: sprintf('b%d', $destBranchId);
122+
123+
$edgeHit = isset($branch->out_hit[$edgeIndex]) && $branch->out_hit[$edgeIndex] > 0;
124+
$color = $edgeHit ? $coveredBorder : $uncoveredEdge;
125+
126+
$edgeKey = $destBranchId === self::XDEBUG_EXIT_BRANCH
127+
? $branchId . '-exit'
128+
: $branchId . '-' . $destBranchId;
129+
130+
$attrs = sprintf('color="%s"', $color);
131+
$attrs .= sprintf(', id="edge-%s"', $edgeKey);
132+
133+
if (isset($edgePathClasses[$edgeKey])) {
134+
$attrs .= sprintf(', class="%s"', implode(' ', $edgePathClasses[$edgeKey]));
135+
}
136+
137+
$dot .= sprintf(
138+
" b%d -> %s [%s];\n",
139+
$branchId,
140+
$destNode,
141+
$attrs,
142+
);
143+
}
144+
}
145+
146+
$dot .= "}\n";
147+
148+
return $dot;
149+
}
150+
151+
/**
152+
* @param null|array<int, ProcessedPathCoverageData> $paths
153+
*
154+
* @return array<string, list<string>>
155+
*/
156+
private function buildEdgePathClasses(ProcessedFunctionCoverageData $methodData, ?array $paths): array
157+
{
158+
$edgePathClasses = [];
159+
160+
if ($paths === null) {
161+
return $edgePathClasses;
162+
}
163+
164+
$pathIndex = 0;
165+
166+
foreach ($paths as $path) {
167+
$branchIds = $path->path;
168+
169+
for ($i = 0; $i < count($branchIds) - 1; $i++) {
170+
$edgeKey = $branchIds[$i] . '-' . $branchIds[$i + 1];
171+
172+
if (!isset($edgePathClasses[$edgeKey])) {
173+
$edgePathClasses[$edgeKey] = [];
174+
}
175+
176+
$edgePathClasses[$edgeKey][] = 'path-' . $pathIndex;
177+
}
178+
179+
$lastBranchId = $branchIds[count($branchIds) - 1];
180+
181+
if (isset($methodData->branches[$lastBranchId])) {
182+
foreach ($methodData->branches[$lastBranchId]->out as $dest) {
183+
if ($dest === self::XDEBUG_EXIT_BRANCH) {
184+
$edgeKey = $lastBranchId . '-exit';
185+
186+
if (!isset($edgePathClasses[$edgeKey])) {
187+
$edgePathClasses[$edgeKey] = [];
188+
}
189+
190+
$edgePathClasses[$edgeKey][] = 'path-' . $pathIndex;
191+
}
192+
}
193+
}
194+
195+
$pathIndex++;
196+
}
197+
198+
return $edgePathClasses;
199+
}
200+
201+
private function dotToSvg(string $dot): string
202+
{
203+
if ($this->dotAvailable === false) {
204+
return '';
205+
}
206+
207+
$descriptorSpec = [
208+
0 => ['pipe', 'r'],
209+
1 => ['pipe', 'w'],
210+
2 => ['pipe', 'w'],
211+
];
212+
213+
$process = @proc_open('dot -Tsvg', $descriptorSpec, $pipes);
214+
215+
if ($process === false) {
216+
$this->dotAvailable = false;
217+
218+
return '';
219+
}
220+
221+
// Use non-blocking I/O to avoid deadlock when dot's output
222+
// buffer fills up before we finish writing the input
223+
stream_set_blocking($pipes[1], false);
224+
225+
$written = 0;
226+
$length = strlen($dot);
227+
$svg = '';
228+
229+
while ($written < $length) {
230+
$chunk = @fwrite($pipes[0], substr($dot, $written));
231+
232+
if ($chunk === false) {
233+
break;
234+
}
235+
236+
$written += $chunk;
237+
238+
// Drain available output to prevent pipe buffer deadlock
239+
$svg .= stream_get_contents($pipes[1]);
240+
}
241+
242+
fclose($pipes[0]);
243+
244+
// Read remaining output
245+
stream_set_blocking($pipes[1], true);
246+
$svg .= stream_get_contents($pipes[1]);
247+
fclose($pipes[1]);
248+
fclose($pipes[2]);
249+
250+
$exitCode = proc_close($process);
251+
252+
if ($exitCode !== 0 || $svg === '') {
253+
$this->dotAvailable = false;
254+
255+
return '';
256+
}
257+
258+
$this->dotAvailable = true;
259+
260+
// Strip XML declaration and DOCTYPE, keep only the <svg> element
261+
return preg_replace('/^.*?(<svg\b)/s', '$1', $svg);
262+
}
263+
}

src/Report/Html/Facade.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ public function process(DirectoryNode $report, string $target): void
7171
$date,
7272
$this->thresholds,
7373
$hasBranchCoverage,
74+
$this->colors,
7475
);
7576

7677
$directory->render($report, $target . 'index.html');

src/Report/Html/Renderer/File.php

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
use function htmlspecialchars;
9696
use function implode;
9797
use function is_string;
98+
use function json_encode;
9899
use function ksort;
99100
use function max;
100101
use function min;
@@ -113,6 +114,7 @@
113114
use SebastianBergmann\CodeCoverage\Data\ProcessedTraitType;
114115
use SebastianBergmann\CodeCoverage\FileCouldNotBeWrittenException;
115116
use SebastianBergmann\CodeCoverage\Node\File as FileNode;
117+
use SebastianBergmann\CodeCoverage\Report\Thresholds;
116118
use SebastianBergmann\CodeCoverage\Util\Percentage;
117119
use SebastianBergmann\Template\Exception;
118120
use SebastianBergmann\Template\Template;
@@ -203,7 +205,16 @@ final class File extends Renderer
203205
/**
204206
* @var array<non-empty-string, list<string>>
205207
*/
206-
private static array $formattedSourceCache = [];
208+
private static array $formattedSourceCache = [];
209+
private ?ControlFlowGraph $controlFlowGraph = null;
210+
private readonly Colors $colors;
211+
212+
public function __construct(string $templatePath, string $generator, string $date, Thresholds $thresholds, bool $hasBranchCoverage, Colors $colors)
213+
{
214+
parent::__construct($templatePath, $generator, $date, $thresholds, $hasBranchCoverage);
215+
216+
$this->colors = $colors;
217+
}
207218

208219
public function render(FileNode $node, string $file): void
209220
{
@@ -1018,8 +1029,9 @@ private function renderPathStructure(FileNode $node): string
10181029
}
10191030

10201031
$paths .= sprintf(
1021-
'<tr class="%s"><td>%d</td><td>%s</td><td>%s</td><td>%s</td></tr>' . "\n",
1032+
'<tr class="%s path-row" data-path-index="%d"><td>%d</td><td>%s</td><td>%s</td><td>%s</td></tr>' . "\n",
10221033
$statusClass,
1034+
$pathIndex - 1,
10231035
$pathIndex,
10241036
$branchesLabel,
10251037
$statusLabel,
@@ -1031,6 +1043,39 @@ private function renderPathStructure(FileNode $node): string
10311043

10321044
$paths .= '</tbody></table>' . "\n";
10331045

1046+
$pathsJson = [];
1047+
$pathIdx = 0;
1048+
1049+
foreach ($methodData->paths as $path) {
1050+
$edges = [];
1051+
$branchIds = $path->path;
1052+
1053+
for ($i = 0; $i < count($branchIds) - 1; $i++) {
1054+
$edges[] = $branchIds[$i] . '-' . $branchIds[$i + 1];
1055+
}
1056+
1057+
$lastBranchId = $branchIds[count($branchIds) - 1];
1058+
1059+
if (isset($methodData->branches[$lastBranchId])) {
1060+
foreach ($methodData->branches[$lastBranchId]->out as $dest) {
1061+
if ($dest === ControlFlowGraph::XDEBUG_EXIT_BRANCH) {
1062+
$edges[] = $lastBranchId . '-exit';
1063+
}
1064+
}
1065+
}
1066+
1067+
$pathsJson[$pathIdx] = $edges;
1068+
$pathIdx++;
1069+
}
1070+
1071+
$svg = $this->controlFlowGraph()->renderSvg($methodData, $methodData->paths);
1072+
1073+
$paths .= sprintf(
1074+
'<div class="cfg-graph" data-paths="%s">%s</div>' . "\n",
1075+
htmlspecialchars(json_encode($pathsJson), self::HTML_SPECIAL_CHARS_FLAGS),
1076+
$svg,
1077+
);
1078+
10341079
if ($pathCount > 100) {
10351080
$paths .= '</details>' . "\n";
10361081
}
@@ -1041,6 +1086,15 @@ private function renderPathStructure(FileNode $node): string
10411086
return $pathsTemplate->render();
10421087
}
10431088

1089+
private function controlFlowGraph(): ControlFlowGraph
1090+
{
1091+
if ($this->controlFlowGraph === null) {
1092+
$this->controlFlowGraph = new ControlFlowGraph($this->colors);
1093+
}
1094+
1095+
return $this->controlFlowGraph;
1096+
}
1097+
10441098
private function renderLine(Template $template, int $lineNumber, string $lineContent, string $class, string $popover, string $coverageCount = '', string $coverageCountClass = 'col-0'): string
10451099
{
10461100
$template->setVar(

0 commit comments

Comments
 (0)