diff --git a/extension.neon b/extension.neon index 665d85c..ffa0365 100644 --- a/extension.neon +++ b/extension.neon @@ -80,6 +80,12 @@ services: - class: PHPStan\Type\PHPUnit\DynamicCallToAssertionIgnoreExtension + - + class: PHPStan\Rules\PHPUnit\AttributeVersionRequirementHelper + arguments: + deprecationRulesInstalled: %deprecationRulesInstalled% + bleedingEdge: %featureToggles.bleedingEdge% + conditionalTags: PHPStan\PhpDoc\PHPUnit\MockObjectTypeNodeResolverExtension: phpstan.phpDoc.typeNodeResolverExtension: %phpunit.convertUnionToIntersectionType% diff --git a/rules.neon b/rules.neon index 4d13e96..7809d66 100644 --- a/rules.neon +++ b/rules.neon @@ -27,9 +27,6 @@ services: - class: PHPStan\Rules\PHPUnit\AttributeRequiresPhpVersionRule - arguments: - deprecationRulesInstalled: %deprecationRulesInstalled% - bleedingEdge: %featureToggles.bleedingEdge% tags: - phpstan.rules.rule diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php index 1e2ab97..913ffea 100644 --- a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php +++ b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php @@ -2,25 +2,11 @@ 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\PhpMinorVersionIterator; -use PHPStan\Php\PhpVersion; use PHPStan\Rules\Rule; -use PHPStan\Rules\RuleErrorBuilder; -use PHPStan\Type\Constant\ConstantIntegerType; -use PHPStan\Type\IntegerRangeType; 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 @@ -28,41 +14,17 @@ class AttributeRequiresPhpVersionRule implements Rule { - private const VERSION_COMPARISON = "/(?P!=|<|<=|<>|=|==|>|>=)?\s*(?P[\d\.-]+(dev|(RC|alpha|beta)[\d\.])?)[ \t]*\r?$/m"; - - private PHPUnitVersion $PHPUnitVersion; - private TestMethodsHelper $testMethodsHelper; - private PhpVersion $fallbackPhpVersion; - - /** - * 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; - - private bool $bleedingEdge; + private AttributeVersionRequirementHelper $attributeVersionRequirementHelper; public function __construct( - PHPUnitVersion $PHPUnitVersion, TestMethodsHelper $testMethodsHelper, - bool $deprecationRulesInstalled, - PhpVersion $phpVersion, - bool $bleedingEdge, - bool $warnAboutIncompleteVersion = true + AttributeVersionRequirementHelper $attributeVersionRequirementHelper ) { - $this->PHPUnitVersion = $PHPUnitVersion; $this->testMethodsHelper = $testMethodsHelper; - $this->deprecationRulesInstalled = $deprecationRulesInstalled; - $this->fallbackPhpVersion = $phpVersion; - $this->warnAboutIncompleteVersion = $warnAboutIncompleteVersion; - $this->bleedingEdge = $bleedingEdge; + $this->attributeVersionRequirementHelper = $attributeVersionRequirementHelper; } public function getNodeType(): string @@ -82,145 +44,10 @@ public function processNode(Node $node, Scope $scope): array return []; } - $phpstanPharIoVersions = $this->getAnalyzedPhpVersions($scope); - if ($phpstanPharIoVersions === []) { - return []; - } - - $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.attributeRequiresPhpVersion') - ->build(); - } - - if ( - !is_numeric($versionRequirement) - ) { - if (!$this->bleedingEdge) { - continue; - } - - try { - // check composer like version constraints, e.g. ^1 or ~2 - $testPhpVersionConstraint = $parser->parse($versionRequirement); - - foreach ($phpstanPharIoVersions as $pharIoVersion) { - if ($testPhpVersionConstraint->complies($pharIoVersion)) { - // one of the versions within range matched, check next attribute - continue 2; - } - } - } 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'] : '>='; - - foreach ($phpstanPharIoVersions as $pharIoVersion) { - if (version_compare($pharIoVersion->getVersionString(), $matches['version'], $operator)) { - // one of the versions within range matched, check next attribute - continue 2; - } - } - } - - $errors[] = RuleErrorBuilder::message( - sprintf('Version requirement will always evaluate to false.'), - ) - ->identifier('phpunit.attributeRequiresPhpVersion') - ->build(); - - continue; - } - - if ($this->PHPUnitVersion->requiresPhpversionAttributeWithOperator()->yes()) { - $errors[] = RuleErrorBuilder::message( - sprintf('Version requirement is missing operator.'), - ) - ->identifier('phpunit.attributeRequiresPhpVersion') - ->build(); - } elseif ( - $this->deprecationRulesInstalled - && $this->PHPUnitVersion->deprecatesPhpversionAttributeWithoutOperator()->yes() - ) { - $errors[] = RuleErrorBuilder::message( - sprintf('Version requirement without operator is deprecated.'), - ) - ->identifier('phpunit.attributeRequiresPhpVersion') - ->build(); - } - } - - return $errors; - } - - /** - * @return Version[] - */ - private function getAnalyzedPhpVersions(Scope $scope): array - { - $scopePhpVersion = $scope->getPhpVersion()->getType(); - if ($scopePhpVersion instanceof ConstantIntegerType) { - $v = new PhpVersion($scopePhpVersion->getValue()); - return [new Version($v->getVersionString())]; - } elseif ($scopePhpVersion instanceof IntegerRangeType) { - if ($scopePhpVersion->getMin() === null || $scopePhpVersion->getMax() === null) { - return []; - } - - $versions = []; - $minorVersionIterator = new PhpMinorVersionIterator( - new PhpVersion($scopePhpVersion->getMin()), - new PhpVersion($scopePhpVersion->getMax()), - ); - foreach ($minorVersionIterator as $phpstanVersion) { - $versions[] = new Version($phpstanVersion->getVersionString()); - } - return $versions; - } - - return [new Version($this->fallbackPhpVersion->getVersionString())]; - } - - // see https://github.com/sebastianbergmann/phpunit/issues/6451 - private function warnAboutIncompleteVersion(string $versionRequirement): bool - { - if (!$this->bleedingEdge) { - return false; - } - - if (!$this->warnAboutIncompleteVersion) { - return false; - } - - if (!$this->PHPUnitVersion->warnsAboutIncompleteVersion()->yes()) { - return false; - } - - return substr_count($versionRequirement, '.') !== 2; + return $this->attributeVersionRequirementHelper->checkRequiresPhpVersion( + $reflectionMethod->getAttributesByName('PHPUnit\Framework\Attributes\RequiresPhp'), + $scope, + ); } } diff --git a/src/Rules/PHPUnit/AttributeVersionRequirementHelper.php b/src/Rules/PHPUnit/AttributeVersionRequirementHelper.php new file mode 100644 index 0000000..22ae5ab --- /dev/null +++ b/src/Rules/PHPUnit/AttributeVersionRequirementHelper.php @@ -0,0 +1,206 @@ +!=|<|<=|<>|=|==|>|>=)?\s*(?P[\d\.-]+(dev|(RC|alpha|beta)[\d\.])?)[ \t]*\r?$/m"; + + private PHPUnitVersion $PHPUnitVersion; + + private PhpVersion $fallbackPhpVersion; + + /** + * 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; + + private bool $bleedingEdge; + + public function __construct( + PHPUnitVersion $PHPUnitVersion, + bool $deprecationRulesInstalled, + PhpVersion $phpVersion, + bool $bleedingEdge, + bool $warnAboutIncompleteVersion = true + ) + { + $this->PHPUnitVersion = $PHPUnitVersion; + $this->deprecationRulesInstalled = $deprecationRulesInstalled; + $this->fallbackPhpVersion = $phpVersion; + $this->warnAboutIncompleteVersion = $warnAboutIncompleteVersion; + $this->bleedingEdge = $bleedingEdge; + } + + /** + * @param array $attributes + * + * @return list + */ + public function checkRequiresPhpVersion(array $attributes, Scope $scope): array + { + $phpstanPharIoVersions = $this->getAnalyzedPhpVersions($scope); + if ($phpstanPharIoVersions === []) { + return []; + } + + $errors = []; + $parser = new VersionConstraintParser(); + foreach ($attributes 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.attributeRequiresPhpVersion') + ->build(); + } + + if ( + !is_numeric($versionRequirement) + ) { + if (!$this->bleedingEdge) { + continue; + } + + try { + // check composer like version constraints, e.g. ^1 or ~2 + $testPhpVersionConstraint = $parser->parse($versionRequirement); + + foreach ($phpstanPharIoVersions as $pharIoVersion) { + if ($testPhpVersionConstraint->complies($pharIoVersion)) { + // one of the versions within range matched, check next attribute + continue 2; + } + } + } 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'] : '>='; + + foreach ($phpstanPharIoVersions as $pharIoVersion) { + if (version_compare($pharIoVersion->getVersionString(), $matches['version'], $operator)) { + // one of the versions within range matched, check next attribute + continue 2; + } + } + } + + $errors[] = RuleErrorBuilder::message( + sprintf('Version requirement will always evaluate to false.'), + ) + ->identifier('phpunit.attributeRequiresPhpVersion') + ->build(); + + continue; + } + + if ($this->PHPUnitVersion->requiresPhpversionAttributeWithOperator()->yes()) { + $errors[] = RuleErrorBuilder::message( + sprintf('Version requirement is missing operator.'), + ) + ->identifier('phpunit.attributeRequiresPhpVersion') + ->build(); + } elseif ( + $this->deprecationRulesInstalled + && $this->PHPUnitVersion->deprecatesPhpversionAttributeWithoutOperator()->yes() + ) { + $errors[] = RuleErrorBuilder::message( + sprintf('Version requirement without operator is deprecated.'), + ) + ->identifier('phpunit.attributeRequiresPhpVersion') + ->build(); + } + } + return $errors; + } + + /** + * @return Version[] + */ + private function getAnalyzedPhpVersions(Scope $scope): array + { + $scopePhpVersion = $scope->getPhpVersion()->getType(); + if ($scopePhpVersion instanceof ConstantIntegerType) { + $v = new PhpVersion($scopePhpVersion->getValue()); + return [new Version($v->getVersionString())]; + } elseif ($scopePhpVersion instanceof IntegerRangeType) { + if ($scopePhpVersion->getMin() === null || $scopePhpVersion->getMax() === null) { + return []; + } + + $versions = []; + $minorVersionIterator = new PhpMinorVersionIterator( + new PhpVersion($scopePhpVersion->getMin()), + new PhpVersion($scopePhpVersion->getMax()), + ); + foreach ($minorVersionIterator as $phpstanVersion) { + $versions[] = new Version($phpstanVersion->getVersionString()); + } + return $versions; + } + + return [new Version($this->fallbackPhpVersion->getVersionString())]; + } + + // see https://github.com/sebastianbergmann/phpunit/issues/6451 + private function warnAboutIncompleteVersion(string $versionRequirement): bool + { + if (!$this->bleedingEdge) { + return false; + } + + if (!$this->warnAboutIncompleteVersion) { + return false; + } + + if (!$this->PHPUnitVersion->warnsAboutIncompleteVersion()->yes()) { + return false; + } + + return substr_count($versionRequirement, '.') !== 2; + } + +} diff --git a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRangeRuleTest.php b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRangeRuleTest.php index 3e306fa..be34cf5 100644 --- a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRangeRuleTest.php +++ b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRangeRuleTest.php @@ -46,14 +46,16 @@ protected function getRule(): Rule $phpunitVersion = new PHPUnitVersion(null, null); return new AttributeRequiresPhpVersionRule( - $phpunitVersion, new TestMethodsHelper( self::getContainer()->getByType(FileTypeMapper::class), $phpunitVersion, ), - false, - new PhpVersion($this->phpVersion), - true, + new AttributeVersionRequirementHelper( + $phpunitVersion, + false, + new PhpVersion($this->phpVersion), + true, + ), ); } diff --git a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php index ff3d68f..3dd09f8 100644 --- a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php +++ b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php @@ -175,15 +175,17 @@ protected function getRule(): Rule $phpunitVersion = new PHPUnitVersion($this->phpunitMajorVersion, $this->phpunitMinorVersion); return new AttributeRequiresPhpVersionRule( - $phpunitVersion, new TestMethodsHelper( self::getContainer()->getByType(FileTypeMapper::class), $phpunitVersion, ), - $this->deprecationRulesInstalled, - new PhpVersion($this->phpVersion), - true, - $this->warnAboutIncompleteVersion, + new AttributeVersionRequirementHelper( + $phpunitVersion, + $this->deprecationRulesInstalled, + new PhpVersion($this->phpVersion), + true, + $this->warnAboutIncompleteVersion, + ), ); }