diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 1c375aae070..6565f884339 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -738,7 +738,13 @@ private static function compareTypesInUnion(Type $a, Type $b): ?array } // simplify string[] | int[] to (string|int)[] - if ($a instanceof IterableType && $b instanceof IterableType) { + // - but not TKey|TValue both of iterable, that would forget the template types + if ( + $a instanceof IterableType + && $b instanceof IterableType + && !$a instanceof TemplateType + && !$b instanceof TemplateType + ) { return [ new IterableType( self::union($a->getIterableKeyType(), $b->getIterableKeyType()), @@ -1132,6 +1138,48 @@ private static function findEmptyArrayNextToOversizedArray(array $arrayTypes): ? return $hasOversizedArray ? $emptyArray : null; } + /** + * Splits the arrays of a union into the arrays of each template type and the rest. + * + * Arrays of different template types cannot be merged: `TKey|TValue` both of + * `array` is not `TKey` - nor is it a plain array, the merge would forget which + * template types the values come from. + * + * @param list $arrayTypes + * @return list> + */ + private static function groupArrayTypesByTemplateType(array $arrayTypes): array + { + $groups = []; + $templateTypes = []; + $otherArrayTypes = []; + foreach ($arrayTypes as $arrayType) { + if (!$arrayType instanceof TemplateType) { + $otherArrayTypes[] = $arrayType; + continue; + } + + foreach ($templateTypes as $i => $templateType) { + if ( + $templateType->getName() === $arrayType->getName() + && $templateType->getScope()->equals($arrayType->getScope()) + ) { + $groups[$i][] = $arrayType; + continue 2; + } + } + + $templateTypes[] = $arrayType; + $groups[] = [$arrayType]; + } + + if ($otherArrayTypes !== []) { + $groups[] = $otherArrayTypes; + } + + return $groups; + } + /** * @param list $arrayTypes * @return Type[] @@ -1142,6 +1190,16 @@ private static function processArrayTypes(array $arrayTypes): array return []; } + $arrayTypeGroups = self::groupArrayTypesByTemplateType($arrayTypes); + if (count($arrayTypeGroups) > 1) { + $processedArrayTypes = []; + foreach ($arrayTypeGroups as $arrayTypeGroup) { + $processedArrayTypes = array_merge($processedArrayTypes, self::processArrayTypes($arrayTypeGroup)); + } + + return $processedArrayTypes; + } + $emptyArray = self::findEmptyArrayNextToOversizedArray($arrayTypes); if ($emptyArray !== null) { // like reduceArrays() does when it generalizes to an oversized array: diff --git a/src/Type/UnionType.php b/src/Type/UnionType.php index 4edc065cd63..bedb6c24a00 100644 --- a/src/Type/UnionType.php +++ b/src/Type/UnionType.php @@ -264,7 +264,11 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult } if ($type instanceof IterableType) { - return $this->accepts($type->toArrayOrTraversable(), $strictTypes); + $result = $this->accepts($type->toArrayOrTraversable(), $strictTypes); + // TA|TB accepts TA, but not the array|Traversable it stands for + if ($result->yes() || !$type instanceof TemplateIterableType) { + return $result; + } } foreach (self::EQUAL_UNION_CLASSES as $baseClass => $classes) { @@ -1407,14 +1411,42 @@ public function inferTemplateTypes(Type $receivedType): TemplateTypeMap $receivedType = TypeCombinator::union(...$remainingReceivedTypes); } + $nakedTemplateTypes = []; + $otherTypes = []; foreach ($this->types as $type) { if (self::getNakedTemplateType($type) !== null) { + $nakedTemplateTypes[] = $type; continue; } + $otherTypes[] = $type; $types = $types->union($type->inferTemplateTypes($receivedType)); } if (!$types->isEmpty()) { + if ($nakedTemplateTypes === []) { + return $types; + } + + // T|Box receiving stdClass|Box: what the other members + // infer nothing from is what the naked template types receive + $unexplainedReceivedTypes = []; + foreach ($remainingReceivedTypes as $remainingReceivedType) { + foreach ($otherTypes as $otherType) { + if (!$otherType->inferTemplateTypes($remainingReceivedType)->isEmpty()) { + continue 2; + } + } + $unexplainedReceivedTypes[] = $remainingReceivedType; + } + if ($unexplainedReceivedTypes === []) { + return $types; + } + + $unexplainedReceivedType = TypeCombinator::union(...$unexplainedReceivedTypes); + foreach ($nakedTemplateTypes as $nakedTemplateType) { + $types = $types->union($nakedTemplateType->inferTemplateTypes($unexplainedReceivedType)); + } + return $types; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-15431.php b/tests/PHPStan/Analyser/nsrt/bug-15431.php new file mode 100644 index 00000000000..4685412adec --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15431.php @@ -0,0 +1,122 @@ + + */ +class Box +{ +} + +/** + * @template TKey of object|array + * @template TValue of object|array + */ +class Map +{ + + /** + * @template T of TKey|TValue + * @param (T is object ? T : Box) $value + * @return T + */ + protected function unbox(object $value) + { + assertType('Bug15431Nsrt\Box (class Bug15431Nsrt\Map, argument)|TValue of array (class Bug15431Nsrt\Map, argument) (method Bug15431Nsrt\Map::unbox(), argument)>|(T of TKey of object (class Bug15431Nsrt\Map, argument)|TValue of object (class Bug15431Nsrt\Map, argument) (method Bug15431Nsrt\Map::unbox(), argument))', $value); + } + + /** + * @param (TValue is object ? TValue : Box) $v + * @param Box> $b + * @param TValue&object $o + */ + public function doFoo($v, $b, $o): void + { + assertType('TValue of array (class Bug15431Nsrt\Map, argument)|TValue of object (class Bug15431Nsrt\Map, argument)', $this->unbox($v)); + assertType('TValue of array (class Bug15431Nsrt\Map, argument)', $this->unbox($b)); + assertType('TValue of object (class Bug15431Nsrt\Map, argument)', $this->unbox($o)); + } + + /** + * @param (TKey&array)|(TValue&array) $arrays + * @param (TKey&list)|(TValue&list) $lists + */ + public function doBar($arrays, $lists): void + { + assertType('TKey of array (class Bug15431Nsrt\Map, argument)|TValue of array (class Bug15431Nsrt\Map, argument)', $arrays); + assertType('TKey of list (class Bug15431Nsrt\Map, argument)|TValue of list (class Bug15431Nsrt\Map, argument)', $lists); + } + +} + +/** + * @template TA of array{a: int} + * @template TB of array{a: int} + * @template TC of array + * @template TD of list + * @param TA|TB $ab + * @param TC|TD $cd + * @param TA|array $mixed + */ +function constantArrays($ab, $cd, $mixed): void +{ + assertType('TA of array{a: int} (function Bug15431Nsrt\constantArrays(), argument)|TB of array{a: int} (function Bug15431Nsrt\constantArrays(), argument)', $ab); + assertType('TC of array (function Bug15431Nsrt\constantArrays(), argument)|TD of list (function Bug15431Nsrt\constantArrays(), argument)', $cd); + assertType('array|TA of array{a: int} (function Bug15431Nsrt\constantArrays(), argument)', $mixed); +} + +/** + * @template T of object|array + * @param T|Box $value + * @return T + */ +function unboxNaked($value) +{ +} + +/** + * @template T of object|array + * @param (T is object ? T : Box) $value + * @return T + */ +function unboxConditional(object $value) +{ +} + +/** + * @template T + * @param T|array $value + * @return T + */ +function unwrapArray($value) +{ +} + +/** + * @param stdClass|Box $a + * @param int|array $b + * @param array $c + */ +function doBaz($a, $b, $c): void +{ + assertType('array{int}|stdClass', unboxNaked($a)); + assertType('array{int}|stdClass', unboxConditional($a)); + assertType('int|string', unwrapArray($b)); + assertType('string', unwrapArray($c)); +} + +/** + * @template TA of iterable + * @template TB of iterable + * @param TA $a + * @param TB $b + */ +function iterables($a, $b): void +{ + $c = rand(0, 1) === 1 ? $a : $b; + assertType('TA of iterable (function Bug15431Nsrt\iterables(), argument)|TB of iterable (function Bug15431Nsrt\iterables(), argument)', $c); +} diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 8e6c4084026..4be19cfa7a6 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4616,4 +4616,12 @@ public function testDeadCatch(): void ]); } + public function testBug15431(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-15431.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index 260fde1475f..ae17a8f4e62 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -1419,4 +1419,22 @@ public function testBug13325(): void $this->analyse([__DIR__ . '/data/bug-13325.php'], []); } + public function testBug15431(): void + { + $this->analyse([__DIR__ . '/data/bug-15431.php'], [ + [ + 'Method Bug15431\\WeakAnalysingMap::unboxValue() should return T of (TKey of array|object)|(TValue of array|object) but returns array|(T of (TKey of object)|TValue of object).', + 30, + '• Type #1 from the union: Type array is not always the same as TKey. It breaks the contract for some argument types, typically subtypes. +• Type #2 from the union: Type array is not always the same as TValue. It breaks the contract for some argument types, typically subtypes. +• Type array is not always the same as T. It breaks the contract for some argument types, typically subtypes.', + ], + ]); + } + + public function testBug15431Iterable(): void + { + $this->analyse([__DIR__ . '/data/bug-15431-iterable.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-15431-iterable.php b/tests/PHPStan/Rules/Methods/data/bug-15431-iterable.php new file mode 100644 index 00000000000..8d585feada3 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-15431-iterable.php @@ -0,0 +1,47 @@ + + * @template TValue of object|array + */ +class Map +{ + + /** + * @param TKey&iterable $a + * @param TValue&array $b + * @param (TValue&array)|(TKey&iterable) $c + * @return TKey|TValue + */ + public function doFoo($a, $b, $c) + { + if (rand(0, 1) === 1) { + return $a; + } + if (rand(0, 1) === 1) { + return $b; + } + + return $c; + } + +} + +class Foo +{ + + /** + * @template TA of iterable + * @template TB of iterable + * @param TA $a + * @param TB $b + * @return TA|TB + */ + public function doFoo($a, $b) + { + return rand(0, 1) === 1 ? $a : $b; + } + +} diff --git a/tests/PHPStan/Rules/Methods/data/bug-15431.php b/tests/PHPStan/Rules/Methods/data/bug-15431.php new file mode 100644 index 00000000000..118482a5cbf --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-15431.php @@ -0,0 +1,44 @@ + + */ +class WeakAnalysingBoxedArray +{ + /** @var T */ + public array $value = []; +} + +/** + * @template TKey of object|array + * @template TValue of object|array + * @template TOwner of object + */ +class WeakAnalysingMap +{ + /** + * @template T of TKey|TValue + * + * @param (T is object ? T : WeakAnalysingBoxedArray) $value + * + * @return T + */ + protected function unboxValue(object $value) + { + return $value instanceof WeakAnalysingBoxedArray + ? $value->value + : $value; + } + + /** + * @param (TValue is object ? TValue : WeakAnalysingBoxedArray) $v + * + * @return TValue|null + */ + public function unboxValueOnly($v) + { + return $this->unboxValue($v); + } +} diff --git a/turbo-ext/src/TypeCombinator.cpp b/turbo-ext/src/TypeCombinator.cpp index f6c1afd51e3..090a969cdbd 100644 --- a/turbo-ext/src/TypeCombinator.cpp +++ b/turbo-ext/src/TypeCombinator.cpp @@ -1379,8 +1379,17 @@ class TypeCombinator if (bConstantArray == PT_TRI_YES) return 0; } - /* simplify string[] | int[] to (string|int)[] */ - if (isInstance(a, pt_ce_iterable_type) && isInstance(b, pt_ce_iterable_type)) { + /* simplify string[] | int[] to (string|int)[] - but not TKey|TValue + * both of iterable, that would forget the template types */ + bool iterables = isInstance(a, pt_ce_iterable_type) && isInstance(b, pt_ce_iterable_type); + if (iterables) { + int aTemplate = isInstanceMap(a, PT_CLASS_TEMPLATE_TYPE); + if (UNEXPECTED(aTemplate < 0)) return -1; + int bTemplate = aTemplate ? 0 : isInstanceMap(b, PT_CLASS_TEMPLATE_TYPE); + if (UNEXPECTED(bTemplate < 0)) return -1; + iterables = !aTemplate && !bTemplate; + } + if (iterables) { zv::Val aKey = call(a, PT_LC("getiterablekeytype")); zv::Val bKey = call(b, PT_LC("getiterablekeytype")); if (UNEXPECTED(aKey.isUndef() || bKey.isUndef())) return -1; @@ -1925,11 +1934,70 @@ class TypeCombinator return hasOversizedArray ? std::move(emptyArray) : zv::Val::null(); } + /* private static groupArrayTypesByTemplateType(list $arrayTypes): list> + * — the arrays of each template type, then the rest; false = pending + * exception */ + static bool groupArrayTypesByTemplateType(TypeList &arrayTypes, std::vector &groups) + { + TypeList templateNames; + TypeList templateScopes; + TypeList otherArrayTypes; + for (zv::Val &arrayType : arrayTypes) { + int isTemplate = isInstanceMap(arrayType.raw(), PT_CLASS_TEMPLATE_TYPE); + if (UNEXPECTED(isTemplate < 0)) return false; + if (!isTemplate) { + otherArrayTypes.push_back(zv::Val::copyOf(arrayType.ref())); + continue; + } + + zv::Val name = call(arrayType.raw(), PT_LC("getname")); + if (UNEXPECTED(name.isUndef())) return false; + zv::Val scope = call(arrayType.raw(), PT_LC("getscope")); + if (UNEXPECTED(scope.isUndef())) return false; + bool grouped = false; + for (size_t i = 0; i < templateNames.size(); i++) { + if (!zend_is_identical(templateNames[i].raw(), name.raw())) continue; + int sameScope = callBool(templateScopes[i].raw(), PT_LC("equals"), 1, scope.raw()); + if (UNEXPECTED(sameScope < 0)) return false; + if (!sameScope) continue; + groups[i].push_back(zv::Val::copyOf(arrayType.ref())); + grouped = true; + break; + } + if (grouped) continue; + + templateNames.push_back(std::move(name)); + templateScopes.push_back(std::move(scope)); + groups.emplace_back(); + groups.back().push_back(zv::Val::copyOf(arrayType.ref())); + } + + if (!otherArrayTypes.empty()) groups.push_back(std::move(otherArrayTypes)); + + return true; + } + /* private static processArrayTypes(list $arrayTypes): Type[] */ static zv::Val processArrayTypes(TypeList &arrayTypes) { if (arrayTypes.empty()) return zv::Val(zv::Arr::create(0)); + { + std::vector arrayTypeGroups; + if (UNEXPECTED(!groupArrayTypesByTemplateType(arrayTypes, arrayTypeGroups))) return zv::Val(); + if (arrayTypeGroups.size() > 1) { + zv::Arr result = zv::Arr::create((uint32_t) arrayTypes.size()); + for (TypeList &arrayTypeGroup : arrayTypeGroups) { + zv::Val processed = processArrayTypes(arrayTypeGroup); + PT_FAIL_IF_UNDEF(processed); + for (zv::ArrayEntry entry : zv::ArrRef(processed.raw())) { + result.push(zv::Ref(entry.value().raw())); + } + } + return zv::Val(std::move(result)); + } + } + { zv::Val emptyArray = findEmptyArrayNextToOversizedArray(arrayTypes); PT_FAIL_IF_UNDEF(emptyArray); diff --git a/turbo-ext/src/UnionType.cpp b/turbo-ext/src/UnionType.cpp index ac355c53dfc..3fdb0bf82ea 100644 --- a/turbo-ext/src/UnionType.cpp +++ b/turbo-ext/src/UnionType.cpp @@ -34,6 +34,8 @@ #include "TypeTraits.h" #include "generated/UnionType.h" +#include + namespace slots = ptdecl::UnionType::slot; namespace sigs = ptdecl::UnionType::sig; @@ -612,7 +614,15 @@ class UnionType if (isIterable) { zv::Val arrayOrTraversable = callType(Z_OBJ_P(type), PT_LC("toarrayortraversable"), 0, NULL); if (UNEXPECTED(arrayOrTraversable.isUndef())) return zv::Val(); - return thisAccepts(arrayOrTraversable.raw(), strictTypes); + zv::Val iterableResult = thisAccepts(arrayOrTraversable.raw(), strictTypes); + if (UNEXPECTED(iterableResult.isUndef())) return zv::Val(); + // TA|TB accepts TA, but not the array|Traversable it stands for + zend_long iterableResultValue = pt_type_result_trinary(iterableResult.raw()); + if (UNEXPECTED(iterableResultValue < 0)) return zv::Val(); + if (iterableResultValue == PT_TRI_YES) return iterableResult; + bool isTemplateIterable; + if (UNEXPECTED(!isInstance(type, pt_ce_template_iterable_type, isTemplateIterable))) return zv::Val(); + if (!isTemplateIterable) return iterableResult; } /* foreach (self::EQUAL_UNION_CLASSES as $baseClass => $classes) */ @@ -1677,8 +1687,9 @@ class UnionType /* inferTemplateTypes(): a received member some member definitely accepts * is absorbed by it (never excepted), only the rest is inferred against - * the non-naked-template members, then — when that gave nothing — - * against every member; UNDEF = pending exception */ + * the non-naked-template members — what those infer nothing from against + * the naked ones — then, when that gave nothing, against every member; + * UNDEF = pending exception */ zv::Val inferTemplateTypes(zval *receivedTypeArg) const { zv::Val receivedType = zv::Val::copyOf(zv::Ref(receivedTypeArg)); @@ -1738,11 +1749,18 @@ class UnionType if (UNEXPECTED(receivedType.isUndef())) return zv::Val(); } + // borrowed from ownTypesCopy, which outlives them + std::vector nakedTemplateTypes; + std::vector otherTypes; for (zv::ArrayEntry entry : zv::ArrRef(ownTypesCopy.raw())) { zend_object *type = entry.value().deref().asObject(); zv::Val naked = getNakedTemplateType(entry.value().deref().raw()); if (UNEXPECTED(naked.isUndef())) return zv::Val(); - if (!zv::Ref(naked.raw()).isNull()) continue; + if (!zv::Ref(naked.raw()).isNull()) { + nakedTemplateTypes.push_back(type); + continue; + } + otherTypes.push_back(type); zv::Val inferred = pt_type_call(type, PT_LC("infertemplatetypes"), 1, receivedType.raw()); if (UNEXPECTED(inferred.isUndef())) return zv::Val(); types = mapUnion(std::move(types), inferred.raw()); @@ -1751,7 +1769,42 @@ class UnionType int empty = pt_type_call_is_true(Z_OBJ_P(types.raw()), PT_LC("isempty"), 0, NULL); if (UNEXPECTED(empty < 0)) return zv::Val(); - if (empty == 0) return types; + if (empty == 0) { + if (nakedTemplateTypes.empty()) return types; + + // T|Box receiving stdClass|Box: what the other members + // infer nothing from is what the naked template types receive + zv::Arr unexplainedReceivedTypes = zv::Arr::create(0); + for (zv::ArrayEntry received : zv::ArrRef(remainingReceivedTypes.raw())) { + zval *remainingReceivedType = received.value().deref().raw(); + bool explained = false; + for (zend_object *otherType : otherTypes) { + zv::Val inferred = pt_type_call(otherType, PT_LC("infertemplatetypes"), 1, remainingReceivedType); + if (UNEXPECTED(inferred.isUndef())) return zv::Val(); + int inferredEmpty = pt_type_call_is_true(Z_OBJ_P(inferred.raw()), PT_LC("isempty"), 0, NULL); + if (UNEXPECTED(inferredEmpty < 0)) return zv::Val(); + if (inferredEmpty == 0) { + explained = true; + break; + } + } + if (!explained) { + unexplainedReceivedTypes.push(zv::Ref(remainingReceivedType)); + } + } + if (zend_hash_num_elements(unexplainedReceivedTypes.table()) == 0) return types; + + zv::Val unexplainedReceivedType = combinatorUnion(unexplainedReceivedTypes.table()); + if (UNEXPECTED(unexplainedReceivedType.isUndef())) return zv::Val(); + for (zend_object *nakedTemplateType : nakedTemplateTypes) { + zv::Val inferred = pt_type_call(nakedTemplateType, PT_LC("infertemplatetypes"), 1, unexplainedReceivedType.raw()); + if (UNEXPECTED(inferred.isUndef())) return zv::Val(); + types = mapUnion(std::move(types), inferred.raw()); + if (UNEXPECTED(types.isUndef())) return zv::Val(); + } + + return types; + } for (zv::ArrayEntry entry : zv::ArrRef(ownTypesCopy.raw())) { zv::Val inferred = pt_type_call(entry.value().deref().asObject(), PT_LC("infertemplatetypes"), 1, receivedType.raw());