diff --git a/composer.json b/composer.json index 308327f..4165994 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "keywords": ["static analysis"], "require": { "php": "^7.4 || ^8.0", + "phar-io/version": "^3.2", "phpstan/phpstan": "^2.1.48" }, "conflict": { diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php index 899ff1c..fe388b6 100644 --- a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php +++ b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php @@ -2,15 +2,22 @@ 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 @@ -18,24 +25,37 @@ class AttributeRequiresPhpVersionRule implements Rule { + private const VERSION_COMPARISON = "/(?P!=|<|<=|<>|=|==|>|>=)?\s*(?P[\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 @@ -56,15 +76,61 @@ public function processNode(Node $node, Scope $scope): array } $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; } @@ -84,10 +150,23 @@ public function processNode(Node $node, Scope $scope): array ->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()) { + return false; + } + + return substr_count($versionRequirement, '.') !== 2; + } + } diff --git a/src/Rules/PHPUnit/PHPUnitVersion.php b/src/Rules/PHPUnit/PHPUnitVersion.php index 56eb755..f614585 100644 --- a/src/Rules/PHPUnit/PHPUnitVersion.php +++ b/src/Rules/PHPUnit/PHPUnitVersion.php @@ -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) { diff --git a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php index 92d9715..3dc7cc7 100644 --- a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php +++ b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php @@ -2,6 +2,7 @@ namespace PHPStan\Rules\PHPUnit; +use PHPStan\Php\PhpVersion; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; use PHPStan\Type\FileTypeMapper; @@ -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; @@ -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); @@ -89,6 +179,8 @@ protected function getRule(): Rule $phpunitVersion, ), $this->deprecationRulesInstalled, + new PhpVersion($this->phpVersion), + $this->warnAboutIncompleteVersion, ); } diff --git a/tests/Rules/PHPUnit/data/requires-php-version-invalid.php b/tests/Rules/PHPUnit/data/requires-php-version-invalid.php new file mode 100644 index 0000000..a3d57b4 --- /dev/null +++ b/tests/Rules/PHPUnit/data/requires-php-version-invalid.php @@ -0,0 +1,17 @@ +=8.0')] + public function testFoo(): void { + + } +} + +class RequiresPhp8Caret extends TestCase +{ + #[RequiresPhp('^8.0')] + public function testFoo(): void { + + } +} + +class RequiresPhp8Tilde extends TestCase +{ + #[RequiresPhp('~8.0')] + public function testFoo(): void { + + } +} + +class RequiresPhp8Star extends TestCase +{ + #[RequiresPhp('8.*')] + public function testFoo(): void { + + } +}