Skip to content

Commit b969686

Browse files
authored
ExecutableLineExcluder: ability not to count certain lines as executable (#67)
1 parent 9283427 commit b969686

33 files changed

Lines changed: 964 additions & 157 deletions

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ vendor/bin/coverage-guard check clover.xml --patch changes.patch
6969
<?php
7070

7171
use ShipMonk\CoverageGuard\Config;
72+
use ShipMonk\CoverageGuard\Excluder\IgnoreThrowNewExceptionLineExcluder;
7273
use ShipMonk\CoverageGuard\Rule\EnforceCoverageForMethodsRule;
7374

7475
$config = new Config();
@@ -80,6 +81,9 @@ $config->addRule(new EnforceCoverageForMethodsRule(
8081
minExecutableLines: 5, // only check methods with at least 5 executable lines
8182
));
8283

84+
// Lines you don't want to enforce coverage for (they stop being treated as executable)
85+
$config->addExecutableLineExcluder(new IgnoreThrowNewExceptionLineExcluder([LogicException::class]));
86+
8387
// Replace prefix of absolute paths in coverage files
8488
// Handy if you want to reuse clover.xml generated in CI
8589
$config->addCoveragePathMapping('/absolute/ci/prefix', __DIR__);
@@ -102,10 +106,14 @@ return $config;
102106
- [own coverage config](./coverage-guard.php)
103107
- [dead-code-detector](https://github.com/shipmonk-rnd/dead-code-detector/blob/master/coverage-guard.php)
104108
- [phpstan-rules](https://github.com/shipmonk-rnd/phpstan-rules/blob/master/coverage-guard.php)
109+
- For custom line exclusion, implement `ExecutableLineExcluder` and pass it to `Config::addExecutableLineExcluder()` method:
110+
- It receives each AST node together with `ExclusionContext` (file path, line contents) and may return an `ExcludedLineRange`
111+
- Excluded lines are honored by both `check` and `patch-coverage` commands and rendered with gray background
112+
- Inspire by prepared [`IgnoreThrowNewExceptionLineExcluder`](src/Excluder/IgnoreThrowNewExceptionLineExcluder.php)
105113

106114

107115
### What can you enforce:
108-
The `CodeBlock` class passed to `CoverageRule` is aware **which line is executable, changed and covered**.
116+
The `CodeBlock` class passed to `CoverageRule` is aware **which line is executable, excluded, changed and covered**.
109117
Also, you can use **reflection** to pinpoint your rules.
110118
This allows you to setup huge variety of rules, examples:
111119

bin/coverage-guard

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ try {
6565
$registry->register(new CheckCommand($configResolver, $coverageGuard, $errorFormatter));
6666
$registry->register(new MergeCommand($coverageProvider, new CoverageMerger(), $coverageFormatDetector, $configResolver, $stdoutPrinter));
6767
$registry->register(new ConvertCommand($coverageProvider, $configResolver, $stdoutPrinter));
68-
$registry->register(new PatchCoverageCommand($stdoutPrinter, $patchParser, $configResolver, $coverageProvider));
68+
$registry->register(new PatchCoverageCommand($stdoutPrinter, $patchParser, $configResolver, $coverageProvider, $fileTraverser));
6969
$registry->register(new InitCommand($cwd, $stdoutPrinter));
7070

7171
$parameterResolver = new ParameterResolver();

composer-dependency-analyser.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType;
55

66
$config = new Configuration();
7+
$config->ignoreUnknownClasses(['PhpParser\Node\Stmt\Throw_']); // old version of php-parser
78
$config->ignoreErrorsOnPackage('phpunit/php-code-coverage', [ErrorType::DEV_DEPENDENCY_IN_PROD]); // optional dependency to load .cov files
89
$config->ignoreErrorsOnPackage('sebastian/diff', [ErrorType::DEV_DEPENDENCY_IN_PROD]); // optional dependency to parse patch files
910
$config->ignoreErrorsOnExtension('ext-tokenizer', [ErrorType::SHADOW_DEPENDENCY]); // optional dependency to have syntax highlighting

coverage-guard.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?php declare(strict_types = 1);
22

33
use ShipMonk\CoverageGuard\Config;
4+
use ShipMonk\CoverageGuard\Excluder\IgnoreThrowNewExceptionLineExcluder;
45
use ShipMonk\CoverageGuard\Hierarchy\ClassMethodBlock;
56
use ShipMonk\CoverageGuard\Hierarchy\CodeBlock;
67
use ShipMonk\CoverageGuard\Rule\CoverageError;
@@ -45,6 +46,8 @@ private function isPublicApiClass(ReflectionClass $classReflection): bool
4546

4647
});
4748

49+
$config->addExecutableLineExcluder(new IgnoreThrowNewExceptionLineExcluder([LogicException::class]));
50+
4851
$localConfig = __DIR__ . '/coverage-guard.local.php';
4952
if (is_file($localConfig)) {
5053
require $localConfig; // handy for $config->setEditorUrl()

phpstan.neon.dist

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ parameters:
6262
ShipMonk\CoverageGuard\Rule\CoverageRule: Rule
6363
ShipMonk\CoverageGuard\Writer\CoverageWriter: CoverageWriter
6464
ShipMonk\CoverageGuard\Extractor\CoverageExtractor: Extractor
65+
ShipMonk\CoverageGuard\Excluder\ExecutableLineExcluder: LineExcluder
6566

6667
ignoreErrors:
6768
# allow calling internal methods from SebastianBergmann\CodeCoverage
@@ -77,6 +78,13 @@ parameters:
7778
paths:
7879
- src/Utils/PatchParser.php
7980

81+
# support even old nikic/php-parser
82+
-
83+
message: '#PhpParser\\Node\\Stmt\\Throw_#'
84+
identifier: class.notFound
85+
paths:
86+
- src/Excluder/IgnoreThrowNewExceptionLineExcluder.php
87+
8088
# do not track checked exceptions in tests
8189
-
8290
identifiers:

src/Ast/FileTraverser.php

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
use LogicException;
66
use PhpParser\Error as ParseError;
77
use PhpParser\NodeTraverser;
8+
use PhpParser\NodeVisitor;
89
use PhpParser\NodeVisitor\NameResolver;
910
use PhpParser\Parser as PhpParser;
10-
use ShipMonk\CoverageGuard\CodeBlockAnalyser;
1111
use ShipMonk\CoverageGuard\Exception\ErrorException;
1212
use function implode;
1313
use const PHP_EOL;
@@ -29,17 +29,14 @@ public function __construct(
2929
public function traverse(
3030
string $file,
3131
array $fileLines,
32-
CodeBlockAnalyser $analyser,
32+
NodeVisitor ...$visitors,
3333
): void
3434
{
3535
$nameResolver = new NameResolver();
3636

3737
$nameResolvingTraverser = new NodeTraverser();
3838
$nameResolvingTraverser->addVisitor($nameResolver);
3939

40-
$analyserTraverser = new NodeTraverser();
41-
$analyserTraverser->addVisitor($analyser);
42-
4340
try {
4441
/** @throws ParseError */
4542
$ast = $this->phpParser->parse(implode(PHP_EOL, $fileLines));
@@ -51,7 +48,14 @@ public function traverse(
5148
throw new LogicException("Failed to parse PHP code in file {$file}. Should never happen as Throwing error handler is used.");
5249
}
5350

54-
$analyserTraverser->traverse($nameResolvingTraverser->traverse($ast));
51+
$resolvedAst = $nameResolvingTraverser->traverse($ast);
52+
53+
// each visitor gets its own traversal so that later visitors can rely on complete state of earlier ones
54+
foreach ($visitors as $visitor) {
55+
$visitorTraverser = new NodeTraverser();
56+
$visitorTraverser->addVisitor($visitor);
57+
$visitorTraverser->traverse($resolvedAst);
58+
}
5559
}
5660

5761
}

src/CodeBlockAnalyser.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use PhpParser\Node\Stmt\ClassLike;
88
use PhpParser\Node\Stmt\ClassMethod;
99
use PhpParser\NodeVisitorAbstract;
10+
use ShipMonk\CoverageGuard\Excluder\ExcluderVisitor;
1011
use ShipMonk\CoverageGuard\Hierarchy\ClassMethodBlock;
1112
use ShipMonk\CoverageGuard\Hierarchy\LineOfCode;
1213
use ShipMonk\CoverageGuard\Report\ReportedError;
@@ -36,6 +37,7 @@ final class CodeBlockAnalyser extends NodeVisitorAbstract
3637
* @param array<int, int> $linesCoverage executable_line => hits
3738
* @param array<int, string> $linesContents
3839
* @param list<CoverageRule> $rules
40+
* @param ExcluderVisitor $excluderVisitor must be traversed before this visitor
3941
*/
4042
public function __construct(
4143
private readonly bool $patchMode,
@@ -44,6 +46,7 @@ public function __construct(
4446
private readonly array $linesCoverage,
4547
private readonly array $linesContents,
4648
private readonly array $rules,
49+
private readonly ExcluderVisitor $excluderVisitor,
4750
)
4851
{
4952
$this->updateContext();
@@ -130,6 +133,7 @@ private function getLines(
130133
$executableLines[] = new LineOfCode(
131134
number: $lineNumber,
132135
executable: isset($this->linesCoverage[$lineNumber]),
136+
excluded: $this->excluderVisitor->isLineExcluded($lineNumber),
133137
covered: isset($this->linesCoverage[$lineNumber]) && $this->linesCoverage[$lineNumber] > 0,
134138
changed: isset($this->linesChanged[$lineNumber]),
135139
contents: $this->linesContents[$lineNumber],

src/Command/PatchCoverageCommand.php

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,24 @@
22

33
namespace ShipMonk\CoverageGuard\Command;
44

5+
use ShipMonk\CoverageGuard\Ast\FileTraverser;
56
use ShipMonk\CoverageGuard\Cli\Arguments\CoverageFileCliArgument;
67
use ShipMonk\CoverageGuard\Cli\Options\ConfigCliOption;
78
use ShipMonk\CoverageGuard\Cli\Options\PatchCliOption;
9+
use ShipMonk\CoverageGuard\Coverage\FileCoverage;
810
use ShipMonk\CoverageGuard\CoverageProvider;
911
use ShipMonk\CoverageGuard\Exception\ErrorException;
12+
use ShipMonk\CoverageGuard\Excluder\ExcluderVisitor;
13+
use ShipMonk\CoverageGuard\Excluder\ExclusionContext;
14+
use ShipMonk\CoverageGuard\Excluder\ExecutableLineExcluder;
1015
use ShipMonk\CoverageGuard\Printer;
1116
use ShipMonk\CoverageGuard\Utils\ConfigResolver;
17+
use ShipMonk\CoverageGuard\Utils\FileUtils;
1218
use ShipMonk\CoverageGuard\Utils\PatchParser;
19+
use function array_combine;
20+
use function count;
1321
use function number_format;
22+
use function range;
1423

1524
final class PatchCoverageCommand implements Command
1625
{
@@ -20,6 +29,7 @@ public function __construct(
2029
private readonly PatchParser $patchParser,
2130
private readonly ConfigResolver $configResolver,
2231
private readonly CoverageProvider $coverageProvider,
32+
private readonly FileTraverser $fileTraverser,
2333
)
2434
{
2535
}
@@ -42,6 +52,7 @@ public function __invoke(
4252

4353
$coveragePerFile = $this->coverageProvider->getCoverage($config, $coverageFile);
4454
$changesPerFile = $this->patchParser->getPatchChangedLines($patchPath, $config);
55+
$excluders = $config->getExecutableLineExcluders();
4556

4657
// Calculate coverage for changed lines
4758
$totalChangedLines = 0;
@@ -52,23 +63,76 @@ public function __invoke(
5263
continue; // File not in coverage report
5364
}
5465

55-
$fileCoverage = $coveragePerFile[$file];
56-
$executableLinesMap = [];
66+
$changedExecutableLines = $this->getChangedExecutableLines($coveragePerFile[$file], $changedLines);
5767

58-
foreach ($fileCoverage->executableLines as $line) {
59-
$executableLinesMap[$line->lineNumber] = $line->hits > 0;
60-
}
68+
$excluderVisitor = $excluders !== [] && $changedExecutableLines !== []
69+
? $this->createExcluderVisitor($excluders, $file)
70+
: null;
6171

62-
foreach ($changedLines as $lineNumber) {
63-
if (isset($executableLinesMap[$lineNumber])) {
64-
$totalChangedLines++;
65-
if ($executableLinesMap[$lineNumber]) {
66-
$totalCoveredLines++;
67-
}
72+
foreach ($changedExecutableLines as $lineNumber => $isCovered) {
73+
if ($excluderVisitor?->isLineExcluded($lineNumber) === true) {
74+
continue;
75+
}
76+
$totalChangedLines++;
77+
if ($isCovered) {
78+
$totalCoveredLines++;
6879
}
6980
}
7081
}
7182

83+
$this->printStatistics($totalChangedLines, $totalCoveredLines);
84+
85+
return 0;
86+
}
87+
88+
/**
89+
* @param list<int> $changedLines
90+
* @return array<int, bool> line number => is covered
91+
*/
92+
private function getChangedExecutableLines(
93+
FileCoverage $fileCoverage,
94+
array $changedLines,
95+
): array
96+
{
97+
$executableLinesMap = [];
98+
foreach ($fileCoverage->executableLines as $line) {
99+
$executableLinesMap[$line->lineNumber] = $line->hits > 0;
100+
}
101+
102+
$changedExecutableLines = [];
103+
foreach ($changedLines as $lineNumber) {
104+
if (isset($executableLinesMap[$lineNumber])) {
105+
$changedExecutableLines[$lineNumber] = $executableLinesMap[$lineNumber];
106+
}
107+
}
108+
109+
return $changedExecutableLines;
110+
}
111+
112+
/**
113+
* @param non-empty-list<ExecutableLineExcluder> $excluders
114+
*
115+
* @throws ErrorException
116+
*/
117+
private function createExcluderVisitor(
118+
array $excluders,
119+
string $file,
120+
): ExcluderVisitor
121+
{
122+
$fileLines = FileUtils::readFileLines($file);
123+
$linesContents = array_combine(range(1, count($fileLines)), $fileLines);
124+
125+
$excluderVisitor = new ExcluderVisitor($excluders, new ExclusionContext($file, $linesContents));
126+
$this->fileTraverser->traverse($file, $fileLines, $excluderVisitor);
127+
128+
return $excluderVisitor;
129+
}
130+
131+
private function printStatistics(
132+
int $totalChangedLines,
133+
int $totalCoveredLines,
134+
): void
135+
{
72136
if ($totalChangedLines === 0) {
73137
$percentage = 0;
74138
} else {
@@ -84,8 +148,6 @@ public function __invoke(
84148
$this->stdoutPrinter->printLine(' Uncovered lines: <orange>' . ($totalChangedLines - $totalCoveredLines) . '</orange>');
85149
$this->stdoutPrinter->printLine(" Coverage: {$percentageFormatted}%");
86150
$this->stdoutPrinter->printLine('');
87-
88-
return 0;
89151
}
90152

91153
public function getName(): string

src/Config.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace ShipMonk\CoverageGuard;
44

55
use ShipMonk\CoverageGuard\Exception\ErrorException;
6+
use ShipMonk\CoverageGuard\Excluder\ExecutableLineExcluder;
67
use ShipMonk\CoverageGuard\Rule\CoverageRule;
78
use ShipMonk\CoverageGuard\Utils\FileUtils;
89
use function file_exists;
@@ -31,6 +32,11 @@ final class Config
3132
*/
3233
private array $rules = [];
3334

35+
/**
36+
* @var list<ExecutableLineExcluder>
37+
*/
38+
private array $excluders = [];
39+
3440
private ?string $editorUrl = null;
3541

3642
public function __construct()
@@ -86,6 +92,15 @@ public function addRule(CoverageRule $rule): self
8692
return $this;
8793
}
8894

95+
/**
96+
* Allows you to ignore/exclude certain executable lines from coverage calculations.
97+
*/
98+
public function addExecutableLineExcluder(ExecutableLineExcluder $excluder): self
99+
{
100+
$this->excluders[] = $excluder;
101+
return $this;
102+
}
103+
89104
/**
90105
* Set the editor URL pattern to make filepaths clickable in CLI output via OSC 8 hyperlink
91106
*
@@ -118,6 +133,14 @@ public function getCoveragePathMapping(): array
118133
return $this->coveragePathMapping;
119134
}
120135

136+
/**
137+
* @return list<ExecutableLineExcluder>
138+
*/
139+
public function getExecutableLineExcluders(): array
140+
{
141+
return $this->excluders;
142+
}
143+
121144
/**
122145
* @return list<CoverageRule>
123146
*/

0 commit comments

Comments
 (0)