Skip to content
Draft
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
4 changes: 3 additions & 1 deletion bin/coverage-guard
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<?php declare(strict_types=1);

use PhpParser\ParserFactory;
use ShipMonk\CoverageGuard\Ast\FileTraverser;
use ShipMonk\CoverageGuard\Cli\CliParser;
use ShipMonk\CoverageGuard\Cli\CommandRegistry;
use ShipMonk\CoverageGuard\Cli\CommandRunner;
Expand Down Expand Up @@ -56,7 +57,8 @@ try {
$pathHelper = new PathHelper($cwd);
$phpParser = (new ParserFactory())->createForHostVersion();
$stopwatch = new Stopwatch();
$coverageGuard = new CoverageGuard($stderrPrinter, $phpParser, $pathHelper, $patchParser, $coverageProvider, $stopwatch);
$fileTraverser = new FileTraverser($phpParser);
$coverageGuard = new CoverageGuard($stderrPrinter, $fileTraverser, $pathHelper, $patchParser, $coverageProvider, $stopwatch);
$errorFormatter = new ErrorFormatter($pathHelper, $stdoutPrinter);

$registry = new CommandRegistry();
Expand Down
1 change: 1 addition & 0 deletions composer-dependency-analyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType;

$config = new Configuration();
$config->ignoreUnknownClasses(['PhpParser\Node\Stmt\Throw_']); // old version of php-parser
$config->ignoreErrorsOnPackage('phpunit/php-code-coverage', [ErrorType::DEV_DEPENDENCY_IN_PROD]); // optional dependency to load .cov files
$config->ignoreErrorsOnPackage('sebastian/diff', [ErrorType::DEV_DEPENDENCY_IN_PROD]); // optional dependency to parse patch files
$config->ignoreErrorsOnExtension('ext-tokenizer', [ErrorType::SHADOW_DEPENDENCY]); // optional dependency to have syntax highlighting
Expand Down
3 changes: 3 additions & 0 deletions coverage-guard.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php declare(strict_types = 1);

use ShipMonk\CoverageGuard\Config;
use ShipMonk\CoverageGuard\Excluder\IgnoreThrowNewExceptionLineExcluder;
use ShipMonk\CoverageGuard\Hierarchy\ClassMethodBlock;
use ShipMonk\CoverageGuard\Hierarchy\CodeBlock;
use ShipMonk\CoverageGuard\Rule\CoverageError;
Expand Down Expand Up @@ -45,6 +46,8 @@ private function isPublicApiClass(ReflectionClass $classReflection): bool

});

$config->addExecutableLineExcluder(new IgnoreThrowNewExceptionLineExcluder([LogicException::class]));

$localConfig = __DIR__ . '/coverage-guard.local.php';
if (is_file($localConfig)) {
require $localConfig; // handy for $config->setEditorUrl()
Expand Down
8 changes: 8 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ parameters:
ShipMonk\CoverageGuard\Rule\CoverageRule: Rule
ShipMonk\CoverageGuard\Writer\CoverageWriter: CoverageWriter
ShipMonk\CoverageGuard\Extractor\CoverageExtractor: Extractor
ShipMonk\CoverageGuard\Excluder\ExecutableLineExcluder: LineExcluder

ignoreErrors:
# allow calling internal methods from SebastianBergmann\CodeCoverage
Expand All @@ -75,6 +76,13 @@ parameters:
paths:
- src/Utils/PatchParser.php

# support even old nikic/php-parser
-
message: '#PhpParser\\Node\\Stmt\\Throw_#'
identifier: class.notFound
paths:
- src/Excluder/IgnoreThrowNewExceptionLineExcluder.php

# do not track checked exceptions in tests
-
identifiers:
Expand Down
57 changes: 57 additions & 0 deletions src/Ast/FileTraverser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php declare(strict_types = 1);

namespace ShipMonk\CoverageGuard\Ast;

use LogicException;
use PhpParser\Error as ParseError;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\NameResolver;
use PhpParser\Parser as PhpParser;
use ShipMonk\CoverageGuard\CodeBlockAnalyser;
use ShipMonk\CoverageGuard\Exception\ErrorException;
use function implode;
use const PHP_EOL;

final class FileTraverser
{

public function __construct(
private readonly PhpParser $phpParser,
)
{
}

/**
* @param array<string> $fileLines
*
* @throws ErrorException
*/
public function traverse(
string $file,
array $fileLines,
CodeBlockAnalyser $analyser,
): void
{
$nameResolver = new NameResolver();

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

$analyserTraverser = new NodeTraverser();
$analyserTraverser->addVisitor($analyser);

try {
/** @throws ParseError */
$ast = $this->phpParser->parse(implode(PHP_EOL, $fileLines));
} catch (ParseError $e) {
throw new ErrorException("Failed to parse PHP code in file {$file}: {$e->getMessage()}", $e);
}

if ($ast === null) {
throw new LogicException("Failed to parse PHP code in file {$file}. Should never happen as Throwing error handler is used.");
}

$analyserTraverser->traverse($nameResolvingTraverser->traverse($ast));
}

}
114 changes: 63 additions & 51 deletions src/CodeBlockAnalyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,35 +7,49 @@
use PhpParser\Node\Stmt\ClassLike;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\NodeVisitorAbstract;
use ShipMonk\CoverageGuard\Excluder\ExecutableLineExcluder;
use ShipMonk\CoverageGuard\Hierarchy\ClassMethodBlock;
use ShipMonk\CoverageGuard\Hierarchy\LineOfCode;
use ShipMonk\CoverageGuard\Report\ReportedError;
use ShipMonk\CoverageGuard\Rule\CoverageRule;
use ShipMonk\CoverageGuard\Rule\InspectionContext;
use function assert;
use function array_pop;
use function end;
use function range;

final class CodeBlockAnalyser extends NodeVisitorAbstract
{

private ?string $currentClass = null;

private ?string $currentMethod = null;
/**
* Anonymous classes can be nested
*
* @var list<string|null>
*/
private array $currentClassStack = [];

private bool $inAnonymousClass = false;
/**
* Anonymous classes can cause nested methods
*
* @var list<string|null>
*/
private array $currentMethodStack = [];

/**
* @var list<ReportedError>
*/
private array $reportedErrors = [];

private InspectionContext $context;
/**
* @var array<int, int>
*/
private array $excludedLines = [];

/**
* @param array<int, int> $linesChanged line => line
* @param array<int, int> $linesCoverage executable_line => hits
* @param array<int, string> $linesContents
* @param list<CoverageRule> $rules
* @param list<ExecutableLineExcluder> $excluders
*/
public function __construct(
private readonly bool $patchMode,
Expand All @@ -44,70 +58,74 @@ public function __construct(
private readonly array $linesCoverage,
private readonly array $linesContents,
private readonly array $rules,
private readonly array $excluders,
)
{
$this->updateContext();
}

public function enterNode(Node $node): ?int
{
if ($node instanceof ClassLike) {
if ($node->name === null) {
$this->inAnonymousClass = true;
} else {
assert($node->namespacedName !== null); // using NameResolver
$this->currentClass = $node->namespacedName->toString();
$this->updateContext();
foreach ($this->excluders as $excluder) {
$excludedExecutableLineRange = $excluder->getExcludedLineRange($node);
if ($excludedExecutableLineRange !== null) {
foreach (range($excludedExecutableLineRange->getStart(), $excludedExecutableLineRange->getEnd()) as $excludedLine) {
$this->excludedLines[$excludedLine] = $excludedLine;
}
}
}

if ($node instanceof ClassMethod && $node->stmts !== null) {
if ($this->inAnonymousClass) {
return null; // ClassMethodBlock is emitted only for real methods
}
if ($this->currentClass === null) {
throw new LogicException('Found class method without a class, should never happen');
}
if ($node instanceof ClassLike) {
$this->currentClassStack[] = $node->namespacedName?->toString();
}

if ($node instanceof ClassMethod) {
$this->currentMethodStack[] = $node->name->name;
}

return null;
}

public function leaveNode(Node $node): mixed
{
if ($node instanceof ClassMethod && $node->stmts !== null) {
$currentClass = end($this->currentClassStack) !== false ? end($this->currentClassStack) : null;
$currentMethod = end($this->currentMethodStack) !== false ? end($this->currentMethodStack) : null;
$startLine = $node->name->getStartLine();
$endLine = $node->getEndLine();
$methodName = $node->name->toString();

$lines = $this->getLines($startLine, $endLine);
if ($lines === []) {
return null;
$classStr = $currentClass ?? 'unknown';
$methodStr = $currentMethod ?? 'unknown';
throw new LogicException("Class method '{$classStr}::{$methodStr}' has no executable lines although it has some statements");
}

$block = new ClassMethodBlock(
$node,
$lines,
);

$this->currentMethod = $methodName;
$this->updateContext();
$context = new InspectionContext(
className: $currentClass,
methodName: $currentMethod,
filePath: $this->filePath,
patchMode: $this->patchMode,
);

if ($this->patchMode && $block->getChangedLinesCount() === 0) {
return null; // unchanged methods not passed to rules in patch mode
}

foreach ($this->inspectCodeBlock($block) as $reportedError) {
foreach ($this->inspectCodeBlock($block, $context) as $reportedError) {
$this->reportedErrors[] = $reportedError;
}

return null;
}

return null;
}

public function leaveNode(Node $node): mixed
{
if ($node instanceof ClassLike) {
if ($node->name !== null) {
$this->currentClass = null;
} else {
$this->inAnonymousClass = false;
}
array_pop($this->currentClassStack);
}

if ($node instanceof ClassMethod) {
array_pop($this->currentMethodStack);
}

return null;
Expand All @@ -130,6 +148,7 @@ private function getLines(
$executableLines[] = new LineOfCode(
number: $lineNumber,
executable: isset($this->linesCoverage[$lineNumber]),
excluded: isset($this->excludedLines[$lineNumber]),
covered: isset($this->linesCoverage[$lineNumber]) && $this->linesCoverage[$lineNumber] > 0,
changed: isset($this->linesChanged[$lineNumber]),
contents: $this->linesContents[$lineNumber],
Expand All @@ -141,11 +160,14 @@ private function getLines(
/**
* @return list<ReportedError>
*/
private function inspectCodeBlock(ClassMethodBlock $block): array
private function inspectCodeBlock(
ClassMethodBlock $block,
InspectionContext $context,
): array
{
$reportedErrors = [];
foreach ($this->rules as $rule) {
$coverageError = $rule->inspect($block, $this->context);
$coverageError = $rule->inspect($block, $context);

if ($coverageError !== null) {
$reportedErrors[] = new ReportedError($this->filePath, $block, $coverageError);
Expand All @@ -163,14 +185,4 @@ public function getReportedErrors(): array
return $this->reportedErrors;
}

private function updateContext(): void
{
$this->context = new InspectionContext(
className: $this->currentClass,
methodName: $this->currentMethod,
filePath: $this->filePath,
patchMode: $this->patchMode,
);
}

}
2 changes: 1 addition & 1 deletion src/Command/PatchCoverageCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public function __invoke(

$this->stdoutPrinter->printLine('Patch Coverage Statistics:');
$this->stdoutPrinter->printLine('');
$this->stdoutPrinter->printLine(" Changed executable lines: {$totalChangedLines}");
$this->stdoutPrinter->printLine(" Changed executable lines: {$totalChangedLines}"); // TODO excluders should be used
$this->stdoutPrinter->printLine(" Covered lines: <green>{$totalCoveredLines}</green>");
$this->stdoutPrinter->printLine(' Uncovered lines: <orange>' . ($totalChangedLines - $totalCoveredLines) . '</orange>');
$this->stdoutPrinter->printLine(" Coverage: {$percentageFormatted}%");
Expand Down
23 changes: 23 additions & 0 deletions src/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace ShipMonk\CoverageGuard;

use ShipMonk\CoverageGuard\Exception\ErrorException;
use ShipMonk\CoverageGuard\Excluder\ExecutableLineExcluder;
use ShipMonk\CoverageGuard\Rule\CoverageRule;
use ShipMonk\CoverageGuard\Utils\FileUtils;
use function file_exists;
Expand All @@ -29,6 +30,11 @@ final class Config
*/
private array $rules = [];

/**
* @var list<ExecutableLineExcluder>
*/
private array $excluders = [];

private ?string $editorUrl = null;

public function __construct()
Expand Down Expand Up @@ -81,6 +87,15 @@ public function addRule(CoverageRule $rule): self
return $this;
}

/**
* Allows you to ignore/exclude certain executable lines from coverage calculations.
*/
public function addExecutableLineExcluder(ExecutableLineExcluder $excluder): self
{
$this->excluders[] = $excluder;
return $this;
}

/**
* Set the editor URL pattern to make filepaths clickable in CLI output via OSC 8 hyperlink
*
Expand Down Expand Up @@ -113,6 +128,14 @@ public function getCoveragePathMapping(): array
return $this->coveragePathMapping;
}

/**
* @return list<ExecutableLineExcluder>
*/
public function getExecutableLineExcluders(): array
{
return $this->excluders;
}

/**
* @return list<CoverageRule>
*/
Expand Down
Loading