Skip to content

Commit 5ee5584

Browse files
author
Stephan Wentz
committed
feat: Add support for phpunit covers attributes
1 parent 25cc800 commit 5ee5584

18 files changed

+537
-323
lines changed

composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
],
1818
"minimum-stability": "stable",
1919
"require": {
20-
"php": "^7.4|^8.0",
20+
"php": "^8.1",
2121
"nette/utils": "^3.0",
22-
"nikic/php-parser": "^4.3",
22+
"nikic/php-parser": "^4.3|^5.0",
2323
"phpstan/phpstan": "^1.0"
2424
},
2525
"require-dev": {

rules.neon

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@ parametersSchema:
99

1010
services:
1111
-
12-
class: BrainbitsPhpStan\CoversAnnotationRule
12+
class: BrainbitsPhpStan\CoversClassPresentRule
1313
arguments:
1414
unitTestNamespaceContainsString: %brainbits.unitTestNamespaceContainsString%
1515
tags:
1616
- phpstan.rules.rule
1717

1818

1919
-
20-
class: BrainbitsPhpStan\CoversExistsRule
20+
class: BrainbitsPhpStan\CoversClassExistsRule
2121
tags:
2222
- phpstan.rules.rule
2323

src/CoversClassExistsRule.php

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace BrainbitsPhpStan;
6+
7+
use PhpParser\Node;
8+
use PhpParser\Node\Expr\ClassConstFetch;
9+
use PhpParser\Node\Name;
10+
use PhpParser\Node\Scalar\String_;
11+
use PhpParser\Node\Stmt\Class_;
12+
use PHPStan\Analyser\Scope;
13+
use PHPStan\Broker\Broker;
14+
use PHPStan\Rules\Rule;
15+
use PHPStan\Rules\RuleError;
16+
use PHPStan\Rules\RuleErrorBuilder;
17+
18+
use function array_merge;
19+
use function assert;
20+
use function preg_match;
21+
use function preg_split;
22+
use function sha1;
23+
use function sprintf;
24+
25+
// phpcs:disable SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingNativeTypeHint
26+
27+
/**
28+
* @implements Rule<Class_>
29+
*/
30+
final class CoversClassExistsRule implements Rule
31+
{
32+
/** @var Broker */
33+
private $broker;
34+
/** @var bool[] */
35+
private $alreadyParsedDocComments = [];
36+
37+
public function __construct(Broker $broker)
38+
{
39+
$this->broker = $broker;
40+
}
41+
42+
public function getNodeType(): string
43+
{
44+
return Class_::class;
45+
}
46+
47+
/**
48+
* @param Class_ $node
49+
*
50+
* @return RuleError[] errors
51+
*/
52+
public function processNode(Node $node, Scope $scope): array
53+
{
54+
$messagesAttribute = $this->processNodeAttribute($node, $scope);
55+
$messagesAnnotation = $this->processNodeAnnotation($node, $scope);
56+
57+
return array_merge($messagesAttribute, $messagesAnnotation);
58+
}
59+
60+
/**
61+
* @return RuleError[] errors
62+
*/
63+
public function processNodeAttribute(Class_ $node, Scope $scope): array
64+
{
65+
$messages = [];
66+
if (!$node->attrGroups) {
67+
return [];
68+
}
69+
70+
foreach ($node->attrGroups as $attrGroup) {
71+
foreach ($attrGroup->attrs as $name => $attr) {
72+
if ((string) $attr->name !== 'PHPUnit\Framework\Attributes\CoversClass') {
73+
continue;
74+
}
75+
76+
if (!$attr->args) {
77+
continue;
78+
}
79+
80+
$arg = $attr->args[0];
81+
82+
if ($arg->value instanceof ClassConstFetch) {
83+
assert($arg->value->class instanceof Name);
84+
85+
$className = (string) $arg->value->class;
86+
if ($this->broker->hasClass($className)) {
87+
continue;
88+
}
89+
90+
$messages[] = RuleErrorBuilder::message(sprintf('Class %s does not exist.', $className))->line($attr->getStartLine())->build();
91+
92+
continue;
93+
}
94+
95+
if ($arg->value instanceof String_) {
96+
$className = (string) $arg->value->value;
97+
if ($this->broker->hasClass($className)) {
98+
continue;
99+
}
100+
101+
$messages[] = RuleErrorBuilder::message(sprintf('Class %s does not exist.', $className))->line($attr->getStartLine())->build();
102+
103+
continue;
104+
}
105+
}
106+
}
107+
108+
return $messages;
109+
}
110+
111+
/**
112+
* @return RuleError[] errors
113+
*/
114+
public function processNodeAnnotation(Class_ $node, Scope $scope): array
115+
{
116+
$messages = [];
117+
$docComment = $node->getDocComment();
118+
if (empty($docComment)) {
119+
return $messages;
120+
}
121+
122+
$hash = sha1(sprintf(
123+
'%s:%s:%s:%s',
124+
$scope->getFile(),
125+
$docComment->getStartLine(),
126+
$docComment->getStartFilePos(),
127+
$docComment->getText()
128+
));
129+
if (isset($this->alreadyParsedDocComments[$hash])) {
130+
return $messages;
131+
}
132+
133+
$this->alreadyParsedDocComments[$hash] = true;
134+
135+
$lines = preg_split('/\R/u', $docComment->getText());
136+
if ($lines === false) {
137+
return $messages;
138+
}
139+
140+
foreach ($lines as $lineNumber => $lineContent) {
141+
$matches = [];
142+
143+
if (! preg_match('/^(?:\s*\*\s*@(?:covers|coversDefaultClass)\h+)\\\\?(?<className>\w[^:\s]*)(?:::\S+)?\s*$/u', $lineContent, $matches)) {
144+
if (! preg_match('/^(?:\s*\/\*\*\s*@(?:covers|coversDefaultClass)\h+)\\\\?(?<className>\w[^:\s]*)(?:::\S+)?\s*\*\/\s*$/u', $lineContent, $matches)) {
145+
continue;
146+
}
147+
}
148+
149+
if ($this->broker->hasClass($matches['className'])) {
150+
continue;
151+
}
152+
153+
$messages[] = RuleErrorBuilder::message(sprintf('Class %s does not exist.', $matches['className']))->line($docComment->getStartLine() + $lineNumber)->build();
154+
}
155+
156+
return $messages;
157+
}
158+
}

src/CoversAnnotationRule.php renamed to src/CoversClassPresentRule.php

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@
2222
// phpcs:disable SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingNativeTypeHint
2323

2424
/**
25-
* @implements Rule<Node>
25+
* @implements Rule<Class_>
2626
*/
27-
final class CoversAnnotationRule implements Rule
27+
final class CoversClassPresentRule implements Rule
2828
{
2929
private const TEST_CLASS_ENDING_STRING = 'Test';
3030

@@ -41,42 +41,73 @@ public function __construct(string $unitTestNamespaceContainsString)
4141

4242
public function getNodeType(): string
4343
{
44-
return Node::class;
44+
return Class_::class;
4545
}
4646

4747
/**
48+
* @param Class_ $node
49+
*
4850
* @return array<RuleError>
4951
*/
5052
public function processNode(Node $node, Scope $scope): array
5153
{
5254
$messages = [];
53-
$lines = $this->getAnnotationLines($node, $scope);
5455

55-
$isUnitTest = $node instanceof Class_
56-
&& (bool) $node->extends
56+
$isUnitTest = (bool) $node->extends
5757
&& $this->isUnitTest((string) $scope->getNamespace(), (string) $node->name, $this->unitTestNamespaceContainsString);
5858

59-
$hasCovers = false;
59+
$hasCovers = $this->processNodeAnnotation($node, $scope) || $this->processNodeAttribute($node, $scope);
60+
61+
if ($isUnitTest && !$hasCovers) {
62+
$messages[] = RuleErrorBuilder::message('No @covers or #[CoversClass] found in test.')
63+
->build();
64+
}
65+
66+
return $messages;
67+
}
68+
69+
public function processNodeAnnotation(Class_ $node, Scope $scope): bool
70+
{
71+
$lines = $this->getAnnotationLines($node, $scope);
72+
6073
foreach ($lines as $lineContent) {
6174
$lineHasCovers = (bool) preg_match('/^(?:\s*\*\s*@(?:covers|coversDefaultClass)\h+)\\\\?(?<className>\w[^:\s]*)(?:::\S+)?\s*$/u', $lineContent, $matches);
6275
if ($lineHasCovers) {
63-
$hasCovers = true;
64-
break;
76+
return true;
6577
}
6678

6779
$lineHasCovers = (bool) preg_match('/^(?:\s*\/\*\*\s*@(?:covers|coversDefaultClass)\h+)\\\\?(?<className>\w[^:\s]*)(?:::\S+)?\s*\*\/\s*$/u', $lineContent, $matches);
6880
if ($lineHasCovers) {
69-
$hasCovers = true;
70-
break;
81+
return true;
7182
}
7283
}
7384

74-
if ($isUnitTest && !$hasCovers) {
75-
$messages[] = RuleErrorBuilder::message('No @covers or @coversDefaultClass found in test.')
76-
->build();
85+
return false;
86+
}
87+
88+
public function processNodeAttribute(Class_ $node, Scope $scope): bool
89+
{
90+
if (!$node->attrGroups) {
91+
return false;
7792
}
7893

79-
return $messages;
94+
foreach ($node->attrGroups as $attrGroup) {
95+
foreach ($attrGroup->attrs as $name => $attr) {
96+
if ((string) $attr->name === 'PHPUnit\Framework\Attributes\CoversClass') {
97+
return true;
98+
}
99+
100+
if ((string) $attr->name === 'PHPUnit\Framework\Attributes\CoversFunction') {
101+
return true;
102+
}
103+
104+
if ((string) $attr->name === 'PHPUnit\Framework\Attributes\CoversNothing') {
105+
return true;
106+
}
107+
}
108+
}
109+
110+
return false;
80111
}
81112

82113
/**
@@ -93,8 +124,8 @@ private function getAnnotationLines(Node $node, Scope $scope): array
93124
sprintf(
94125
'%s:%s:%s:%s',
95126
$scope->getFile(),
96-
$docComment->getLine(),
97-
$docComment->getFilePos(),
127+
$docComment->getStartLine(),
128+
$docComment->getStartFilePos(),
98129
$docComment->getText()
99130
)
100131
);

src/CoversExistsRule.php

Lines changed: 0 additions & 88 deletions
This file was deleted.

0 commit comments

Comments
 (0)