Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for class-string $class parameter in is_subclass_of() #1039

Merged
merged 1 commit into from Feb 27, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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;
}
}
}
}