Skip to content

Commit fbd7c9a

Browse files
authored
Merge pull request #2447 from coverage-robot/feature/add-go-cover-parsing-support
[Part 2] Add support for Go cover files
2 parents 954bb92 + 4a64bbc commit fbd7c9a

11 files changed

+596
-3
lines changed

services/ingest/src/Model/File.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public function getFileName(): string
3030
}
3131

3232
/**
33-
* @return AbstractLine[]
33+
* @return array<array-key, AbstractLine>
3434
*/
3535
public function getLines(): array
3636
{

services/ingest/src/Model/Line/AbstractLine.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ abstract class AbstractLine implements Stringable
2222
{
2323
public function __construct(
2424
private readonly int $lineNumber,
25-
private readonly int $lineHits = 0
25+
private int $lineHits = 0
2626
) {
2727
}
2828

@@ -52,6 +52,11 @@ public function getLineNumber(): int
5252
return $this->lineNumber;
5353
}
5454

55+
public function setLineHits(int $lineHits): void
56+
{
57+
$this->lineHits = $lineHits;
58+
}
59+
5560
public function getLineHits(): int
5661
{
5762
return $this->lineHits;
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Strategy\GoCover;
6+
7+
use App\Exception\ParseException;
8+
use App\Model\Coverage;
9+
use App\Model\File;
10+
use App\Model\Line\Branch;
11+
use App\Model\Line\Statement;
12+
use App\Service\PathFixingService;
13+
use App\Strategy\ParseStrategyInterface;
14+
use Exception;
15+
use LogicException;
16+
use OutOfBoundsException;
17+
use Override;
18+
use Packages\Contracts\Format\CoverageFormat;
19+
use Packages\Contracts\Provider\Provider;
20+
use Psr\Log\LoggerInterface;
21+
22+
final readonly class GoCoverParseStrategy implements ParseStrategyInterface
23+
{
24+
/**
25+
* Each line is formatted as `name.go:line.column,line.column numberOfStatements count`.
26+
*/
27+
// phpcs:ignore
28+
private const string LINE_STRUCTURE = '/^(?<file>.*?\.go):(?<startLine>[0-9]+).(?<startColumn>[0-9]+),(?<endLine>[0-9]+).(?<endColumn>[0-9]+)\s(?<statements>[0-9]+)\s(?<count>[0-9]+)$/';
29+
30+
/**
31+
* If arguments such as mode are provided, we can skip the line.
32+
*/
33+
private const string MODE_STRUCTURE = 'mode:';
34+
35+
public function __construct(
36+
private LoggerInterface $parseStrategyLogger,
37+
private PathFixingService $pathFixingService
38+
) {
39+
}
40+
41+
#[Override]
42+
public function supports(string $content): bool
43+
{
44+
/** @var string[] $lines */
45+
$lines = preg_split('/\n|\r\n?/', $content);
46+
47+
foreach ($lines as $line) {
48+
$line = trim($line);
49+
50+
// Skip empty lines
51+
if ($line === '') {
52+
continue;
53+
}
54+
55+
// If a mode (i.e. atomic) is provided, skip the line
56+
if (str_starts_with($line, self::MODE_STRUCTURE)) {
57+
continue;
58+
}
59+
60+
// Match the record type and its data
61+
if (preg_match(self::LINE_STRUCTURE, $line) !== 1) {
62+
$this->parseStrategyLogger->error(
63+
'Unable to validate structure of line in Go Cover file.',
64+
[
65+
'line' => $line
66+
]
67+
);
68+
69+
return false;
70+
}
71+
}
72+
73+
return true;
74+
}
75+
76+
#[Override]
77+
public function parse(
78+
Provider $provider,
79+
string $owner,
80+
string $repository,
81+
string $projectRoot,
82+
string $content
83+
): Coverage {
84+
if (!$this->supports($content)) {
85+
throw ParseException::notSupportedException();
86+
}
87+
88+
try {
89+
$coverage = new Coverage(
90+
CoverageFormat::GO_COVER,
91+
root: $projectRoot,
92+
);
93+
} catch (Exception $exception) {
94+
throw new ParseException(
95+
sprintf(
96+
'Failed to create coverage model for %s %s %s. Error was %s',
97+
$provider->value,
98+
$owner,
99+
$repository,
100+
$exception
101+
),
102+
previous: $exception
103+
);
104+
}
105+
106+
/** @var string[] $blocks */
107+
$blocks = preg_split('/\n|\r\n?/', $content);
108+
109+
try {
110+
foreach ($blocks as $block) {
111+
$block = trim($block);
112+
113+
// Skip empty lines
114+
if ($block === '') {
115+
continue;
116+
}
117+
118+
// If a mode (i.e. atomic) is provided, skip the line
119+
if (str_starts_with($block, self::MODE_STRUCTURE)) {
120+
continue;
121+
}
122+
123+
$coverage = $this->handleBlock(
124+
$provider,
125+
$owner,
126+
$repository,
127+
$coverage,
128+
$block
129+
);
130+
}
131+
} catch (LogicException $logicException) {
132+
throw new ParseException(
133+
sprintf(
134+
'Unable to parse coverage of line in Go Cover file for %s %s %s.',
135+
$provider->value,
136+
$owner,
137+
$repository,
138+
),
139+
previous: $logicException
140+
);
141+
}
142+
143+
return $coverage;
144+
}
145+
146+
/**
147+
* Each line of a Go Coverage file is a 'block' of coverage - where a block has a start and end line (and
148+
* start and end columns), but all have the same hit count.
149+
*
150+
* For example, a block which spans 2 lines (and has 2 statements) but is never hit will look
151+
* like this:
152+
*
153+
* ```
154+
* github.com/some-owner/some-repo/internal/cmd/ipv6/main.go:28.2,30.16 2 0
155+
* ```
156+
*
157+
* @throws LogicException
158+
*/
159+
private function handleBlock(
160+
Provider $provider,
161+
string $owner,
162+
string $repository,
163+
Coverage $coverage,
164+
string $line
165+
): Coverage {
166+
$line = trim($line);
167+
168+
// Skip empty lines
169+
if ($line === '') {
170+
return $coverage;
171+
}
172+
173+
$parts = [];
174+
175+
// Match the record type and its data
176+
if (preg_match(self::LINE_STRUCTURE, $line, $parts) !== 1) {
177+
throw new ParseException(
178+
sprintf(
179+
'Unable to parse structure of line in Go Cover file: %s',
180+
$line
181+
)
182+
);
183+
}
184+
185+
// Perform any path fixings we need to do
186+
$filePath = $this->pathFixingService->fixPath(
187+
$provider,
188+
$owner,
189+
$repository,
190+
$parts['file'],
191+
$coverage->getRoot()
192+
);
193+
194+
$files = $coverage->getFiles();
195+
196+
/**
197+
* Generally Go cover files are in sequential order - where all of the blocks for
198+
* a particular file are together sequentially. So most of the time the last item
199+
* of the coverage should be the one we're looking for.
200+
*
201+
* If its not, it _should_ be the first time we've seen the file path - in which case,
202+
* we need to setup a new file!
203+
*/
204+
$file = end($files);
205+
206+
if (!$file || $file->getFileName() !== $filePath) {
207+
$file = new File(
208+
fileName: $filePath,
209+
lines: []
210+
);
211+
$coverage->addFile($file);
212+
}
213+
214+
$lineHits = (int)$parts['count'];
215+
216+
$startLine = (int)$parts['startLine'];
217+
$endLine = (int)$parts['endLine'];
218+
219+
try {
220+
/**
221+
* Each block of a Go cover file (represented as a single line) will span between two lines (with
222+
* start and end columns recorded).
223+
*
224+
* It's possible that between two blocks, one block ends at column 39, and another block starts
225+
* from 39 onwards (non-inclusive).
226+
*
227+
* For example:
228+
*
229+
* ```
230+
* github.com/some-owner/some-repo/internal/ipv4/ipv4.go:54.2,54.46 1 1
231+
* github.com/some-owner/some-repo/internal/ipv4/ipv4.go:54.46,56.3 1 1
232+
* ```
233+
*
234+
* If one starts while the other ends on the same line, that means we've uncovered an if/elseif/else
235+
* line - and so we should any existing line data we have into a branch, and anything else in the block
236+
* should continue as statements.
237+
*/
238+
$alreadyParsedLine = $file->getLine((string)$startLine);
239+
240+
if ($alreadyParsedLine instanceof Branch) {
241+
// Increment the existing branch
242+
$alreadyParsedLine->setLineHits($alreadyParsedLine->getLineHits() + $lineHits);
243+
$alreadyParsedLine->addToBranchHits(count($alreadyParsedLine->getBranchHits()) + 1, $lineHits);
244+
} else {
245+
// The line we already have tracked is not a branch (it wont be when running through the
246+
// individual block data), meaning we should convert it to a branch now we officially know its
247+
// type isn't a simple statement.
248+
$file->setLine(
249+
new Branch(
250+
lineNumber: $startLine,
251+
lineHits: $alreadyParsedLine->getLineHits() + $lineHits,
252+
branchHits: [
253+
$alreadyParsedLine->getLineHits(),
254+
$lineHits
255+
]
256+
)
257+
);
258+
}
259+
260+
if ($startLine === $endLine) {
261+
// This block only spans the single line - we've already recorded it, so can finish
262+
// here
263+
return $coverage;
264+
}
265+
266+
++$startLine;
267+
} catch (OutOfBoundsException) {
268+
// No line recorded with this line number - we can start from here.
269+
}
270+
271+
if ($startLine > $endLine) {
272+
$this->parseStrategyLogger->error(
273+
sprintf(
274+
'Invalid Go cover file. The end of a block (%s) started before the start line (%s)',
275+
$endLine,
276+
$startLine,
277+
),
278+
[
279+
'startLine' => $startLine,
280+
'endLine' => $endLine,
281+
'line' => $line,
282+
]
283+
);
284+
285+
throw new LogicException(
286+
sprintf(
287+
'Invalid Go cover file for %s %s %s. The start and end lines did not match up (%d > %d).',
288+
$provider->value,
289+
$owner,
290+
$repository,
291+
$startLine,
292+
$endLine,
293+
),
294+
);
295+
}
296+
297+
for ($i = $startLine; $i <= $endLine; ++$i) {
298+
// Everything is a statement in Go cover files
299+
$line = new Statement(
300+
lineNumber: $i,
301+
lineHits: $lineHits
302+
);
303+
304+
$file->setLine($line);
305+
}
306+
307+
return $coverage;
308+
}
309+
}

services/ingest/src/Strategy/ParseStrategyInterface.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace App\Strategy;
66

7+
use App\Exception\ParseException;
78
use App\Model\Coverage;
89
use Packages\Contracts\Provider\Provider;
910
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
@@ -19,14 +20,14 @@ interface ParseStrategyInterface
1920
* confirmed as capable of being handled by a given strategy. But it can be assumed
2021
* that if this method returns true, the parser will do a best-effort attempt
2122
* to produce a valid model of the coverage data.
22-
*
2323
*/
2424
public function supports(string $content): bool;
2525

2626
/**
2727
* Parse an arbitrary string (which is presumed to be a coverage file) using a given
2828
* strategy.
2929
*
30+
* @throws ParseException
3031
*/
3132
public function parse(
3233
Provider $provider,
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
mode: atomic
2+
github.com/some-owner/some-repo/examples/domain/main.go:11.13,18.16 5 0
3+
github.com/some-owner/some-repo/examples/domain/main.go:18.16,20.3 1 0
4+
5+
github.com/some-owner/some-repo/examples/domain/main.go:22.2,23.16 2 0
6+
github.com/some-owner/some-repo/examples/domain/main.go:23.16,25.3 1 0
7+
8+
github.com/some-owner/some-repo/examples/domain/main.go:27.2,27.25 1 0
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
mode: atomic
2+
mock/path/to/replace/github.com/some-owner/some-repo/examples/domain/main.go:11.13,18.16 5 0
3+
mock/path/to/replace/github.com/some-owner/some-repo/examples/domain/main.go:18.16,20.3 1 0
4+
mock/path/to/replace/github.com/some-owner/some-repo/examples/domain/main.go:22.2,23.16 2 0
5+
github.com/some-owner/some-repo/examples/domain/main.go:23.16,25.3 1 0
6+
mock/path/to/replace/github.com/some-owner/some-repo/examples/domain/main.go:27.2,27.25 1 0

0 commit comments

Comments
 (0)