Skip to content

Commit cda423c

Browse files
authored
Implement AttributeRequiresPhpVersionRule
1 parent 83b717d commit cda423c

9 files changed

+278
-5
lines changed

rules.neon

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ services:
2525
tags:
2626
- phpstan.rules.rule
2727

28+
-
29+
class: PHPStan\Rules\PHPUnit\AttributeRequiresPhpVersionRule
30+
arguments:
31+
deprecationRulesInstalled: %deprecationRulesInstalled%
32+
tags:
33+
- phpstan.rules.rule
34+
2835
-
2936
class: PHPStan\Rules\PHPUnit\AssertEqualsIsDiscouragedRule
3037

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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\InClassMethodNode;
8+
use PHPStan\Rules\Rule;
9+
use PHPStan\Rules\RuleErrorBuilder;
10+
use PHPUnit\Framework\TestCase;
11+
use function count;
12+
use function is_numeric;
13+
use function method_exists;
14+
use function sprintf;
15+
16+
/**
17+
* @implements Rule<InClassMethodNode>
18+
*/
19+
class AttributeRequiresPhpVersionRule implements Rule
20+
{
21+
22+
private PHPUnitVersion $PHPUnitVersion;
23+
24+
private TestMethodsHelper $testMethodsHelper;
25+
26+
/**
27+
* When phpstan-deprecation-rules is installed, it reports deprecated usages.
28+
*/
29+
private bool $deprecationRulesInstalled;
30+
31+
public function __construct(
32+
PHPUnitVersion $PHPUnitVersion,
33+
TestMethodsHelper $testMethodsHelper,
34+
bool $deprecationRulesInstalled
35+
)
36+
{
37+
$this->PHPUnitVersion = $PHPUnitVersion;
38+
$this->testMethodsHelper = $testMethodsHelper;
39+
$this->deprecationRulesInstalled = $deprecationRulesInstalled;
40+
}
41+
42+
public function getNodeType(): string
43+
{
44+
return InClassMethodNode::class;
45+
}
46+
47+
public function processNode(Node $node, Scope $scope): array
48+
{
49+
$classReflection = $scope->getClassReflection();
50+
if ($classReflection === null || $classReflection->is(TestCase::class) === false) {
51+
return [];
52+
}
53+
54+
$reflectionMethod = $this->testMethodsHelper->getTestMethodReflection($classReflection, $node->getMethodReflection(), $scope);
55+
if ($reflectionMethod === null) {
56+
return [];
57+
}
58+
59+
/** @phpstan-ignore function.alreadyNarrowedType */
60+
if (!method_exists($reflectionMethod, 'getAttributes')) {
61+
return [];
62+
}
63+
64+
$errors = [];
65+
foreach ($reflectionMethod->getAttributes('PHPUnit\Framework\Attributes\RequiresPhp') as $attr) {
66+
$args = $attr->getArguments();
67+
if (count($args) !== 1) {
68+
continue;
69+
}
70+
71+
if (
72+
!is_numeric($args[0])
73+
) {
74+
continue;
75+
}
76+
77+
if ($this->PHPUnitVersion->requiresPhpversionAttributeWithOperator()->yes()) {
78+
$errors[] = RuleErrorBuilder::message(
79+
sprintf('Version requirement is missing operator.'),
80+
)
81+
->identifier('phpunit.attributeRequiresPhpVersion')
82+
->build();
83+
} elseif (
84+
$this->deprecationRulesInstalled
85+
&& $this->PHPUnitVersion->deprecatesPhpversionAttributeWithoutOperator()->yes()
86+
) {
87+
$errors[] = RuleErrorBuilder::message(
88+
sprintf('Version requirement without operator is deprecated.'),
89+
)
90+
->identifier('phpunit.attributeRequiresPhpVersion')
91+
->build();
92+
}
93+
94+
}
95+
96+
return $errors;
97+
}
98+
99+
}

src/Rules/PHPUnit/PHPUnitVersion.php

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@ class PHPUnitVersion
99

1010
private ?int $majorVersion;
1111

12-
public function __construct(?int $majorVersion)
12+
private ?int $minorVersion;
13+
14+
public function __construct(?int $majorVersion, ?int $minorVersion)
1315
{
1416
$this->majorVersion = $majorVersion;
17+
$this->minorVersion = $minorVersion;
1518
}
1619

1720
public function supportsDataProviderAttribute(): TrinaryLogic
@@ -46,4 +49,34 @@ public function supportsNamedArgumentsInDataProvider(): TrinaryLogic
4649
return TrinaryLogic::createFromBoolean($this->majorVersion >= 11);
4750
}
4851

52+
public function requiresPhpversionAttributeWithOperator(): TrinaryLogic
53+
{
54+
if ($this->majorVersion === null) {
55+
return TrinaryLogic::createMaybe();
56+
}
57+
return TrinaryLogic::createFromBoolean($this->majorVersion >= 13);
58+
}
59+
60+
public function deprecatesPhpversionAttributeWithoutOperator(): TrinaryLogic
61+
{
62+
return $this->minVersion(12, 4);
63+
}
64+
65+
private function minVersion(int $major, int $minor): TrinaryLogic
66+
{
67+
if ($this->majorVersion === null || $this->minorVersion === null) {
68+
return TrinaryLogic::createMaybe();
69+
}
70+
71+
if ($this->majorVersion > $major) {
72+
return TrinaryLogic::createYes();
73+
}
74+
75+
if ($this->majorVersion === $major && $this->minorVersion >= $minor) {
76+
return TrinaryLogic::createYes();
77+
}
78+
79+
return TrinaryLogic::createNo();
80+
}
81+
4982
}

src/Rules/PHPUnit/PHPUnitVersionDetector.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public function __construct(ReflectionProvider $reflectionProvider)
2323
public function createPHPUnitVersion(): PHPUnitVersion
2424
{
2525
$majorVersion = null;
26+
$minorVersion = null;
2627
if ($this->reflectionProvider->hasClass(TestCase::class)) {
2728
$testCase = $this->reflectionProvider->getClass(TestCase::class);
2829
$file = $testCase->getFileName();
@@ -35,14 +36,16 @@ public function createPHPUnitVersion(): PHPUnitVersion
3536
$json = json_decode($composerJson, true);
3637
$version = $json['extra']['branch-alias']['dev-main'] ?? null;
3738
if ($version !== null) {
38-
$majorVersion = (int) explode('.', $version)[0];
39+
$versionParts = explode('.', $version);
40+
$majorVersion = (int) $versionParts[0];
41+
$minorVersion = (int) $versionParts[1];
3942
}
4043
}
4144
}
4245
}
4346
}
4447

45-
return new PHPUnitVersion($majorVersion);
48+
return new PHPUnitVersion($majorVersion, $minorVersion);
4649
}
4750

4851
}

src/Rules/PHPUnit/TestMethodsHelper.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use PHPStan\Analyser\Scope;
66
use PHPStan\PhpDoc\ResolvedPhpDocBlock;
77
use PHPStan\Reflection\ClassReflection;
8+
use PHPStan\Reflection\MethodReflection;
89
use PHPStan\Type\FileTypeMapper;
910
use PHPUnit\Framework\TestCase;
1011
use ReflectionMethod;
@@ -27,6 +28,17 @@ public function __construct(
2728
$this->PHPUnitVersion = $PHPUnitVersion;
2829
}
2930

31+
public function getTestMethodReflection(ClassReflection $classReflection, MethodReflection $methodReflection, Scope $scope): ?ReflectionMethod
32+
{
33+
foreach ($this->getTestMethods($classReflection, $scope) as $testMethod) {
34+
if ($testMethod->getName() === $methodReflection->getName()) {
35+
return $testMethod;
36+
}
37+
}
38+
39+
return null;
40+
}
41+
3042
/**
3143
* @return array<ReflectionMethod>
3244
*/
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\PHPUnit;
4+
5+
use PHPStan\Rules\Rule;
6+
use PHPStan\Testing\RuleTestCase;
7+
use PHPStan\Type\FileTypeMapper;
8+
9+
/**
10+
* @extends RuleTestCase<AttributeRequiresPhpVersionRule>
11+
*/
12+
final class AttributeRequiresPhpVersionRuleTest extends RuleTestCase
13+
{
14+
15+
private ?int $phpunitMajorVersion;
16+
17+
private ?int $phpunitMinorVersion;
18+
19+
private bool $deprecationRulesInstalled = true;
20+
21+
public function testRuleOnPHPUnitUnknown(): void
22+
{
23+
$this->phpunitMajorVersion = null;
24+
$this->phpunitMinorVersion = null;
25+
26+
$this->analyse([__DIR__ . '/data/requires-php-version.php'], []);
27+
}
28+
29+
public function testRuleOnPHPUnit115(): void
30+
{
31+
$this->phpunitMajorVersion = 11;
32+
$this->phpunitMinorVersion = 5;
33+
34+
$this->analyse([__DIR__ . '/data/requires-php-version.php'], []);
35+
}
36+
37+
public function testRuleOnPHPUnit123(): void
38+
{
39+
$this->phpunitMajorVersion = 12;
40+
$this->phpunitMinorVersion = 3;
41+
42+
$this->analyse([__DIR__ . '/data/requires-php-version.php'], []);
43+
}
44+
45+
public function testRuleOnPHPUnit124DeprecationsOn(): void
46+
{
47+
$this->phpunitMajorVersion = 12;
48+
$this->phpunitMinorVersion = 4;
49+
$this->deprecationRulesInstalled = true;
50+
51+
$this->analyse([__DIR__ . '/data/requires-php-version.php'], [
52+
[
53+
'Version requirement without operator is deprecated.',
54+
12,
55+
],
56+
]);
57+
}
58+
59+
public function testRuleOnPHPUnit124DeprecationsOff(): void
60+
{
61+
$this->phpunitMajorVersion = 12;
62+
$this->phpunitMinorVersion = 4;
63+
$this->deprecationRulesInstalled = false;
64+
65+
$this->analyse([__DIR__ . '/data/requires-php-version.php'], []);
66+
}
67+
68+
public function testRuleOnPHPUnit13(): void
69+
{
70+
$this->phpunitMajorVersion = 13;
71+
$this->phpunitMinorVersion = 0;
72+
73+
$this->analyse([__DIR__ . '/data/requires-php-version.php'], [
74+
[
75+
'Version requirement is missing operator.',
76+
12,
77+
],
78+
]);
79+
}
80+
81+
protected function getRule(): Rule
82+
{
83+
$phpunitVersion = new PHPUnitVersion($this->phpunitMajorVersion, $this->phpunitMinorVersion);
84+
85+
return new AttributeRequiresPhpVersionRule(
86+
$phpunitVersion,
87+
new TestMethodsHelper(
88+
self::getContainer()->getByType(FileTypeMapper::class),
89+
$phpunitVersion,
90+
),
91+
$this->deprecationRulesInstalled,
92+
);
93+
}
94+
95+
}

tests/Rules/PHPUnit/DataProviderDataRuleTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class DataProviderDataRuleTest extends RuleTestCase
2222
protected function getRule(): Rule
2323
{
2424
$reflectionProvider = $this->createReflectionProvider();
25-
$phpunitVersion = new PHPUnitVersion($this->phpunitVersion);
25+
$phpunitVersion = new PHPUnitVersion($this->phpunitVersion, 0);
2626

2727
/** @var list<Rule<Node>> $rules */
2828
$rules = [

tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ protected function getRule(): Rule
2424
$reflection,
2525
self::getContainer()->getByType(FileTypeMapper::class),
2626
self::getContainer()->getService('defaultAnalysisParser'),
27-
new PHPUnitVersion($this->phpunitVersion)
27+
new PHPUnitVersion($this->phpunitVersion, 0)
2828
),
2929
true,
3030
true
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
namespace RequiresPhpVersion;
4+
5+
use PHPUnit\Framework\Attributes\DataProvider;
6+
use PHPUnit\Framework\Attributes\Test;
7+
use PHPUnit\Framework\TestCase;
8+
use PHPUnit\Framework\Attributes\RequiresPhp;
9+
10+
class DeprecatedVersionFormat extends TestCase
11+
{
12+
#[RequiresPhp('8.0')]
13+
public function testDeprecatedFormat(): void {
14+
15+
}
16+
}
17+
18+
class AllGoodTest extends TestCase
19+
{
20+
#[RequiresPhp('>=8.0')]
21+
public function testHappyPath(): void {
22+
23+
}
24+
}

0 commit comments

Comments
 (0)