Skip to content

Commit f27c560

Browse files
committed
Implement AttributeRequiresPhpVersionRule
1 parent 83b717d commit f27c560

File tree

5 files changed

+182
-3
lines changed

5 files changed

+182
-3
lines changed
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->notSupportsPhpversionAttributeWithoutOperator()->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: 31 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,31 @@ public function supportsNamedArgumentsInDataProvider(): TrinaryLogic
4649
return TrinaryLogic::createFromBoolean($this->majorVersion >= 11);
4750
}
4851

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

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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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+
if (PHP_VERSION_ID < 70400) {
11+
class RequiresPhp8 extends TestCase
12+
{
13+
#[RequiresPhp('>= 8.0')]
14+
public function testWithMinPHP8(): void {
15+
16+
}
17+
}
18+
}
19+
20+
21+
class DeprecatedVersionFormat extends TestCase
22+
{
23+
#[RequiresPhp('8.0')]
24+
public function testDeprecatedFormat(): void {
25+
26+
}
27+
}
28+
29+
class AllGoodTest extends TestCase
30+
{
31+
#[RequiresPhp('>=8.0')]
32+
public function testHappyPath(): void {
33+
34+
}
35+
}

0 commit comments

Comments
 (0)