Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 5fcfe8f

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

File tree

9 files changed

+549
-0
lines changed

9 files changed

+549
-0
lines changed

‎extension.neon‎

Lines changed: 2 additions & 0 deletions
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:

‎rules.neon‎

Lines changed: 10 additions & 0 deletions
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%
Lines changed: 93 additions & 0 deletions
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+
}
Lines changed: 118 additions & 0 deletions
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+
}

‎src/Rules/PHPUnit/CoversHelper.php‎

Lines changed: 101 additions & 0 deletions
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+
}
Lines changed: 48 additions & 0 deletions
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)

AltStyle によって変換されたページ (->オリジナル) /