diff --git a/src/Type/Generic/TemplateTypeHelper.php b/src/Type/Generic/TemplateTypeHelper.php index 41028207124..1c2e8dae608 100644 --- a/src/Type/Generic/TemplateTypeHelper.php +++ b/src/Type/Generic/TemplateTypeHelper.php @@ -8,6 +8,7 @@ use PHPStan\Type\NonAcceptingNeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeTraverser; +use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; final class TemplateTypeHelper @@ -146,16 +147,75 @@ public static function toArgument(Type $type): Type public static function generalizeInferredTemplateType(TemplateType $templateType, Type $type): Type { - if (!$templateType->getVariance()->covariant()) { - $isArrayKey = $templateType->getBound()->describe(VerbosityLevel::precise()) === '(int|string)'; - if ($type->isScalar()->yes() && $isArrayKey) { - $type = $type->generalize(GeneralizePrecision::templateArgument()); - } elseif ($type->isConstantValue()->yes() && (!$templateType->getBound()->isScalar()->yes() || $isArrayKey)) { - $type = $type->generalize(GeneralizePrecision::templateArgument()); + if ($templateType->getVariance()->covariant()) { + return $type; + } + + $bound = $templateType->getBound(); + $isArrayKey = $bound->describe(VerbosityLevel::precise()) === '(int|string)'; + if ($bound->isScalar()->yes() && !$isArrayKey) { + return $type; + } + + if ($type->isScalar()->yes() && $isArrayKey) { + $type = $type->generalize(GeneralizePrecision::templateArgument()); + } elseif ($type->isConstantValue()->yes()) { + $type = $type->generalize(GeneralizePrecision::templateArgument()); + } + + return self::widenToBoundType($bound, $type); + } + + /** + * An invariant template type argument that merely refines one of the bound's + * alternatives is unusable - Foo is not accepted by Foo>. + * Such a type is widened to the bound alternative it refines. + */ + private static function widenToBoundType(Type $bound, Type $type): Type + { + $boundTypes = $bound instanceof UnionType ? $bound->getTypes() : [$bound]; + + $widenedType = null; + foreach ($boundTypes as $boundType) { + if (!self::onlyRefinesType($boundType, $type)) { + continue; } + + if ($type->isSuperTypeOf($boundType)->yes()) { + // the type is already as general as the bound alternative + return $type; + } + + if ($widenedType !== null) { + return $type; + } + + $widenedType = $boundType; + } + + return $widenedType ?? $type; + } + + /** + * Does $type say the same thing as $superType, only with more precision + * (accessory types, array shapes, literal values)? + */ + private static function onlyRefinesType(Type $superType, Type $type): bool + { + if (!$superType->isSuperTypeOf($type)->yes()) { + return false; + } + + // arrays refine the bound with their shape, so they're compared key type to key type + // and value type to value type instead. Object types are excluded to not lose the class. + // An empty array has never key and value types, which say more than any bound does, + // so array{} is never widened - the class author may well have written Foo. + if ($type->isArray()->yes() && $superType->isIterable()->yes() && $superType->getObjectClassNames() === []) { + return self::onlyRefinesType($superType->getIterableKeyType(), $type->getIterableKeyType()) + && self::onlyRefinesType($superType->getIterableValueType(), $type->getIterableValueType()); } - return $type; + return $type->generalize(GeneralizePrecision::lessSpecific())->isSuperTypeOf($superType)->yes(); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-15027.php b/tests/PHPStan/Analyser/nsrt/bug-15027.php new file mode 100644 index 00000000000..685581cec1e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15027.php @@ -0,0 +1,225 @@ += 8.0 + +namespace Bug15027; + +use function PHPStan\Testing\assertType; + +/** + * @template Value of string|list + */ +final class LanguageProperty +{ + /** @var Value */ + public $value; + + /** + * @param Value $value + */ + public function __construct($value) + { + $this->value = $value; + } + + /** + * @template T of string|list + * @param T $value + * @return self + */ + public static function create($value): self + { + return new self($value); + } +} + +/** + * @template T + */ +final class Unbounded +{ + /** @param T $value */ + public function __construct(public $value) + { + } +} + +/** + * @template T of array + */ +final class BoundToArray +{ + /** @param T $value */ + public function __construct(public $value) + { + } +} + +/** + * @template TData of array = array + */ +final class DefaultedArgument +{ + /** @param TData $data */ + public function __construct(public array $data = [], public ?string $alias = null) + { + } +} + +/** + * @template T of array + */ +final class BoundToMixedMap +{ + /** @param T $value */ + public function __construct(public $value) + { + } +} + +/** + * @template T of array + */ +final class BoundToStringMap +{ + /** @param T $value */ + public function __construct(public $value) + { + } +} + +/** + * @template T of list + */ +final class BoundToList +{ + /** @param T $value */ + public function __construct(public $value) + { + } +} + +/** + * @template T of string|list> + */ +final class BoundToNestedList +{ + /** @param T $value */ + public function __construct(public $value) + { + } +} + +/** + * @template T of iterable + */ +final class BoundToIterable +{ + /** @param T $value */ + public function __construct(public $value) + { + } +} + +/** + * @template T of int|list + */ +final class BoundToIntOrList +{ + /** @param T $value */ + public function __construct(public $value) + { + } +} + +/** + * @template T of \DateTimeInterface|string + */ +final class BoundToObjectOrString +{ + /** @param T $value */ + public function __construct(public $value) + { + } +} + +/** + * @template T of \Traversable + */ +final class BoundToTraversable +{ + /** @param T $value */ + public function __construct(public $value) + { + } +} + +function doFoo(): string +{ + return 'hallo'; +} + +/** + * @template T of string|list + * @param T $value + * @return LanguageProperty + */ +function createLanguageProperty($value): LanguageProperty +{ + return new LanguageProperty($value); +} + +/** + * @param non-empty-list $nonEmptyList + * @param numeric-string $numericString + * @param int<1, 5> $intRange + * @param array $stringMap + */ +function test( + string $s, + array $nonEmptyList, + string $numericString, + int $intRange, + array $stringMap, + \DateTimeImmutable $date, + \ArrayIterator $iterator +): void +{ + assertType('Bug15027\LanguageProperty>', new LanguageProperty(['abc'])); + assertType('Bug15027\LanguageProperty>', new LanguageProperty([$s])); + assertType('Bug15027\LanguageProperty>', new LanguageProperty($nonEmptyList)); + assertType('Bug15027\LanguageProperty', new LanguageProperty('abc')); + assertType('Bug15027\LanguageProperty', new LanguageProperty('abc' . doFoo())); + assertType('Bug15027\LanguageProperty', new LanguageProperty($numericString)); + + // the same widening applies when the generic type is created by a factory + assertType('Bug15027\LanguageProperty>', LanguageProperty::create(['abc'])); + assertType('Bug15027\LanguageProperty', LanguageProperty::create('abc' . doFoo())); + assertType('Bug15027\LanguageProperty>', createLanguageProperty(['abc'])); + assertType('Bug15027\LanguageProperty', createLanguageProperty('abc' . doFoo())); + + assertType('Bug15027\BoundToList>', new BoundToList(['abc'])); + assertType('Bug15027\BoundToNestedList>>', new BoundToNestedList([['abc']])); + assertType('Bug15027\BoundToIterable>', new BoundToIterable(['abc'])); + assertType('Bug15027\BoundToIntOrList', new BoundToIntOrList($intRange)); + assertType('Bug15027\BoundToIntOrList>', new BoundToIntOrList([1, 2])); + assertType('Bug15027\BoundToStringMap>', new BoundToStringMap(['a' => 'b'])); + assertType('Bug15027\BoundToStringMap>', new BoundToStringMap($stringMap)); + + // bounds that say more than the argument does keep the precise type + assertType('Bug15027\Unbounded', new Unbounded(['abc'])); + assertType('Bug15027\Unbounded', new Unbounded('abc')); + assertType('Bug15027\BoundToArray', new BoundToArray(['abc'])); + assertType('Bug15027\BoundToMixedMap', new BoundToMixedMap(['foo' => 'abc', 'bar' => 1])); + + // an empty array says more than any array bound does, so it is not widened either - + // both when written explicitly and when it comes from an optional parameter's default value + assertType('Bug15027\LanguageProperty', new LanguageProperty([])); + assertType('Bug15027\BoundToList', new BoundToList([])); + assertType('Bug15027\DefaultedArgument', new DefaultedArgument()); + assertType('Bug15027\DefaultedArgument', new DefaultedArgument([])); + assertType('Bug15027\DefaultedArgument', new DefaultedArgument(['a' => 1])); + + // class precision is not lost + assertType('Bug15027\BoundToObjectOrString', new BoundToObjectOrString($date)); + assertType('Bug15027\BoundToObjectOrString', new BoundToObjectOrString('abc' . doFoo())); + assertType('Bug15027\BoundToTraversable', new BoundToTraversable($iterator)); +} diff --git a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php index 4251d2d2d3b..022149154fd 100644 --- a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php @@ -1067,4 +1067,9 @@ public function testBug10749(): void $this->analyse([__DIR__ . '/data/bug-10749.php'], []); } + public function testBug15027(): void + { + $this->analyse([__DIR__ . '/data/bug-15027.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-15027.php b/tests/PHPStan/Rules/Properties/data/bug-15027.php new file mode 100644 index 00000000000..f5d30a20321 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-15027.php @@ -0,0 +1,43 @@ + + */ +final class LanguageProperty +{ + /** @var Value */ + public $value; + + /** + * @param Value $value + */ + public function __construct($value) + { + $this->value = $value; + } +} + +class Holder +{ + + /** @var LanguageProperty>|null */ + public $lp = null; + + /** @var LanguageProperty|null */ + public static $lps = null; + +} + +function doFoo(): string +{ + return 'hallo'; +} + +function test(): void +{ + $h = new Holder(); + $h->lp = new LanguageProperty(['abc']); + Holder::$lps = new LanguageProperty('abc' . doFoo()); +}