diff --git a/rules.neon b/rules.neon index 8272f47..8469bd1 100644 --- a/rules.neon +++ b/rules.neon @@ -25,6 +25,13 @@ services: tags: - phpstan.rules.rule + - + class: PHPStan\Rules\PHPUnit\AttributeRequiresPhpVersionRule + arguments: + deprecationRulesInstalled: %deprecationRulesInstalled% + tags: + - phpstan.rules.rule + - class: PHPStan\Rules\PHPUnit\AssertEqualsIsDiscouragedRule diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php new file mode 100644 index 0000000..f106bf8 --- /dev/null +++ b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php @@ -0,0 +1,99 @@ + + */ +class AttributeRequiresPhpVersionRule implements Rule +{ + + private PHPUnitVersion $PHPUnitVersion; + + private TestMethodsHelper $testMethodsHelper; + + /** + * When phpstan-deprecation-rules is installed, it reports deprecated usages. + */ + private bool $deprecationRulesInstalled; + + public function __construct( + PHPUnitVersion $PHPUnitVersion, + TestMethodsHelper $testMethodsHelper, + bool $deprecationRulesInstalled + ) + { + $this->PHPUnitVersion = $PHPUnitVersion; + $this->testMethodsHelper = $testMethodsHelper; + $this->deprecationRulesInstalled = $deprecationRulesInstalled; + } + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $scope->getClassReflection(); + if ($classReflection === null || $classReflection->is(TestCase::class) === false) { + return []; + } + + $reflectionMethod = $this->testMethodsHelper->getTestMethodReflection($classReflection, $node->getMethodReflection(), $scope); + if ($reflectionMethod === null) { + return []; + } + + /** @phpstan-ignore function.alreadyNarrowedType */ + if (!method_exists($reflectionMethod, 'getAttributes')) { + return []; + } + + $errors = []; + foreach ($reflectionMethod->getAttributes('PHPUnit\Framework\Attributes\RequiresPhp') as $attr) { + $args = $attr->getArguments(); + if (count($args) !== 1) { + continue; + } + + if ( + !is_numeric($args[0]) + ) { + 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; + } + +} diff --git a/src/Rules/PHPUnit/PHPUnitVersion.php b/src/Rules/PHPUnit/PHPUnitVersion.php index b7259a8..56eb755 100644 --- a/src/Rules/PHPUnit/PHPUnitVersion.php +++ b/src/Rules/PHPUnit/PHPUnitVersion.php @@ -9,9 +9,12 @@ class PHPUnitVersion private ?int $majorVersion; - public function __construct(?int $majorVersion) + private ?int $minorVersion; + + public function __construct(?int $majorVersion, ?int $minorVersion) { $this->majorVersion = $majorVersion; + $this->minorVersion = $minorVersion; } public function supportsDataProviderAttribute(): TrinaryLogic @@ -46,4 +49,34 @@ public function supportsNamedArgumentsInDataProvider(): TrinaryLogic return TrinaryLogic::createFromBoolean($this->majorVersion >= 11); } + public function requiresPhpversionAttributeWithOperator(): TrinaryLogic + { + if ($this->majorVersion === null) { + return TrinaryLogic::createMaybe(); + } + return TrinaryLogic::createFromBoolean($this->majorVersion >= 13); + } + + public function deprecatesPhpversionAttributeWithoutOperator(): TrinaryLogic + { + return $this->minVersion(12, 4); + } + + private function minVersion(int $major, int $minor): TrinaryLogic + { + if ($this->majorVersion === null || $this->minorVersion === null) { + return TrinaryLogic::createMaybe(); + } + + if ($this->majorVersion > $major) { + return TrinaryLogic::createYes(); + } + + if ($this->majorVersion === $major && $this->minorVersion >= $minor) { + return TrinaryLogic::createYes(); + } + + return TrinaryLogic::createNo(); + } + } diff --git a/src/Rules/PHPUnit/PHPUnitVersionDetector.php b/src/Rules/PHPUnit/PHPUnitVersionDetector.php index f0e2c4b..b82caaf 100644 --- a/src/Rules/PHPUnit/PHPUnitVersionDetector.php +++ b/src/Rules/PHPUnit/PHPUnitVersionDetector.php @@ -23,6 +23,7 @@ public function __construct(ReflectionProvider $reflectionProvider) public function createPHPUnitVersion(): PHPUnitVersion { $majorVersion = null; + $minorVersion = null; if ($this->reflectionProvider->hasClass(TestCase::class)) { $testCase = $this->reflectionProvider->getClass(TestCase::class); $file = $testCase->getFileName(); @@ -35,14 +36,16 @@ public function createPHPUnitVersion(): PHPUnitVersion $json = json_decode($composerJson, true); $version = $json['extra']['branch-alias']['dev-main'] ?? null; if ($version !== null) { - $majorVersion = (int) explode('.', $version)[0]; + $versionParts = explode('.', $version); + $majorVersion = (int) $versionParts[0]; + $minorVersion = (int) $versionParts[1]; } } } } } - return new PHPUnitVersion($majorVersion); + return new PHPUnitVersion($majorVersion, $minorVersion); } } diff --git a/src/Rules/PHPUnit/TestMethodsHelper.php b/src/Rules/PHPUnit/TestMethodsHelper.php index 5eb274e..a215c8f 100644 --- a/src/Rules/PHPUnit/TestMethodsHelper.php +++ b/src/Rules/PHPUnit/TestMethodsHelper.php @@ -5,6 +5,7 @@ use PHPStan\Analyser\Scope; use PHPStan\PhpDoc\ResolvedPhpDocBlock; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\MethodReflection; use PHPStan\Type\FileTypeMapper; use PHPUnit\Framework\TestCase; use ReflectionMethod; @@ -27,6 +28,17 @@ public function __construct( $this->PHPUnitVersion = $PHPUnitVersion; } + public function getTestMethodReflection(ClassReflection $classReflection, MethodReflection $methodReflection, Scope $scope): ?ReflectionMethod + { + foreach ($this->getTestMethods($classReflection, $scope) as $testMethod) { + if ($testMethod->getName() === $methodReflection->getName()) { + return $testMethod; + } + } + + return null; + } + /** * @return array */ diff --git a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php new file mode 100644 index 0000000..92d9715 --- /dev/null +++ b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php @@ -0,0 +1,95 @@ + + */ +final class AttributeRequiresPhpVersionRuleTest extends RuleTestCase +{ + + private ?int $phpunitMajorVersion; + + private ?int $phpunitMinorVersion; + + private bool $deprecationRulesInstalled = true; + + public function testRuleOnPHPUnitUnknown(): void + { + $this->phpunitMajorVersion = null; + $this->phpunitMinorVersion = null; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], []); + } + + public function testRuleOnPHPUnit115(): void + { + $this->phpunitMajorVersion = 11; + $this->phpunitMinorVersion = 5; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], []); + } + + public function testRuleOnPHPUnit123(): void + { + $this->phpunitMajorVersion = 12; + $this->phpunitMinorVersion = 3; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], []); + } + + public function testRuleOnPHPUnit124DeprecationsOn(): void + { + $this->phpunitMajorVersion = 12; + $this->phpunitMinorVersion = 4; + $this->deprecationRulesInstalled = true; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], [ + [ + 'Version requirement without operator is deprecated.', + 12, + ], + ]); + } + + public function testRuleOnPHPUnit124DeprecationsOff(): void + { + $this->phpunitMajorVersion = 12; + $this->phpunitMinorVersion = 4; + $this->deprecationRulesInstalled = false; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], []); + } + + public function testRuleOnPHPUnit13(): void + { + $this->phpunitMajorVersion = 13; + $this->phpunitMinorVersion = 0; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], [ + [ + 'Version requirement is missing operator.', + 12, + ], + ]); + } + + 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, + ); + } + +} diff --git a/tests/Rules/PHPUnit/DataProviderDataRuleTest.php b/tests/Rules/PHPUnit/DataProviderDataRuleTest.php index cca88e7..012fce7 100644 --- a/tests/Rules/PHPUnit/DataProviderDataRuleTest.php +++ b/tests/Rules/PHPUnit/DataProviderDataRuleTest.php @@ -22,7 +22,7 @@ class DataProviderDataRuleTest extends RuleTestCase protected function getRule(): Rule { $reflectionProvider = $this->createReflectionProvider(); - $phpunitVersion = new PHPUnitVersion($this->phpunitVersion); + $phpunitVersion = new PHPUnitVersion($this->phpunitVersion, 0); /** @var list> $rules */ $rules = [ diff --git a/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php b/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php index 2bf9d87..f63c9e6 100644 --- a/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php +++ b/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php @@ -24,7 +24,7 @@ protected function getRule(): Rule $reflection, self::getContainer()->getByType(FileTypeMapper::class), self::getContainer()->getService('defaultAnalysisParser'), - new PHPUnitVersion($this->phpunitVersion) + new PHPUnitVersion($this->phpunitVersion, 0) ), true, true diff --git a/tests/Rules/PHPUnit/data/requires-php-version.php b/tests/Rules/PHPUnit/data/requires-php-version.php new file mode 100644 index 0000000..5550edf --- /dev/null +++ b/tests/Rules/PHPUnit/data/requires-php-version.php @@ -0,0 +1,24 @@ +=8.0')] + public function testHappyPath(): void { + + } +}