Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions rules.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
99 changes: 99 additions & 0 deletions src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\PHPUnit;

use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\InClassMethodNode;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPUnit\Framework\TestCase;
use function count;
use function is_numeric;
use function method_exists;
use function sprintf;

/**
* @implements Rule<InClassMethodNode>
*/
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;
}

}
35 changes: 34 additions & 1 deletion src/Rules/PHPUnit/PHPUnitVersion.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
}

}
7 changes: 5 additions & 2 deletions src/Rules/PHPUnit/PHPUnitVersionDetector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
}

}
12 changes: 12 additions & 0 deletions src/Rules/PHPUnit/TestMethodsHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ReflectionMethod>
*/
Expand Down
95 changes: 95 additions & 0 deletions tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\PHPUnit;

use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;
use PHPStan\Type\FileTypeMapper;

/**
* @extends RuleTestCase<AttributeRequiresPhpVersionRule>
*/
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,
);
}

}
2 changes: 1 addition & 1 deletion tests/Rules/PHPUnit/DataProviderDataRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<Rule<Node>> $rules */
$rules = [
Expand Down
2 changes: 1 addition & 1 deletion tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions tests/Rules/PHPUnit/data/requires-php-version.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace RequiresPhpVersion;

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

class DeprecatedVersionFormat extends TestCase
{
#[RequiresPhp('8.0')]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in a followup I want to emit errors when the version contstraint used within #[RequiresPhp('8.0')] does not fit into the php version used for analysis (detecting tests which require php-versions no longer supported in the project)

public function testDeprecatedFormat(): void {

}
}

class AllGoodTest extends TestCase
{
#[RequiresPhp('>=8.0')]
public function testHappyPath(): void {

}
}