Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion services/ingest/src/Model/File.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public function getFileName(): string
}

/**
* @return AbstractLine[]
* @return array<array-key, AbstractLine>
*/
public function getLines(): array
{
Expand Down
7 changes: 6 additions & 1 deletion services/ingest/src/Model/Line/AbstractLine.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ abstract class AbstractLine implements Stringable
{
public function __construct(
private readonly int $lineNumber,
private readonly int $lineHits = 0
private int $lineHits = 0
) {
}

Expand Down Expand Up @@ -52,6 +52,11 @@ public function getLineNumber(): int
return $this->lineNumber;
}

public function setLineHits(int $lineHits): void
{
$this->lineHits = $lineHits;
}

public function getLineHits(): int
{
return $this->lineHits;
Expand Down
309 changes: 309 additions & 0 deletions services/ingest/src/Strategy/GoCover/GoCoverParseStrategy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
<?php

declare(strict_types=1);

namespace App\Strategy\GoCover;

use App\Exception\ParseException;
use App\Model\Coverage;
use App\Model\File;
use App\Model\Line\Branch;
use App\Model\Line\Statement;
use App\Service\PathFixingService;
use App\Strategy\ParseStrategyInterface;
use Exception;
use LogicException;
use OutOfBoundsException;
use Override;
use Packages\Contracts\Format\CoverageFormat;
use Packages\Contracts\Provider\Provider;
use Psr\Log\LoggerInterface;

final readonly class GoCoverParseStrategy implements ParseStrategyInterface
{
/**
* Each line is formatted as `name.go:line.column,line.column numberOfStatements count`.
*/
// phpcs:ignore
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]+)$/';

/**
* If arguments such as mode are provided, we can skip the line.
*/
private const string MODE_STRUCTURE = 'mode:';

public function __construct(
private LoggerInterface $parseStrategyLogger,
private PathFixingService $pathFixingService
) {
}

#[Override]
public function supports(string $content): bool
{
/** @var string[] $lines */
$lines = preg_split('/\n|\r\n?/', $content);

foreach ($lines as $line) {
$line = trim($line);

// Skip empty lines
if ($line === '') {
continue;
}

// If a mode (i.e. atomic) is provided, skip the line
if (str_starts_with($line, self::MODE_STRUCTURE)) {
continue;
}

// Match the record type and its data
if (preg_match(self::LINE_STRUCTURE, $line) !== 1) {
$this->parseStrategyLogger->error(
'Unable to validate structure of line in Go Cover file.',
[
'line' => $line
]
);

return false;
}
}

return true;
}

#[Override]
public function parse(
Provider $provider,
string $owner,
string $repository,
string $projectRoot,
string $content
): Coverage {
if (!$this->supports($content)) {
throw ParseException::notSupportedException();
}

try {
$coverage = new Coverage(
CoverageFormat::GO_COVER,
root: $projectRoot,
);
} catch (Exception $exception) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The next 10 lines are not covered by any tests.

throw new ParseException(
sprintf(
'Failed to create coverage model for %s %s %s. Error was %s',
$provider->value,
$owner,
$repository,
$exception
),
previous: $exception
);
}

/** @var string[] $blocks */
$blocks = preg_split('/\n|\r\n?/', $content);

try {
foreach ($blocks as $block) {
$block = trim($block);

// Skip empty lines
if ($block === '') {
continue;
}

// If a mode (i.e. atomic) is provided, skip the line
if (str_starts_with($block, self::MODE_STRUCTURE)) {
continue;
}

$coverage = $this->handleBlock(
$provider,
$owner,
$repository,
$coverage,
$block
);
}
} catch (LogicException $logicException) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The next 9 lines are not covered by any tests.

throw new ParseException(
sprintf(
'Unable to parse coverage of line in Go Cover file for %s %s %s.',
$provider->value,
$owner,
$repository,
),
previous: $logicException
);
}

return $coverage;
}

/**
* Each line of a Go Coverage file is a 'block' of coverage - where a block has a start and end line (and
* start and end columns), but all have the same hit count.
*
* For example, a block which spans 2 lines (and has 2 statements) but is never hit will look
* like this:
*
* ```
* github.com/some-owner/some-repo/internal/cmd/ipv6/main.go:28.2,30.16 2 0
* ```
*
* @throws LogicException
*/
private function handleBlock(
Provider $provider,
string $owner,
string $repository,
Coverage $coverage,
string $line
): Coverage {
$line = trim($line);

// Skip empty lines
if ($line === '') {
return $coverage;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is not covered by any tests.

}

$parts = [];

// Match the record type and its data
if (preg_match(self::LINE_STRUCTURE, $line, $parts) !== 1) {
throw new ParseException(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The next 5 lines are not covered by any tests.

sprintf(
'Unable to parse structure of line in Go Cover file: %s',
$line
)
);
}

// Perform any path fixings we need to do
$filePath = $this->pathFixingService->fixPath(
$provider,
$owner,
$repository,
$parts['file'],
$coverage->getRoot()
);

$files = $coverage->getFiles();

/**
* Generally Go cover files are in sequential order - where all of the blocks for
* a particular file are together sequentially. So most of the time the last item
* of the coverage should be the one we're looking for.
*
* If its not, it _should_ be the first time we've seen the file path - in which case,
* we need to setup a new file!
*/
$file = end($files);

if (!$file || $file->getFileName() !== $filePath) {
$file = new File(
fileName: $filePath,
lines: []
);
$coverage->addFile($file);
}

$lineHits = (int)$parts['count'];

$startLine = (int)$parts['startLine'];
$endLine = (int)$parts['endLine'];

try {
/**
* Each block of a Go cover file (represented as a single line) will span between two lines (with
* start and end columns recorded).
*
* It's possible that between two blocks, one block ends at column 39, and another block starts
* from 39 onwards (non-inclusive).
*
* For example:
*
* ```
* github.com/some-owner/some-repo/internal/ipv4/ipv4.go:54.2,54.46 1 1
* github.com/some-owner/some-repo/internal/ipv4/ipv4.go:54.46,56.3 1 1
* ```
*
* If one starts while the other ends on the same line, that means we've uncovered an if/elseif/else
* line - and so we should any existing line data we have into a branch, and anything else in the block
* should continue as statements.
*/
$alreadyParsedLine = $file->getLine((string)$startLine);

if ($alreadyParsedLine instanceof Branch) {
// Increment the existing branch
$alreadyParsedLine->setLineHits($alreadyParsedLine->getLineHits() + $lineHits);
$alreadyParsedLine->addToBranchHits(count($alreadyParsedLine->getBranchHits()) + 1, $lineHits);
} else {
// The line we already have tracked is not a branch (it wont be when running through the
// individual block data), meaning we should convert it to a branch now we officially know its
// type isn't a simple statement.
$file->setLine(
new Branch(
lineNumber: $startLine,
lineHits: $alreadyParsedLine->getLineHits() + $lineHits,
branchHits: [
$alreadyParsedLine->getLineHits(),
$lineHits
]
)
);
}

if ($startLine === $endLine) {
// This block only spans the single line - we've already recorded it, so can finish
// here
return $coverage;
}

++$startLine;
} catch (OutOfBoundsException) {
// No line recorded with this line number - we can start from here.
}

if ($startLine > $endLine) {
$this->parseStrategyLogger->error(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The next 22 lines are not covered by any tests.

sprintf(
'Invalid Go cover file. The end of a block (%s) started before the start line (%s)',
$endLine,
$startLine,
),
[
'startLine' => $startLine,
'endLine' => $endLine,
'line' => $line,
]
);

throw new LogicException(
sprintf(
'Invalid Go cover file for %s %s %s. The start and end lines did not match up (%d > %d).',
$provider->value,
$owner,
$repository,
$startLine,
$endLine,
),
);
}

for ($i = $startLine; $i <= $endLine; ++$i) {
// Everything is a statement in Go cover files
$line = new Statement(
lineNumber: $i,
lineHits: $lineHits
);

$file->setLine($line);
}

return $coverage;
}
}
3 changes: 2 additions & 1 deletion services/ingest/src/Strategy/ParseStrategyInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Strategy;

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

/**
* Parse an arbitrary string (which is presumed to be a coverage file) using a given
* strategy.
*
* @throws ParseException
*/
public function parse(
Provider $provider,
Expand Down
8 changes: 8 additions & 0 deletions services/ingest/tests/Fixture/GoCover/go-empty-lines.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
mode: atomic
github.com/some-owner/some-repo/examples/domain/main.go:11.13,18.16 5 0
github.com/some-owner/some-repo/examples/domain/main.go:18.16,20.3 1 0

github.com/some-owner/some-repo/examples/domain/main.go:22.2,23.16 2 0
github.com/some-owner/some-repo/examples/domain/main.go:23.16,25.3 1 0

github.com/some-owner/some-repo/examples/domain/main.go:27.2,27.25 1 0
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
mode: atomic
mock/path/to/replace/github.com/some-owner/some-repo/examples/domain/main.go:11.13,18.16 5 0
mock/path/to/replace/github.com/some-owner/some-repo/examples/domain/main.go:18.16,20.3 1 0
mock/path/to/replace/github.com/some-owner/some-repo/examples/domain/main.go:22.2,23.16 2 0
github.com/some-owner/some-repo/examples/domain/main.go:23.16,25.3 1 0
mock/path/to/replace/github.com/some-owner/some-repo/examples/domain/main.go:27.2,27.25 1 0
Loading
Loading