Skip to content
Draft
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"keywords": ["static analysis"],
"require": {
"php": "^7.4 || ^8.0",
"phar-io/version": "^3.2",
"phpstan/phpstan": "^2.1.48"
},
"conflict": {
Expand Down
87 changes: 83 additions & 4 deletions src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,60 @@

namespace PHPStan\Rules\PHPUnit;

use PharIo\Version\UnsupportedVersionConstraintException;
use PharIo\Version\Version;
use PharIo\Version\VersionConstraintParser;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\InClassMethodNode;
use PHPStan\Php\PhpVersion;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPUnit\Framework\TestCase;
use function count;
use function is_numeric;
use function preg_match;
use function sprintf;
use function substr_count;
use function version_compare;

/**
* @implements Rule<InClassMethodNode>
*/
class AttributeRequiresPhpVersionRule implements Rule
{

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

private Version $phpstanPhpVersion;

private PHPUnitVersion $PHPUnitVersion;

private TestMethodsHelper $testMethodsHelper;

/**
* When phpstan-deprecation-rules is installed, it reports deprecated usages.
* When phpstan-deprecation-rules is installed, rule reports deprecated usages.
*/
private bool $deprecationRulesInstalled;

/**
* Whether warnings about incomplete versions are allowed to be emitted
*/
private bool $warnAboutIncompleteVersion;

public function __construct(
PHPUnitVersion $PHPUnitVersion,
TestMethodsHelper $testMethodsHelper,
bool $deprecationRulesInstalled
bool $deprecationRulesInstalled,
PhpVersion $phpVersion,
bool $warnAboutIncompleteVersion = true
)
{
$this->PHPUnitVersion = $PHPUnitVersion;
$this->testMethodsHelper = $testMethodsHelper;
$this->deprecationRulesInstalled = $deprecationRulesInstalled;
$this->phpstanPhpVersion = new Version($phpVersion->getVersionString());
$this->warnAboutIncompleteVersion = $warnAboutIncompleteVersion;
}

public function getNodeType(): string
Expand All @@ -56,15 +76,61 @@
}

$errors = [];
$parser = new VersionConstraintParser();
foreach ($reflectionMethod->getAttributesByName('PHPUnit\Framework\Attributes\RequiresPhp') as $attr) {
$args = $attr->getArguments();
if (count($args) !== 1) {
continue;
}

// the following block is mimicing PHPUnit version parsing
// see https://github.com/sebastianbergmann/phpunit/blob/43c2cd7b96ee1e800b35e4df23b419a88b53111d/src/Metadata/Version/Requirement.php

$versionRequirement = $args[0];

if ($this->warnAboutIncompleteVersion($versionRequirement)) {
$errors[] = RuleErrorBuilder::message(
sprintf('Version requirement is incomplete.'),
)
->identifier('phpunit.incompletePhpVersion')
->build();
}

if (
!is_numeric($args[0])
!is_numeric($versionRequirement)
) {
try {
// check composer like version constraints, e.g. ^1 or ~2
$testPhpVersionConstraint = $parser->parse($versionRequirement);

if ($testPhpVersionConstraint->complies($this->phpstanPhpVersion)) {
continue;
}
} catch (UnsupportedVersionConstraintException $e) {
// test php-src builtin operators as in version_compare()
if (preg_match(self::VERSION_COMPARISON, $versionRequirement, $matches) <= 0) {
$errors[] = RuleErrorBuilder::message(
sprintf($e->getMessage()),
)
->identifier('phpunit.attributeRequiresPhpVersion')
->build();

continue;
}

$operator = $matches['operator'] !== '' ? $matches['operator'] : '>=';

if (version_compare($this->phpstanPhpVersion->getVersionString(), $matches['version'], $operator)) {
continue;
}
}

$errors[] = RuleErrorBuilder::message(
sprintf('Version requirement will always evaluate to false.'),
)
->identifier('phpunit.attributeRequiresPhpVersion')
->build();

continue;
}

Expand All @@ -84,10 +150,23 @@
->identifier('phpunit.attributeRequiresPhpVersion')
->build();
}

}

return $errors;
}

// see https://github.com/sebastianbergmann/phpunit/issues/6451
function warnAboutIncompleteVersion(string $versionRequirement): bool
{
if (!$this->warnAboutIncompleteVersion) {
return false;
}

if (!$this->PHPUnitVersion->warnsAboutIncompleteVersion()->yes()) {

Check warning on line 165 in src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.5, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ return false; } - if (!$this->PHPUnitVersion->warnsAboutIncompleteVersion()->yes()) { + if ($this->PHPUnitVersion->warnsAboutIncompleteVersion()->no()) { return false; }

Check warning on line 165 in src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ return false; } - if (!$this->PHPUnitVersion->warnsAboutIncompleteVersion()->yes()) { + if ($this->PHPUnitVersion->warnsAboutIncompleteVersion()->no()) { return false; }

Check warning on line 165 in src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ return false; } - if (!$this->PHPUnitVersion->warnsAboutIncompleteVersion()->yes()) { + if ($this->PHPUnitVersion->warnsAboutIncompleteVersion()->no()) { return false; }
return false;
}

return substr_count($versionRequirement, '.') !== 2;
}

}
5 changes: 5 additions & 0 deletions src/Rules/PHPUnit/PHPUnitVersion.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ public function deprecatesPhpversionAttributeWithoutOperator(): TrinaryLogic
return $this->minVersion(12, 4);
}

public function warnsAboutIncompleteVersion(): TrinaryLogic
{
return $this->minVersion(12, 5);
}

private function minVersion(int $major, int $minor): TrinaryLogic
{
if ($this->majorVersion === null || $this->minorVersion === null) {
Expand Down
92 changes: 92 additions & 0 deletions tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace PHPStan\Rules\PHPUnit;

use PHPStan\Php\PhpVersion;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;
use PHPStan\Type\FileTypeMapper;
Expand All @@ -12,12 +13,16 @@
final class AttributeRequiresPhpVersionRuleTest extends RuleTestCase
{

private int $phpVersion = 80500;

private ?int $phpunitMajorVersion;

private ?int $phpunitMinorVersion;

private bool $deprecationRulesInstalled = true;

private bool $warnAboutIncompleteVersion = false;

public function testRuleOnPHPUnitUnknown(): void
{
$this->phpunitMajorVersion = null;
Expand Down Expand Up @@ -78,6 +83,91 @@ public function testRuleOnPHPUnit13(): void
]);
}

public function testPhpVersionMismatch(): void
{
$this->phpunitMajorVersion = 12;
$this->phpunitMinorVersion = 4;
$this->deprecationRulesInstalled = false;

$this->analyse([__DIR__ . '/data/requires-php-version-mismatch.php'], [
[
'Version requirement will always evaluate to false.',
12,
],
[
'Version requirement will always evaluate to false.',
20,
],
[
'Version requirement will always evaluate to false.',
28,
],
[
'Version requirement will always evaluate to false.',
36,
],
[
'Version requirement will always evaluate to false.',
44,
],
[
'Version requirement will always evaluate to false.',
52,
],
[
'Version requirement will always evaluate to false.',
60,
],
[
'Version requirement will always evaluate to false.',
68,
],
]);
}

public function testInvalidPhpVersion(): void
{
$this->phpunitMajorVersion = 12;
$this->phpunitMinorVersion = 4;
$this->deprecationRulesInstalled = false;

$this->analyse([__DIR__ . '/data/requires-php-version-invalid.php'], [
[
'Version constraint abc is not supported.',
12,
],
]);
}

public function testNoWarnAboutIncompleteVersionInOldPhpunit(): void
{
$this->phpunitMajorVersion = 12;
$this->phpunitMinorVersion = 0;
$this->deprecationRulesInstalled = false;
$this->warnAboutIncompleteVersion = true;

$this->analyse([__DIR__ . '/data/requires-php-version.php'], []);
}

public function testWarnAboutIncompleteVersion(): void
{
$this->phpunitMajorVersion = 12;
$this->phpunitMinorVersion = 5;
$this->deprecationRulesInstalled = false;
$this->warnAboutIncompleteVersion = true;

$this->analyse([__DIR__ . '/data/requires-php-version.php'], [
[
'Version requirement is incomplete.',
12,
],
[
'Version requirement is incomplete.',
20,
],
]);
}

protected function getRule(): Rule
{
$phpunitVersion = new PHPUnitVersion($this->phpunitMajorVersion, $this->phpunitMinorVersion);
Expand All @@ -89,6 +179,8 @@ protected function getRule(): Rule
$phpunitVersion,
),
$this->deprecationRulesInstalled,
new PhpVersion($this->phpVersion),
$this->warnAboutIncompleteVersion,
);
}

Expand Down
17 changes: 17 additions & 0 deletions tests/Rules/PHPUnit/data/requires-php-version-invalid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace RequiresPhpVersionMismatch;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\RequiresPhp;

class InvalidConstraint extends TestCase
{
#[RequiresPhp('abc')]
public function testFoo(): void {

}
}

Loading
Loading