Skip to content

Commit

Permalink
Support for class-string $class parameter in is_subclass_of()
Browse files Browse the repository at this point in the history
  • Loading branch information
arnaud-lb authored and ondrejmirtes committed Feb 27, 2022
1 parent 1b117f7 commit 73f14db
Show file tree
Hide file tree
Showing 7 changed files with 180 additions and 44 deletions.
66 changes: 22 additions & 44 deletions src/Type/Php/IsSubclassOfFunctionTypeSpecifyingExtension.php
Expand Up @@ -15,15 +15,11 @@
use PHPStan\Type\FunctionTypeSpecifyingExtension;
use PHPStan\Type\Generic\GenericClassStringType;
use PHPStan\Type\IntersectionType;
use PHPStan\Type\MixedType;
use PHPStan\Type\NeverType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\ObjectWithoutClassType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\TypeTraverser;
use PHPStan\Type\TypeWithClassName;
use PHPStan\Type\UnionType;
use function count;
use function strtolower;
Expand All @@ -44,61 +40,43 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n
if (count($node->getArgs()) < 2) {
return new SpecifiedTypes();
}
$objectType = $scope->getType($node->getArgs()[0]->value);
$classType = $scope->getType($node->getArgs()[1]->value);
$allowStringType = isset($node->getArgs()[2]) ? $scope->getType($node->getArgs()[2]->value) : new ConstantBooleanType(true);
$allowString = !$allowStringType->equals(new ConstantBooleanType(false));

if (!$classType instanceof ConstantStringType) {
if ($context->truthy()) {
if ($allowString) {
$type = TypeCombinator::union(
new ObjectWithoutClassType(),
new ClassStringType(),
);
} else {
$type = new ObjectWithoutClassType();
}

return $this->typeSpecifier->create(
$node->getArgs()[0]->value,
$type,
$context,
false,
$scope,
);
}

return new SpecifiedTypes();
if (!$classType instanceof ConstantStringType && !$context->truthy()) {
return new SpecifiedTypes([], []);
}

$type = TypeTraverser::map($objectType, static function (Type $type, callable $traverse) use ($classType, $allowString): Type {
if ($type instanceof UnionType) {
return $traverse($type);
}
if ($type instanceof IntersectionType) {
$type = TypeTraverser::map($classType, static function (Type $type, callable $traverse) use ($allowString): Type {
if ($type instanceof UnionType || $type instanceof IntersectionType) {
return $traverse($type);
}
if ($allowString) {
if ($type instanceof StringType) {
return new GenericClassStringType(new ObjectType($classType->getValue()));
if ($type instanceof ConstantStringType) {
if ($allowString) {
return TypeCombinator::union(
new ObjectType($type->getValue()),
new GenericClassStringType(new ObjectType($type->getValue())),
);
}
return new ObjectType($type->getValue());
}
if ($type instanceof ObjectWithoutClassType || $type instanceof TypeWithClassName) {
return new ObjectType($classType->getValue());
}
if ($type instanceof MixedType) {
$objectType = new ObjectType($classType->getValue());
if ($type instanceof GenericClassStringType) {
if ($allowString) {
return TypeCombinator::union(
new GenericClassStringType($objectType),
$objectType,
$type->getGenericType(),
$type,
);
}

return $objectType;
return $type->getGenericType();
}
if ($allowString) {
return TypeCombinator::union(
new ObjectWithoutClassType(),
new ClassStringType(),
);
}
return new NeverType();
return new ObjectWithoutClassType();
});

return $this->typeSpecifier->create(
Expand Down
1 change: 1 addition & 0 deletions tests/PHPStan/Analyser/NodeScopeResolverTest.php
Expand Up @@ -728,6 +728,7 @@ public function dataFileAsserts(): iterable
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-6696.php');
yield from $this->gatherAssertTypes(__DIR__ . '/../Rules/Methods/data/filter-iterator-child-class.php');
yield from $this->gatherAssertTypes(__DIR__ . '/data/smaller-than-benevolent.php');
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-6698.php');
}

/**
Expand Down
45 changes: 45 additions & 0 deletions tests/PHPStan/Analyser/TypeSpecifierTest.php
Expand Up @@ -26,6 +26,7 @@
use PHPStan\Type\MixedType;
use PHPStan\Type\NullType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\ObjectWithoutClassType;
use PHPStan\Type\StringType;
use PHPStan\Type\UnionType;
use PHPStan\Type\VerbosityLevel;
Expand Down Expand Up @@ -66,6 +67,7 @@ protected function setUp(): void
$this->scope = $this->scope->assignVariable('foo', new MixedType());
$this->scope = $this->scope->assignVariable('classString', new ClassStringType());
$this->scope = $this->scope->assignVariable('genericClassString', new GenericClassStringType(new ObjectType('Bar')));
$this->scope = $this->scope->assignVariable('object', new ObjectWithoutClassType());
}

/**
Expand Down Expand Up @@ -955,6 +957,17 @@ public function dataCondition(): array
],
[],
],
[
new FuncCall(new Name('is_subclass_of'), [
new Arg(new Variable('object')),
new Arg(new Variable('stringOrNull')),
new Arg(new Expr\ConstFetch(new Name('false'))),
]),
[
'$object' => 'object',
],
[],
],
[
new FuncCall(new Name('is_subclass_of'), [
new Arg(new Variable('string')),
Expand All @@ -966,6 +979,38 @@ public function dataCondition(): array
],
[],
],
[
new FuncCall(new Name('is_subclass_of'), [
new Arg(new Variable('string')),
new Arg(new Variable('genericClassString')),
]),
[
'$string' => 'Bar|class-string<Bar>',
],
[],
],
[
new FuncCall(new Name('is_subclass_of'), [
new Arg(new Variable('object')),
new Arg(new Variable('genericClassString')),
new Arg(new Expr\ConstFetch(new Name('false'))),
]),
[
'$object' => 'Bar',
],
[],
],
[
new FuncCall(new Name('is_subclass_of'), [
new Arg(new Variable('string')),
new Arg(new Variable('genericClassString')),
new Arg(new Expr\ConstFetch(new Name('false'))),
]),
[
'$string' => 'Bar',
],
[],
],
[
new Expr\BinaryOp\BooleanOr(
new Expr\BinaryOp\BooleanAnd(
Expand Down
34 changes: 34 additions & 0 deletions tests/PHPStan/Analyser/data/bug-6698.php
@@ -0,0 +1,34 @@
<?php declare(strict_types = 1);

namespace Analyzer\Bug6698;

use function PHPStan\Testing\assertType;

interface X {
/**
* @return iterable<class-string>
*/
public function getClasses(): iterable;
}

class Y
{
/** @var X */
public $x;

/**
* @template T of object
*
* @param class-string<T> $type
* @return iterable<class-string<T>>
*/
public function findImplementations(string $type): iterable
{
foreach ($this->x->getClasses() as $class) {
if (is_subclass_of($class, $type)) {
assertType('class-string<T of object (method Analyzer\Bug6698\Y::findImplementations(), argument)>', $class);
yield $class;
}
}
}
}
40 changes: 40 additions & 0 deletions tests/PHPStan/Analyser/data/generic-class-string.php
Expand Up @@ -20,15 +20,22 @@ function testMixed($a) {
if (is_subclass_of($a, 'DateTimeInterface')) {
assertType('class-string<DateTimeInterface>|DateTimeInterface', $a);
assertType('DateTimeInterface', new $a());
} else {
assertType('mixed~class-string<DateTimeInterface>|DateTimeInterface', $a);
}

if (is_subclass_of($a, 'DateTimeInterface') || is_subclass_of($a, 'stdClass')) {
assertType('class-string<DateTimeInterface>|class-string<stdClass>|DateTimeInterface|stdClass', $a);
assertType('DateTimeInterface|stdClass', new $a());
} else {
// could also exclude stdClass
assertType('mixed~class-string<DateTimeInterface>|DateTimeInterface', $a);
}

if (is_subclass_of($a, C::class)) {
assertType('int', $a::f());
} else {
assertType('mixed~class-string<PHPStan\Generics\GenericClassStringType\C>|PHPStan\Generics\GenericClassStringType\C', $a);
}
}

Expand All @@ -40,6 +47,8 @@ function testObject($a) {

if (is_subclass_of($a, 'DateTimeInterface')) {
assertType('DateTimeInterface', $a);
} else {
assertType('object~DateTimeInterface', $a);
}
}

Expand All @@ -52,10 +61,14 @@ function testString($a) {
if (is_subclass_of($a, 'DateTimeInterface')) {
assertType('class-string<DateTimeInterface>', $a);
assertType('DateTimeInterface', new $a());
} else {
assertType('string', $a);
}

if (is_subclass_of($a, C::class)) {
assertType('int', $a::f());
} else {
assertType('string', $a);
}
}

Expand All @@ -68,10 +81,14 @@ function testStringObject($a) {
if (is_subclass_of($a, 'DateTimeInterface')) {
assertType('class-string<DateTimeInterface>|DateTimeInterface', $a);
assertType('DateTimeInterface', new $a());
} else {
assertType('object~DateTimeInterface|string', $a);
}

if (is_subclass_of($a, C::class)) {
assertType('int', $a::f());
} else {
assertType('object~PHPStan\Generics\GenericClassStringType\C|string', $a);
}
}

Expand All @@ -84,6 +101,29 @@ function testClassString($a) {
if (is_subclass_of($a, 'DateTime')) {
assertType('class-string<DateTime>', $a);
assertType('DateTime', new $a());
} else {
assertType('class-string<DateTimeInterface>', $a);
}
}

/**
* @param object|string $a
* @param class-string<\DateTimeInterface> $b
*/
function testClassStringAsClassName($a, string $b) {
assertType('object', new $a());

if (is_subclass_of($a, $b)) {
assertType('class-string<DateTimeInterface>|DateTimeInterface', $a);
assertType('DateTimeInterface', new $a());
} else {
assertType('object|string', $a);
}

if (is_subclass_of($a, $b, false)) {
assertType('DateTimeInterface', $a);
} else {
assertType('object|string', $a);
}
}

Expand Down
Expand Up @@ -448,4 +448,11 @@ public function testBug3766(): void
$this->analyse([__DIR__ . '/data/bug-3766.php'], []);
}

public function testBug6698(): void
{
$this->checkAlwaysTrueCheckTypeFunctionCall = true;
$this->treatPhpDocTypesAsCertain = true;
$this->analyse([__DIR__ . '/data/bug-6698.php'], []);
}

}
31 changes: 31 additions & 0 deletions tests/PHPStan/Rules/Comparison/data/bug-6698.php
@@ -0,0 +1,31 @@
<?php declare(strict_types = 1);

namespace Comparison\Bug6698;

interface X {
/**
* @return iterable<class-string>
*/
public function getClasses(): iterable;
}

class Y
{
/** @var X */
public $x;

/**
* @template T of object
*
* @param class-string<T> $type
* @return iterable<class-string<T>>
*/
public function findImplementations(string $type): iterable
{
foreach ($this->x->getClasses() as $class) {
if (is_subclass_of($class, $type)) {
yield $class;
}
}
}
}

0 comments on commit 73f14db

Please sign in to comment.