diff --git a/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php b/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php index e4db21dda9..5854d0f778 100644 --- a/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php +++ b/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php @@ -12,6 +12,9 @@ use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; +use PHPStan\Type\Generic\GenericClassStringType; +use PHPStan\Type\ObjectType; +use PHPStan\Type\StaticType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; @@ -63,9 +66,45 @@ public function getTypeFromFunctionCall( return TypeCombinator::union(...array_map(fn (ConstantStringType $stringType): Type => $this->findParentClassNameType($stringType->getValue()), $constantStrings)); } - $classNames = $argType->getObjectClassNames(); + // A `static::class` string refers to the same late-static-bound type as `$this`/`static`, + // so unwrap it and reuse the object handling below. Non-class-string types resolve to + // an ErrorType here, so they are left alone. + $valueType = $argType; + $classStringObjectType = $argType->getClassStringObjectType(); + if ($classStringObjectType instanceof StaticType) { + $valueType = $classStringObjectType; + } + + // Inside a trait a late-static-bound value cannot be resolved to a concrete parent, + // same as `$this` above. + if ($scope->isInTrait() && $valueType instanceof StaticType) { + return null; + } + + $classNames = $valueType->getObjectClassNames(); if (count($classNames) > 0) { - return TypeCombinator::union(...array_map(fn (string $classNames): Type => $this->findParentClassNameType($classNames), $classNames)); + // A `$this`/`static` value can be an instance of a subclass through late static + // binding. For a non-final class the parent class is then not pinned to the declared + // parent: a direct child's parent is the class itself, a deeper descendant's parent is + // some subclass. So the result also includes `class-string`. + $isLateStaticBound = $valueType instanceof StaticType; + + $types = []; + foreach ($classNames as $className) { + $types[] = $this->findParentClassNameType($className); + + if ( + !$isLateStaticBound + || !$this->reflectionProvider->hasClass($className) + || $this->reflectionProvider->getClass($className)->isFinal() + ) { + continue; + } + + $types[] = new GenericClassStringType(new ObjectType($className)); + } + + return TypeCombinator::union(...$types); } return null; diff --git a/tests/PHPStan/Analyser/nsrt/bug-14951.php b/tests/PHPStan/Analyser/nsrt/bug-14951.php new file mode 100644 index 0000000000..d7df8f9e4e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14951.php @@ -0,0 +1,95 @@ +|false', get_parent_class($this)); + + // The sibling functions already model late static binding, so they do not have the + // same problem: they keep the subclass possibility instead of pinning to this class. + assertType('class-string', get_called_class()); + assertType('class-string<$this(Bug14951\A)>', get_class($this)); + + // A class-string argument (e.g. self::class) names an exact class, not a runtime + // value, so it keeps the exact parent — no subclass widening. + assertType('false', get_parent_class(self::class)); + + // static::class is late static bound just like $this, so it is widened the same way. + assertType('class-string|false', get_parent_class(static::class)); + + return get_parent_class($this) === self::class; + } + +} + +class B extends A +{ + + public function parentOfThis(): void + { + assertType('\'Bug14951\\\\A\'|class-string', get_parent_class($this)); + // self::class is the exact class B, so its parent is exactly A. + assertType("'Bug14951\\\\A'", get_parent_class(self::class)); + // static::class is late static bound, matching get_parent_class($this). + assertType('\'Bug14951\\\\A\'|class-string', get_parent_class(static::class)); + } + +} + +final class FinalNoParent +{ + + public function parentOfThis(): void + { + // Final class cannot be subclassed, so the parent is exactly false. + assertType('false', get_parent_class($this)); + assertType('false', get_parent_class(static::class)); + // ... and get_called_class() is pinned to the final class. + assertType("'Bug14951\\\\FinalNoParent'", get_called_class()); + } + +} + +final class FinalWithParent extends A +{ + + public function parentOfThis(): void + { + assertType("'Bug14951\\\\A'", get_parent_class($this)); + assertType("'Bug14951\\\\A'", get_parent_class(static::class)); + } + +} + +/** @final */ +class PhpDocFinalNoParent +{ + + public function parentOfThis(): void + { + // A @final class cannot be subclassed either, so there is no subclass widening. + assertType('false', get_parent_class($this)); + assertType('false', get_parent_class(static::class)); + } + +} + +/** @final */ +class PhpDocFinalWithParent extends A +{ + + public function parentOfThis(): void + { + assertType("'Bug14951\\\\A'", get_parent_class($this)); + assertType("'Bug14951\\\\A'", get_parent_class(static::class)); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/get-parent-class.php b/tests/PHPStan/Analyser/nsrt/get-parent-class.php index 5e6c291e6e..b3caa68b93 100644 --- a/tests/PHPStan/Analyser/nsrt/get-parent-class.php +++ b/tests/PHPStan/Analyser/nsrt/get-parent-class.php @@ -10,7 +10,7 @@ class Foo public function doFoo() { assertType('false', get_parent_class()); - assertType('false', get_parent_class($this)); + assertType('class-string|false', get_parent_class($this)); assertType('class-string<$this(ParentClass\Foo)>', get_class($this)); assertType('\'ParentClass\\\\Foo\'', get_class()); } @@ -25,7 +25,7 @@ class Bar extends Foo public function doBar() { assertType('\'ParentClass\\\\Foo\'', get_parent_class()); - assertType('\'ParentClass\\\\Foo\'', get_parent_class($this)); + assertType('\'ParentClass\\\\Foo\'|class-string', get_parent_class($this)); } } @@ -45,8 +45,16 @@ trait FooTrait public function doBaz() { + // Inside a trait a late-static-bound argument is not resolved to a concrete parent. assertType('class-string|false', get_parent_class()); assertType('class-string|false', get_parent_class($this)); + assertType('class-string|false', get_parent_class(static::class)); + + // self::class is not late static bound - it is the using class, so it is already a + // constant class-string here, indistinguishable from writing Bar::class. + assertType('\'ParentClass\\\\Bar\'', self::class); + assertType('\'ParentClass\\\\Foo\'', get_parent_class(self::class)); + assertType('\'ParentClass\\\\Foo\'', get_parent_class(Bar::class)); } } diff --git a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index f8f38214d8..0d476571c8 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -347,6 +347,11 @@ public function testBug2835(): void $this->analyse([__DIR__ . '/data/bug-2835.php'], []); } + public function testBug14951(): void + { + $this->analyse([__DIR__ . '/data/bug-14951.php'], []); + } + public function testBug1860(): void { $this->analyse([__DIR__ . '/data/bug-1860.php'], [ diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14951.php b/tests/PHPStan/Rules/Comparison/data/bug-14951.php new file mode 100644 index 0000000000..6d065f17c6 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-14951.php @@ -0,0 +1,23 @@ +