Skip to content
85 changes: 82 additions & 3 deletions src/Rules/PHPUnit/AssertEmptyIsDiscouragedRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@

namespace PHPStan\Rules\PHPUnit;

use Countable;
use PhpParser\Node;
use PhpParser\Node\Expr\CallLike;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Identifier;
use PhpParser\Node\Scalar\Int_;
use PhpParser\Node\Scalar\String_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\UnionType;
use function array_merge;
use function count;
use function in_array;
use function sprintf;
Expand Down Expand Up @@ -43,11 +50,83 @@ public function processNode(Node $node, Scope $scope): array
return [];
}

$errorBuilder = RuleErrorBuilder::message(sprintf('%s() is not allowed. Use more strict assertion.', $node->name->toString()))
->identifier('phpunit.assertEmpty');

if (AssertRuleHelper::hasNamedOrUnpackedArguments($node)) {
return [$errorBuilder->build()];
}

$replacement = $this->getReplacement($scope->getNativeType($node->getArgs()[0]->value), $node->name->toLowerString() === 'assertnotempty');
if ($replacement !== null) {
[$correctName, $expectedValue] = $replacement;
$errorBuilder->fixNode($node, static function (CallLike $node) use ($correctName, $expectedValue) {
$node->name = new Identifier($correctName);
if ($expectedValue !== null) {
$node->args = array_merge([new Node\Arg($expectedValue)], $node->args);
}

return $node;
});
}

return [
RuleErrorBuilder::message(sprintf('%s() is not allowed. Use more strict assertion.', $node->name->toString()))
->identifier('phpunit.assertEmpty')
->build(),
$errorBuilder->build(),
];
}

/**
* @return array{string, Node\Expr|null}|null
*/
private function getReplacement(Type $type, bool $negated): ?array
{
if ($type instanceof UnionType) {
if (TypeCombinator::containsNull($type)) {
$typeWithoutNull = TypeCombinator::removeNull($type);

$classReflections = $typeWithoutNull->getObjectClassReflections();
if (count($classReflections) === 0) {
return null;
}
foreach ($classReflections as $classReflection) {
if (
$classReflection->isBuiltin()
|| !$classReflection->isFinalByKeyword()
|| $classReflection->implementsInterface(Countable::class)
) {
return null;
}

$parentClass = $classReflection->getParentClass();
while ($parentClass !== null) {
// Builtin parents can define different empty() semantics.
if ($parentClass->isBuiltin()) {
return null;
}
$parentClass = $parentClass->getParentClass();
}
}

return [$negated ? 'assertNotNull' : 'assertNull', null];
}

return null;
}

if ($type->isBoolean()->yes()) {
return [$negated ? 'assertTrue' : 'assertFalse', null];
}
if ($type->isArray()->yes()) {
return [$negated ? 'assertNotCount' : 'assertCount', new Int_(0)];
}
if ($type->isInteger()->yes()) {
return [$negated ? 'assertNotSame' : 'assertSame', new Int_(0)];
}
if ($type->isNonFalsyString()->yes()) {
return [$negated ? 'assertNotSame' : 'assertSame', new String_('')];
}

return null;
}

}
15 changes: 15 additions & 0 deletions tests/Rules/PHPUnit/AssertEmptyIsDiscouragedRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;
use const PHP_VERSION_ID;

/**
* @extends RuleTestCase<AssertEmptyIsDiscouragedRule>
Expand All @@ -21,6 +22,20 @@ public function testRule(): void
]);
}

public function testFix(): void
{
$this->fix(__DIR__ . '/data/assert-empty-is-discouraged-fixable.php', __DIR__ . '/data/assert-empty-is-discouraged-fixable.php.fixed');
}

public function testNativeUnionTypeIsNotFixed(): void
{
if (PHP_VERSION_ID < 80000) {
self::markTestSkipped('Native union types require PHP 8.0.');
}

$this->fix(__DIR__ . '/data/assert-empty-is-discouraged-native-union.php', __DIR__ . '/data/assert-empty-is-discouraged-native-union.php.fixed');
}

protected function getRule(): Rule
{
return new AssertEmptyIsDiscouragedRule();
Expand Down
143 changes: 143 additions & 0 deletions tests/Rules/PHPUnit/data/assert-empty-is-discouraged-fixable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php declare(strict_types = 1);

namespace AssertEmptyIsDiscouragedFixTest;

use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;

final class UserDefinedObject
{

}

/** @final */
class PhpDocFinalObject
{

}

class UserDefinedCountable implements \Countable
{

public function count(): int
{
return 0;
}

}

class OpenParent
{

}

interface Thing
{

}

class ThingImplementation implements Thing, \Countable
{

public function count(): int
{
return 0;
}

}

final class FinalEmptyIterator extends \EmptyIterator
{

}

final class FinalCountable implements \Countable
{

public function count(): int
{
return 0;
}

}

final class AssertEmptyTest extends TestCase
{

/**
* @param non-falsy-string $nonFalsyString
* @param non-falsy-string $otherNonFalsyString
* @param int $phpDocInteger
* @param string|int $union
*/
public function test(
array $arguments,
bool $boolean,
array $array,
int $integer,
float $float,
string $string,
$union,
string $nonFalsyString,
string $otherNonFalsyString,
?\stdClass $nullableObject,
?object $otherNullableObject,
?\SimpleXMLElement $nullableSimpleXml,
?UserDefinedObject $nullableUserDefinedObject,
?UserDefinedObject $otherNullableUserDefinedObject,
?PhpDocFinalObject $nullablePhpDocFinalObject,
?UserDefinedCountable $nullableCountable,
?OpenParent $nullableOpenParent,
?Thing $nullableThing,
?FinalEmptyIterator $nullableEmptyIterator,
?FinalCountable $nullableFinalCountable,
$phpDocInteger,
$mixed
): void
{
$this->assertEmpty(...$arguments);
$this->assertEmpty($boolean);
$this->assertNotEmpty($boolean);
$this->assertEmpty($array, 'message');
$this->assertNotEmpty($array);
$this->assertEmpty($integer);
$this->assertNotEmpty($integer);
Assert::assertEmpty($float);
Assert::assertNotEmpty($float);
$this->assertEmpty($nonFalsyString);
$this->assertNotEmpty($otherNonFalsyString);
$this->assertEmpty($string);
$this->assertNotEmpty($string);
$this->assertEmpty($union);
$this->assertNotEmpty($union);
$this->assertEmpty($nullableObject);
$this->assertNotEmpty($otherNullableObject);
$this->assertEmpty($nullableSimpleXml);
$this->assertEmpty($nullableUserDefinedObject);
$this->assertNotEmpty($otherNullableUserDefinedObject);
$this->assertEmpty($nullablePhpDocFinalObject);
$this->assertNotEmpty($nullablePhpDocFinalObject);
$this->assertEmpty($nullableCountable);
$this->assertNotEmpty($nullableCountable);
$this->assertEmpty($nullableOpenParent);
$this->assertNotEmpty($nullableOpenParent);
$this->assertEmpty($nullableThing);
$this->assertNotEmpty($nullableThing);
$this->assertEmpty($nullableEmptyIterator);
$this->assertNotEmpty($nullableEmptyIterator);
$this->assertEmpty($nullableFinalCountable);
$this->assertNotEmpty($nullableFinalCountable);
$this->assertNotEmpty($phpDocInteger);
$this->assertEmpty($mixed);
}

public function testNativeNonFalsyString(string $value): void
{
if ($value === '' || $value === '0') {
return;
}

$this->assertNotEmpty($value);
}

}
Loading
Loading