Skip to content

Commit 61860a6

Browse files
authored
Implement DataProviderDataRule
1 parent 39950c7 commit 61860a6

12 files changed

+1382
-2
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
],
88
"require": {
99
"php": "^7.4 || ^8.0",
10-
"phpstan/phpstan": "^2.1.18"
10+
"phpstan/phpstan": "^2.1.32"
1111
},
1212
"conflict": {
1313
"phpunit/phpunit": "<7.0"

extension.neon

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,16 @@ services:
4949
class: PHPStan\Rules\PHPUnit\CoversHelper
5050
-
5151
class: PHPStan\Rules\PHPUnit\AnnotationHelper
52+
5253
-
5354
class: PHPStan\Rules\PHPUnit\PHPUnitVersionDetector
5455

56+
-
57+
class: PHPStan\Rules\PHPUnit\TestMethodsHelper
58+
factory: @PHPStan\Rules\PHPUnit\TestMethodsHelperFactory::create()
59+
-
60+
class: PHPStan\Rules\PHPUnit\TestMethodsHelperFactory
61+
5562
-
5663
class: PHPStan\Rules\PHPUnit\DataProviderHelper
5764
factory: @PHPStan\Rules\PHPUnit\DataProviderHelperFactory::create()

rules.neon

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ conditionalTags:
1313
PHPStan\Rules\PHPUnit\AssertEqualsIsDiscouragedRule:
1414
phpstan.rules.rule: [%strictRulesInstalled%, %featureToggles.bleedingEdge%]
1515

16+
PHPStan\Rules\PHPUnit\DataProviderDataRule:
17+
phpstan.rules.rule: %featureToggles.bleedingEdge%
18+
1619
services:
1720
-
1821
class: PHPStan\Rules\PHPUnit\DataProviderDeclarationRule
@@ -24,3 +27,6 @@ services:
2427

2528
-
2629
class: PHPStan\Rules\PHPUnit\AssertEqualsIsDiscouragedRule
30+
31+
-
32+
class: PHPStan\Rules\PHPUnit\DataProviderDataRule
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
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\Expr\TypeExpr;
8+
use PHPStan\Rules\Rule;
9+
use PHPStan\Type\ObjectType;
10+
use PHPStan\Type\Type;
11+
use PHPUnit\Framework\TestCase;
12+
use function array_slice;
13+
use function count;
14+
use function max;
15+
use const PHP_INT_MAX;
16+
17+
/**
18+
* @implements Rule<Node>
19+
*/
20+
class DataProviderDataRule implements Rule
21+
{
22+
23+
private TestMethodsHelper $testMethodsHelper;
24+
25+
private DataProviderHelper $dataProviderHelper;
26+
27+
public function __construct(
28+
TestMethodsHelper $testMethodsHelper,
29+
DataProviderHelper $dataProviderHelper
30+
)
31+
{
32+
$this->testMethodsHelper = $testMethodsHelper;
33+
$this->dataProviderHelper = $dataProviderHelper;
34+
}
35+
36+
public function getNodeType(): string
37+
{
38+
return Node::class;
39+
}
40+
41+
public function processNode(Node $node, Scope $scope): array
42+
{
43+
if (
44+
!$node instanceof Node\Stmt\Return_
45+
&& !$node instanceof Node\Expr\Yield_
46+
&& !$node instanceof Node\Expr\YieldFrom
47+
) {
48+
return [];
49+
}
50+
51+
if ($scope->getFunction() === null) {
52+
return [];
53+
}
54+
if ($scope->isInAnonymousFunction()) {
55+
return [];
56+
}
57+
58+
$arraysTypes = $this->buildArrayTypesFromNode($node, $scope);
59+
if ($arraysTypes === []) {
60+
return [];
61+
}
62+
63+
$method = $scope->getFunction();
64+
$classReflection = $scope->getClassReflection();
65+
if (
66+
$classReflection === null
67+
|| !$classReflection->is(TestCase::class)
68+
) {
69+
return [];
70+
}
71+
72+
$testsWithProvider = [];
73+
$testMethods = $this->testMethodsHelper->getTestMethods($classReflection, $scope);
74+
foreach ($testMethods as $testMethod) {
75+
foreach ($this->dataProviderHelper->getDataProviderMethods($scope, $testMethod, $classReflection) as [, $providerMethodName]) {
76+
if ($providerMethodName === $method->getName()) {
77+
$testsWithProvider[] = $testMethod;
78+
continue 2;
79+
}
80+
}
81+
}
82+
83+
if (count($testsWithProvider) === 0) {
84+
return [];
85+
}
86+
87+
$maxNumberOfParameters = $testsWithProvider[0]->getNumberOfParameters();
88+
if (count($testsWithProvider) > 1) {
89+
foreach ($testsWithProvider as $testMethod) {
90+
if ($testMethod->isVariadic()) {
91+
$maxNumberOfParameters = PHP_INT_MAX;
92+
break;
93+
}
94+
95+
$maxNumberOfParameters = max($maxNumberOfParameters, $testMethod->getNumberOfParameters());
96+
}
97+
}
98+
99+
foreach ($testsWithProvider as $testMethod) {
100+
$numberOfParameters = $testMethod->getNumberOfParameters();
101+
102+
foreach ($arraysTypes as [$startLine, $arraysType]) {
103+
$args = $this->arrayItemsToArgs($arraysType, $maxNumberOfParameters);
104+
if ($args === null) {
105+
continue;
106+
}
107+
108+
if (
109+
!$testMethod->isVariadic()
110+
&& $numberOfParameters !== $maxNumberOfParameters
111+
) {
112+
$args = array_slice($args, 0, $numberOfParameters);
113+
}
114+
115+
$scope->invokeNodeCallback(new Node\Expr\MethodCall(
116+
new TypeExpr(new ObjectType($classReflection->getName())),
117+
$testMethod->getName(),
118+
$args,
119+
['startLine' => $startLine],
120+
));
121+
}
122+
}
123+
124+
return [];
125+
}
126+
127+
/**
128+
* @return array<Node\Arg>
129+
*/
130+
private function arrayItemsToArgs(Type $array, int $numberOfParameters): ?array
131+
{
132+
$args = [];
133+
134+
$constArrays = $array->getConstantArrays();
135+
if ($constArrays !== [] && count($constArrays) === 1) {
136+
$keyTypes = $constArrays[0]->getKeyTypes();
137+
$valueTypes = $constArrays[0]->getValueTypes();
138+
} elseif ($array->isArray()->yes()) {
139+
$keyTypes = [];
140+
$valueTypes = [];
141+
for ($i = 0; $i < $numberOfParameters; ++$i) {
142+
$keyTypes[$i] = $array->getIterableKeyType();
143+
$valueTypes[$i] = $array->getIterableValueType();
144+
}
145+
} else {
146+
return null;
147+
}
148+
149+
foreach ($valueTypes as $i => $valueType) {
150+
$key = $keyTypes[$i]->getConstantStrings();
151+
if (count($key) > 1) {
152+
return null;
153+
}
154+
155+
if (count($key) === 0) {
156+
$arg = new Node\Arg(new TypeExpr($valueType));
157+
$args[] = $arg;
158+
continue;
159+
160+
}
161+
162+
$arg = new Node\Arg(
163+
new TypeExpr($valueType),
164+
false,
165+
false,
166+
[],
167+
new Node\Identifier($key[0]->getValue()),
168+
);
169+
$args[] = $arg;
170+
}
171+
172+
return $args;
173+
}
174+
175+
/**
176+
* @param Node\Stmt\Return_|Node\Expr\Yield_|Node\Expr\YieldFrom $node
177+
*
178+
* @return list<list{int, Type}>
179+
*/
180+
private function buildArrayTypesFromNode(Node $node, Scope $scope): array
181+
{
182+
$arraysTypes = [];
183+
184+
// special case for providers only containing static data, so we get more precise error lines
185+
if (
186+
($node instanceof Node\Stmt\Return_ && $node->expr instanceof Node\Expr\Array_)
187+
|| ($node instanceof Node\Expr\YieldFrom && $node->expr instanceof Node\Expr\Array_)
188+
) {
189+
foreach ($node->expr->items as $item) {
190+
if (!$item->value instanceof Node\Expr\Array_) {
191+
$arraysTypes = [];
192+
break;
193+
}
194+
195+
$constArrays = $scope->getType($item->value)->getConstantArrays();
196+
if ($constArrays === []) {
197+
$arraysTypes = [];
198+
break;
199+
}
200+
201+
foreach ($constArrays as $constArray) {
202+
$arraysTypes[] = [$item->value->getStartLine(), $constArray];
203+
}
204+
}
205+
206+
if ($arraysTypes !== []) {
207+
return $arraysTypes;
208+
}
209+
}
210+
211+
// general case with less precise error message lines
212+
if ($node instanceof Node\Stmt\Return_ || $node instanceof Node\Expr\YieldFrom) {
213+
if ($node->expr === null) {
214+
return [];
215+
}
216+
217+
$exprType = $scope->getType($node->expr);
218+
$exprConstArrays = $exprType->getConstantArrays();
219+
foreach ($exprConstArrays as $constArray) {
220+
foreach ($constArray->getValueTypes() as $valueType) {
221+
foreach ($valueType->getConstantArrays() as $constValueArray) {
222+
$arraysTypes[] = [$node->getStartLine(), $constValueArray];
223+
}
224+
}
225+
}
226+
227+
if ($arraysTypes === []) {
228+
foreach ($exprType->getIterableValueType()->getArrays() as $arrayType) {
229+
$arraysTypes[] = [$node->getStartLine(), $arrayType];
230+
}
231+
}
232+
} elseif ($node instanceof Node\Expr\Yield_) {
233+
if ($node->value === null) {
234+
return [];
235+
}
236+
237+
$exprType = $scope->getType($node->value);
238+
foreach ($exprType->getConstantArrays() as $constValueArray) {
239+
$arraysTypes[] = [$node->getStartLine(), $constValueArray];
240+
}
241+
}
242+
243+
return $arraysTypes;
244+
}
245+
246+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\PHPUnit;
4+
5+
use PHPStan\Analyser\Scope;
6+
use PHPStan\PhpDoc\ResolvedPhpDocBlock;
7+
use PHPStan\Reflection\ClassReflection;
8+
use PHPStan\Type\FileTypeMapper;
9+
use ReflectionMethod;
10+
use function str_starts_with;
11+
use function strtolower;
12+
13+
final class TestMethodsHelper
14+
{
15+
16+
private FileTypeMapper $fileTypeMapper;
17+
18+
private bool $phpunit10OrNewer;
19+
20+
public function __construct(
21+
FileTypeMapper $fileTypeMapper,
22+
bool $phpunit10OrNewer
23+
)
24+
{
25+
$this->fileTypeMapper = $fileTypeMapper;
26+
$this->phpunit10OrNewer = $phpunit10OrNewer;
27+
}
28+
29+
/**
30+
* @return array<ReflectionMethod>
31+
*/
32+
public function getTestMethods(ClassReflection $classReflection, Scope $scope): array
33+
{
34+
$testMethods = [];
35+
foreach ($classReflection->getNativeReflection()->getMethods() as $reflectionMethod) {
36+
if (!$reflectionMethod->isPublic()) {
37+
continue;
38+
}
39+
40+
if (str_starts_with(strtolower($reflectionMethod->getName()), 'test')) {
41+
$testMethods[] = $reflectionMethod;
42+
continue;
43+
}
44+
45+
$docComment = $reflectionMethod->getDocComment();
46+
if ($docComment !== false) {
47+
$methodPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc(
48+
$scope->getFile(),
49+
$classReflection->getName(),
50+
$scope->isInTrait() ? $scope->getTraitReflection()->getName() : null,
51+
$reflectionMethod->getName(),
52+
$docComment,
53+
);
54+
55+
if ($this->hasTestAnnotation($methodPhpDoc)) {
56+
$testMethods[] = $reflectionMethod;
57+
continue;
58+
}
59+
}
60+
61+
if (!$this->phpunit10OrNewer) {
62+
continue;
63+
}
64+
65+
$testAttributes = $reflectionMethod->getAttributes('PHPUnit\Framework\Attributes\Test'); // @phpstan-ignore argument.type
66+
if ($testAttributes === []) {
67+
continue;
68+
}
69+
70+
$testMethods[] = $reflectionMethod;
71+
}
72+
73+
return $testMethods;
74+
}
75+
76+
private function hasTestAnnotation(?ResolvedPhpDocBlock $phpDoc): bool
77+
{
78+
if ($phpDoc === null) {
79+
return false;
80+
}
81+
82+
$phpDocNodes = $phpDoc->getPhpDocNodes();
83+
84+
foreach ($phpDocNodes as $docNode) {
85+
$tags = $docNode->getTagsByName('@test');
86+
if ($tags !== []) {
87+
return true;
88+
}
89+
}
90+
91+
return false;
92+
}
93+
94+
}

0 commit comments

Comments
 (0)