Skip to content

Commit bf910b5

Browse files
authored
Sanity check #[RequiresPhp] value and range (#269)
1 parent d309858 commit bf910b5

8 files changed

Lines changed: 381 additions & 4 deletions

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"keywords": ["static analysis"],
99
"require": {
1010
"php": "^7.4 || ^8.0",
11-
"phpstan/phpstan": "^2.1.48"
11+
"phar-io/version": "^3.2",
12+
"phpstan/phpstan": "^2.2.3"
1213
},
1314
"conflict": {
1415
"phpunit/phpunit": "<7.0"

src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,39 @@
22

33
namespace PHPStan\Rules\PHPUnit;
44

5+
use PharIo\Version\UnsupportedVersionConstraintException;
6+
use PharIo\Version\Version;
7+
use PharIo\Version\VersionConstraintParser;
58
use PhpParser\Node;
69
use PHPStan\Analyser\Scope;
710
use PHPStan\Node\InClassMethodNode;
11+
use PHPStan\Php\PhpMinorVersionIterator;
12+
use PHPStan\Php\PhpVersion;
813
use PHPStan\Rules\Rule;
914
use PHPStan\Rules\RuleErrorBuilder;
15+
use PHPStan\Type\Constant\ConstantIntegerType;
16+
use PHPStan\Type\IntegerRangeType;
1017
use PHPUnit\Framework\TestCase;
1118
use function count;
1219
use function is_numeric;
20+
use function preg_match;
1321
use function sprintf;
22+
use function version_compare;
1423

1524
/**
1625
* @implements Rule<InClassMethodNode>
1726
*/
1827
class AttributeRequiresPhpVersionRule implements Rule
1928
{
2029

30+
private const VERSION_COMPARISON = "/(?P<operator>!=|<|<=|<>|=|==|>|>=)?\s*(?P<version>[\d\.-]+(dev|(RC|alpha|beta)[\d\.])?)[ \t]*\r?$/m";
31+
2132
private PHPUnitVersion $PHPUnitVersion;
2233

2334
private TestMethodsHelper $testMethodsHelper;
2435

36+
private PhpVersion $fallbackPhpVersion;
37+
2538
/**
2639
* When phpstan-deprecation-rules is installed, it reports deprecated usages.
2740
*/
@@ -30,12 +43,14 @@ class AttributeRequiresPhpVersionRule implements Rule
3043
public function __construct(
3144
PHPUnitVersion $PHPUnitVersion,
3245
TestMethodsHelper $testMethodsHelper,
33-
bool $deprecationRulesInstalled
46+
bool $deprecationRulesInstalled,
47+
PhpVersion $phpVersion
3448
)
3549
{
3650
$this->PHPUnitVersion = $PHPUnitVersion;
3751
$this->testMethodsHelper = $testMethodsHelper;
3852
$this->deprecationRulesInstalled = $deprecationRulesInstalled;
53+
$this->fallbackPhpVersion = $phpVersion;
3954
}
4055

4156
public function getNodeType(): string
@@ -55,16 +70,64 @@ public function processNode(Node $node, Scope $scope): array
5570
return [];
5671
}
5772

73+
$phpstanPharIoVersions = $this->getAnalyzedPhpVersions($scope);
74+
if ($phpstanPharIoVersions === []) {
75+
return [];
76+
}
77+
5878
$errors = [];
79+
$parser = new VersionConstraintParser();
5980
foreach ($reflectionMethod->getAttributesByName('PHPUnit\Framework\Attributes\RequiresPhp') as $attr) {
6081
$args = $attr->getArguments();
6182
if (count($args) !== 1) {
6283
continue;
6384
}
6485

86+
// the following block is mimicing PHPUnit version parsing
87+
// see https://github.com/sebastianbergmann/phpunit/blob/43c2cd7b96ee1e800b35e4df23b419a88b53111d/src/Metadata/Version/Requirement.php
88+
89+
$versionRequirement = $args[0];
6590
if (
66-
!is_numeric($args[0])
91+
!is_numeric($versionRequirement)
6792
) {
93+
try {
94+
// check composer like version constraints, e.g. ^1 or ~2
95+
$testPhpVersionConstraint = $parser->parse($versionRequirement);
96+
97+
foreach ($phpstanPharIoVersions as $pharIoVersion) {
98+
if ($testPhpVersionConstraint->complies($pharIoVersion)) {
99+
// one of the versions within range matched, check next attribute
100+
continue 2;
101+
}
102+
}
103+
} catch (UnsupportedVersionConstraintException $e) {
104+
// test php-src builtin operators as in version_compare()
105+
if (preg_match(self::VERSION_COMPARISON, $versionRequirement, $matches) <= 0) {
106+
$errors[] = RuleErrorBuilder::message(
107+
sprintf($e->getMessage()),
108+
)
109+
->identifier('phpunit.attributeRequiresPhpVersion')
110+
->build();
111+
112+
continue;
113+
}
114+
115+
$operator = $matches['operator'] !== '' ? $matches['operator'] : '>=';
116+
117+
foreach ($phpstanPharIoVersions as $pharIoVersion) {
118+
if (version_compare($pharIoVersion->getVersionString(), $matches['version'], $operator)) {
119+
// one of the versions within range matched, check next attribute
120+
continue 2;
121+
}
122+
}
123+
}
124+
125+
$errors[] = RuleErrorBuilder::message(
126+
sprintf('Version requirement will always evaluate to false.'),
127+
)
128+
->identifier('phpunit.attributeRequiresPhpVersion')
129+
->build();
130+
68131
continue;
69132
}
70133

@@ -84,10 +147,37 @@ public function processNode(Node $node, Scope $scope): array
84147
->identifier('phpunit.attributeRequiresPhpVersion')
85148
->build();
86149
}
87-
88150
}
89151

90152
return $errors;
91153
}
92154

155+
/**
156+
* @return Version[]
157+
*/
158+
private function getAnalyzedPhpVersions(Scope $scope): array
159+
{
160+
$scopePhpVersion = $scope->getPhpVersion()->getType();
161+
if ($scopePhpVersion instanceof ConstantIntegerType) {
162+
$v = new PhpVersion($scopePhpVersion->getValue());
163+
return [new Version($v->getVersionString())];
164+
} elseif ($scopePhpVersion instanceof IntegerRangeType) {
165+
if ($scopePhpVersion->getMin() === null || $scopePhpVersion->getMax() === null) {
166+
return [];
167+
}
168+
169+
$versions = [];
170+
$minorVersionIterator = new PhpMinorVersionIterator(
171+
new PhpVersion($scopePhpVersion->getMin()),
172+
new PhpVersion($scopePhpVersion->getMax()),
173+
);
174+
foreach ($minorVersionIterator as $phpstanVersion) {
175+
$versions[] = new Version($phpstanVersion->getVersionString());
176+
}
177+
return $versions;
178+
}
179+
180+
return [new Version($this->fallbackPhpVersion->getVersionString())];
181+
}
182+
93183
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
parameters:
2+
phpVersion:
3+
min: 80200
4+
max: 80400
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\PHPUnit;
4+
5+
use PHPStan\Php\PhpVersion;
6+
use PHPStan\Rules\Rule;
7+
use PHPStan\Testing\RuleTestCase;
8+
use PHPStan\Type\FileTypeMapper;
9+
10+
/**
11+
* @extends RuleTestCase<AttributeRequiresPhpVersionRule>
12+
*/
13+
final class AttributeRequiresPhpVersionRangeRuleTest extends RuleTestCase
14+
{
15+
16+
private int $phpVersion = 80500;
17+
18+
public function testPhpVersionMismatch(): void
19+
{
20+
$this->analyse([__DIR__ . '/data/requires-php-version-mismatch.php'], [
21+
[
22+
'Version requirement will always evaluate to false.',
23+
20,
24+
],
25+
[
26+
'Version requirement will always evaluate to false.',
27+
28,
28+
],
29+
[
30+
'Version requirement will always evaluate to false.',
31+
36,
32+
],
33+
[
34+
'Version requirement will always evaluate to false.',
35+
44,
36+
],
37+
[
38+
'Version requirement will always evaluate to false.',
39+
76,
40+
],
41+
]);
42+
}
43+
44+
protected function getRule(): Rule
45+
{
46+
$phpunitVersion = new PHPUnitVersion(null, null);
47+
48+
return new AttributeRequiresPhpVersionRule(
49+
$phpunitVersion,
50+
new TestMethodsHelper(
51+
self::getContainer()->getByType(FileTypeMapper::class),
52+
$phpunitVersion,
53+
),
54+
false,
55+
new PhpVersion($this->phpVersion),
56+
);
57+
}
58+
59+
public static function getAdditionalConfigFiles(): array
60+
{
61+
return [
62+
__DIR__ . '/AttributeRequiresPhpVersionRangeRule.neon',
63+
];
64+
}
65+
66+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
parameters:
2+
phpVersion: 80500

tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace PHPStan\Rules\PHPUnit;
44

5+
use PHPStan\Php\PhpVersion;
56
use PHPStan\Rules\Rule;
67
use PHPStan\Testing\RuleTestCase;
78
use PHPStan\Type\FileTypeMapper;
@@ -12,6 +13,8 @@
1213
final class AttributeRequiresPhpVersionRuleTest extends RuleTestCase
1314
{
1415

16+
private int $phpVersion = 80500;
17+
1518
private ?int $phpunitMajorVersion;
1619

1720
private ?int $phpunitMinorVersion;
@@ -78,6 +81,64 @@ public function testRuleOnPHPUnit13(): void
7881
]);
7982
}
8083

84+
public function testPhpVersionMismatch(): void
85+
{
86+
$this->phpunitMajorVersion = 12;
87+
$this->phpunitMinorVersion = 4;
88+
$this->deprecationRulesInstalled = false;
89+
90+
$this->analyse([__DIR__ . '/data/requires-php-version-mismatch.php'], [
91+
[
92+
// errors because https://github.com/sebastianbergmann/phpunit/issues/6451
93+
// the test assumes PHP_VERSION_ID 80500 and the constraint only has 2 digits
94+
'Version requirement will always evaluate to false.',
95+
12,
96+
],
97+
[
98+
'Version requirement will always evaluate to false.',
99+
20,
100+
],
101+
[
102+
'Version requirement will always evaluate to false.',
103+
28,
104+
],
105+
[
106+
'Version requirement will always evaluate to false.',
107+
36,
108+
],
109+
[
110+
'Version requirement will always evaluate to false.',
111+
44,
112+
],
113+
[
114+
'Version requirement will always evaluate to false.',
115+
52,
116+
],
117+
[
118+
'Version requirement will always evaluate to false.',
119+
60,
120+
],
121+
[
122+
'Version requirement will always evaluate to false.',
123+
68,
124+
],
125+
]);
126+
}
127+
128+
public function testInvalidPhpVersion(): void
129+
{
130+
$this->phpunitMajorVersion = 12;
131+
$this->phpunitMinorVersion = 4;
132+
$this->deprecationRulesInstalled = false;
133+
134+
$this->analyse([__DIR__ . '/data/requires-php-version-invalid.php'], [
135+
[
136+
'Version constraint abc is not supported.',
137+
12,
138+
],
139+
]);
140+
}
141+
81142
protected function getRule(): Rule
82143
{
83144
$phpunitVersion = new PHPUnitVersion($this->phpunitMajorVersion, $this->phpunitMinorVersion);
@@ -89,7 +150,15 @@ protected function getRule(): Rule
89150
$phpunitVersion,
90151
),
91152
$this->deprecationRulesInstalled,
153+
new PhpVersion($this->phpVersion),
92154
);
93155
}
94156

157+
public static function getAdditionalConfigFiles(): array
158+
{
159+
return [
160+
__DIR__ . '/AttributeRequiresPhpVersionRule.neon',
161+
];
162+
}
163+
95164
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace RequiresPhpVersionMismatch;
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 InvalidConstraint extends TestCase
11+
{
12+
#[RequiresPhp('abc')]
13+
public function testFoo(): void {
14+
15+
}
16+
}
17+

0 commit comments

Comments
 (0)