Skip to content

Commit 5fcfe8f

Browse files
authored
Rules to check @covers and @coversDefaultClass for methods and classes
1 parent d963a07 commit 5fcfe8f

9 files changed

+549
-0
lines changed

Diff for: extension.neon

+2
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ services:
5151
class: PHPStan\Type\PHPUnit\MockObjectDynamicReturnTypeExtension
5252
tags:
5353
- phpstan.broker.dynamicMethodReturnTypeExtension
54+
-
55+
class: PHPStan\Rules\PHPUnit\CoversHelper
5456

5557
conditionalTags:
5658
PHPStan\PhpDoc\PHPUnit\MockObjectTypeNodeResolverExtension:

Diff for: rules.neon

+10
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,13 @@ rules:
44
- PHPStan\Rules\PHPUnit\AssertSameWithCountRule
55
- PHPStan\Rules\PHPUnit\MockMethodCallRule
66
- PHPStan\Rules\PHPUnit\ShouldCallParentMethodsRule
7+
8+
services:
9+
- class: PHPStan\Rules\PHPUnit\ClassCoversExistsRule
10+
- class: PHPStan\Rules\PHPUnit\ClassMethodCoversExistsRule
11+
12+
conditionalTags:
13+
PHPStan\Rules\PHPUnit\ClassCoversExistsRule:
14+
phpstan.rules.rule: %featureToggles.bleedingEdge%
15+
PHPStan\Rules\PHPUnit\ClassMethodCoversExistsRule:
16+
phpstan.rules.rule: %featureToggles.bleedingEdge%

Diff for: src/Rules/PHPUnit/ClassCoversExistsRule.php

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\PHPUnit;
4+
5+
use PhpParser\Node;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\Node\InClassNode;
8+
use PHPStan\Reflection\ReflectionProvider;
9+
use PHPStan\Rules\Rule;
10+
use PHPStan\Rules\RuleErrorBuilder;
11+
use PHPUnit\Framework\TestCase;
12+
use function array_merge;
13+
use function array_shift;
14+
use function count;
15+
use function sprintf;
16+
17+
/**
18+
* @implements Rule<InClassNode>
19+
*/
20+
class ClassCoversExistsRule implements Rule
21+
{
22+
23+
/**
24+
* Covers helper.
25+
*
26+
* @var CoversHelper
27+
*/
28+
private $coversHelper;
29+
30+
/**
31+
* Reflection provider.
32+
*
33+
* @var ReflectionProvider
34+
*/
35+
private $reflectionProvider;
36+
37+
public function __construct(
38+
CoversHelper $coversHelper,
39+
ReflectionProvider $reflectionProvider
40+
)
41+
{
42+
$this->reflectionProvider = $reflectionProvider;
43+
$this->coversHelper = $coversHelper;
44+
}
45+
46+
public function getNodeType(): string
47+
{
48+
return InClassNode::class;
49+
}
50+
51+
public function processNode(Node $node, Scope $scope): array
52+
{
53+
$classReflection = $node->getClassReflection();
54+
55+
if (!$classReflection->isSubclassOf(TestCase::class)) {
56+
return [];
57+
}
58+
59+
$errors = [];
60+
$classPhpDoc = $classReflection->getResolvedPhpDoc();
61+
[$classCovers, $classCoversDefaultClasses] = $this->coversHelper->getCoverAnnotations($classPhpDoc);
62+
63+
if (count($classCoversDefaultClasses) >= 2) {
64+
$errors[] = RuleErrorBuilder::message(sprintf(
65+
'@coversDefaultClass is defined multiple times.'
66+
))->build();
67+
68+
return $errors;
69+
}
70+
71+
$coversDefaultClass = array_shift($classCoversDefaultClasses);
72+
73+
if ($coversDefaultClass !== null) {
74+
$className = (string) $coversDefaultClass->value;
75+
if (!$this->reflectionProvider->hasClass($className)) {
76+
$errors[] = RuleErrorBuilder::message(sprintf(
77+
'@coversDefaultClass references an invalid class %s.',
78+
$className
79+
))->build();
80+
}
81+
}
82+
83+
foreach ($classCovers as $covers) {
84+
$errors = array_merge(
85+
$errors,
86+
$this->coversHelper->processCovers($node, $covers, null)
87+
);
88+
}
89+
90+
return $errors;
91+
}
92+
93+
}

Diff for: src/Rules/PHPUnit/ClassMethodCoversExistsRule.php

+118
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\PHPUnit;
4+
5+
use PhpParser\Node;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
8+
use PHPStan\Rules\Rule;
9+
use PHPStan\Rules\RuleErrorBuilder;
10+
use PHPStan\Type\FileTypeMapper;
11+
use PHPUnit\Framework\TestCase;
12+
use function array_map;
13+
use function array_merge;
14+
use function array_shift;
15+
use function count;
16+
use function in_array;
17+
use function sprintf;
18+
19+
/**
20+
* @implements Rule<Node\Stmt\ClassMethod>
21+
*/
22+
class ClassMethodCoversExistsRule implements Rule
23+
{
24+
25+
/**
26+
* Covers helper.
27+
*
28+
* @var CoversHelper
29+
*/
30+
private $coversHelper;
31+
32+
/**
33+
* The file type mapper.
34+
*
35+
* @var FileTypeMapper
36+
*/
37+
private $fileTypeMapper;
38+
39+
public function __construct(
40+
CoversHelper $coversHelper,
41+
FileTypeMapper $fileTypeMapper
42+
)
43+
{
44+
$this->coversHelper = $coversHelper;
45+
$this->fileTypeMapper = $fileTypeMapper;
46+
}
47+
48+
public function getNodeType(): string
49+
{
50+
return Node\Stmt\ClassMethod::class;
51+
}
52+
53+
public function processNode(Node $node, Scope $scope): array
54+
{
55+
$classReflection = $scope->getClassReflection();
56+
57+
if ($classReflection === null) {
58+
return [];
59+
}
60+
61+
if (!$classReflection->isSubclassOf(TestCase::class)) {
62+
return [];
63+
}
64+
65+
$errors = [];
66+
$classPhpDoc = $classReflection->getResolvedPhpDoc();
67+
[$classCovers, $classCoversDefaultClasses] = $this->coversHelper->getCoverAnnotations($classPhpDoc);
68+
69+
$classCoversStrings = array_map(static function (PhpDocTagNode $covers): string {
70+
return (string) $covers->value;
71+
}, $classCovers);
72+
73+
$docComment = $node->getDocComment();
74+
if ($docComment === null) {
75+
return [];
76+
}
77+
78+
$coversDefaultClass = count($classCoversDefaultClasses) === 1
79+
? array_shift($classCoversDefaultClasses)
80+
: null;
81+
82+
$methodPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc(
83+
$scope->getFile(),
84+
$classReflection->getName(),
85+
$scope->isInTrait() ? $scope->getTraitReflection()->getName() : null,
86+
$node->name->toString(),
87+
$docComment->getText()
88+
);
89+
90+
[$methodCovers, $methodCoversDefaultClasses] = $this->coversHelper->getCoverAnnotations($methodPhpDoc);
91+
92+
$errors = [];
93+
94+
if (count($methodCoversDefaultClasses) > 0) {
95+
$errors[] = RuleErrorBuilder::message(sprintf(
96+
'@coversDefaultClass defined on class method %s.',
97+
$node->name
98+
))->build();
99+
}
100+
101+
foreach ($methodCovers as $covers) {
102+
if (in_array((string) $covers->value, $classCoversStrings, true)) {
103+
$errors[] = RuleErrorBuilder::message(sprintf(
104+
'Class already @covers %s so the method @covers is redundant.',
105+
$covers->value
106+
))->build();
107+
}
108+
109+
$errors = array_merge(
110+
$errors,
111+
$this->coversHelper->processCovers($node, $covers, $coversDefaultClass)
112+
);
113+
}
114+
115+
return $errors;
116+
}
117+
118+
}

Diff for: src/Rules/PHPUnit/CoversHelper.php

+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\PHPUnit;
4+
5+
use PhpParser\Node;
6+
use PHPStan\PhpDoc\ResolvedPhpDocBlock;
7+
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
8+
use PHPStan\Reflection\ReflectionProvider;
9+
use PHPStan\Rules\RuleError;
10+
use PHPStan\Rules\RuleErrorBuilder;
11+
use function array_merge;
12+
use function explode;
13+
use function sprintf;
14+
use function strpos;
15+
16+
class CoversHelper
17+
{
18+
19+
/**
20+
* Reflection provider.
21+
*
22+
* @var ReflectionProvider
23+
*/
24+
private $reflectionProvider;
25+
26+
public function __construct(ReflectionProvider $reflectionProvider)
27+
{
28+
$this->reflectionProvider = $reflectionProvider;
29+
}
30+
31+
/**
32+
* Gathers @covers and @coversDefaultClass annotations from phpdocs.
33+
*
34+
* @return array{PhpDocTagNode[], PhpDocTagNode[]}
35+
*/
36+
public function getCoverAnnotations(?ResolvedPhpDocBlock $phpDoc): array
37+
{
38+
if ($phpDoc === null) {
39+
return [[], []];
40+
}
41+
42+
$phpDocNodes = $phpDoc->getPhpDocNodes();
43+
44+
$covers = [];
45+
$coversDefaultClasses = [];
46+
47+
foreach ($phpDocNodes as $docNode) {
48+
$covers = array_merge(
49+
$covers,
50+
$docNode->getTagsByName('@covers')
51+
);
52+
53+
$coversDefaultClasses = array_merge(
54+
$coversDefaultClasses,
55+
$docNode->getTagsByName('@coversDefaultClass')
56+
);
57+
}
58+
59+
return [$covers, $coversDefaultClasses];
60+
}
61+
62+
/**
63+
* @return RuleError[] errors
64+
*/
65+
public function processCovers(
66+
Node $node,
67+
PhpDocTagNode $phpDocTag,
68+
?PhpDocTagNode $coversDefaultClass
69+
): array
70+
{
71+
$errors = [];
72+
$covers = (string) $phpDocTag->value;
73+
74+
if (strpos($covers, '::') !== false) {
75+
[$className, $method] = explode('::', $covers);
76+
} else {
77+
$className = $covers;
78+
}
79+
80+
if ($className === '' && $node instanceof Node\Stmt\ClassMethod && $coversDefaultClass !== null) {
81+
$className = (string) $coversDefaultClass->value;
82+
}
83+
84+
if ($this->reflectionProvider->hasClass($className)) {
85+
$class = $this->reflectionProvider->getClass($className);
86+
if (isset($method) && $method !== '' && !$class->hasMethod($method)) {
87+
$errors[] = RuleErrorBuilder::message(sprintf(
88+
'@covers value %s references an invalid method.',
89+
$covers
90+
))->build();
91+
}
92+
} else {
93+
$errors[] = RuleErrorBuilder::message(sprintf(
94+
'@covers value %s references an invalid class.',
95+
$covers
96+
))->build();
97+
}
98+
return $errors;
99+
}
100+
101+
}

Diff for: tests/Rules/PHPUnit/ClassCoversExistsRuleTest.php

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\PHPUnit;
4+
5+
use PHPStan\Rules\Rule;
6+
use PHPStan\Testing\RuleTestCase;
7+
8+
/**
9+
* @extends RuleTestCase<ClassCoversExistsRule>
10+
*/
11+
class ClassCoversExistsRuleTest extends RuleTestCase
12+
{
13+
14+
protected function getRule(): Rule
15+
{
16+
$reflection = $this->createReflectionProvider();
17+
18+
return new ClassCoversExistsRule(
19+
new CoversHelper($reflection),
20+
$reflection
21+
);
22+
}
23+
24+
public function testRule(): void
25+
{
26+
$this->analyse([__DIR__ . '/data/class-coverage.php'], [
27+
[
28+
'@coversDefaultClass references an invalid class \Not\A\Class.',
29+
8,
30+
],
31+
[
32+
'@coversDefaultClass is defined multiple times.',
33+
23,
34+
],
35+
]);
36+
}
37+
38+
/**
39+
* @return string[]
40+
*/
41+
public static function getAdditionalConfigFiles(): array
42+
{
43+
return [
44+
__DIR__ . '/../../../extension.neon',
45+
];
46+
}
47+
48+
}

0 commit comments

Comments
 (0)