Skip to content

Commit 5c97adf

Browse files
committed
Keep union members of different template types apart in TypeCombinator and infer naked template types from what other union members leave unexplained
- TypeCombinator::processArrayTypes(): group template-typed arrays by name and scope and process each group (and the non-template arrays) on its own. Previously all-template-array unions were merged under the first template's name, so `TKey of array|TValue of array` became `TKey of array` (and `TA of array{a: int}|TB of array{a: int}` became `TB ...`). Template `list`s and template arrays mixed with plain arrays no longer lose the template either. - TypeCombinator::compareTypesInUnion(): do not merge `iterable` members when either is a template type (`TA of iterable|TB of iterable` stayed `iterable`). - UnionType::accepts(): when the `array|Traversable` reading of a TemplateIterableType is not accepted, check the template itself member by member, as isSuperTypeOf() already does (`TA|TB` accepts `TA`). - UnionType::inferTemplateTypes(): received members that no non-naked member infers anything from are inferred against the naked template members, so `T|Box<T>` receiving `stdClass|Box<array{int}>` gives `T = array{int}|stdClass` instead of dropping `stdClass`. - Ported every change to turbo-ext (TypeCombinator.cpp, UnionType.cpp). - Probed non-array template bounds (int, int range, string, float, bool, object, class, mixed, constant scalars, scalar unions): these already stayed apart. Together these let `@template T of TKey|TValue` with `(T is object ? T : Box<T>)` resolve T again instead of reporting "Unable to resolve the template type T".
1 parent acd0511 commit 5c97adf

9 files changed

Lines changed: 459 additions & 9 deletions

File tree

‎src/Type/TypeCombinator.php‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -738,7 +738,13 @@ private static function compareTypesInUnion(Type $a, Type $b): ?array
738738
}
739739

740740
// simplify string[] | int[] to (string|int)[]
741-
if ($a instanceof IterableType && $b instanceof IterableType) {
741+
// - but not TKey|TValue both of iterable, that would forget the template types
742+
if (
743+
$a instanceof IterableType
744+
&& $b instanceof IterableType
745+
&& !$a instanceof TemplateType
746+
&& !$b instanceof TemplateType
747+
) {
742748
return [
743749
new IterableType(
744750
self::union($a->getIterableKeyType(), $b->getIterableKeyType()),
@@ -1132,6 +1138,48 @@ private static function findEmptyArrayNextToOversizedArray(array $arrayTypes): ?
11321138
return $hasOversizedArray ? $emptyArray : null;
11331139
}
11341140

1141+
/**
1142+
* Splits the arrays of a union into the arrays of each template type and the rest.
1143+
*
1144+
* Arrays of different template types cannot be merged: `TKey|TValue` both of
1145+
* `array` is not `TKey` - nor is it a plain array, the merge would forget which
1146+
* template types the values come from.
1147+
*
1148+
* @param list<Type> $arrayTypes
1149+
* @return list<list<Type>>
1150+
*/
1151+
private static function groupArrayTypesByTemplateType(array $arrayTypes): array
1152+
{
1153+
$groups = [];
1154+
$templateTypes = [];
1155+
$otherArrayTypes = [];
1156+
foreach ($arrayTypes as $arrayType) {
1157+
if (!$arrayType instanceof TemplateType) {
1158+
$otherArrayTypes[] = $arrayType;
1159+
continue;
1160+
}
1161+
1162+
foreach ($templateTypes as $i => $templateType) {
1163+
if (
1164+
$templateType->getName() === $arrayType->getName()
1165+
&& $templateType->getScope()->equals($arrayType->getScope())
1166+
) {
1167+
$groups[$i][] = $arrayType;
1168+
continue 2;
1169+
}
1170+
}
1171+
1172+
$templateTypes[] = $arrayType;
1173+
$groups[] = [$arrayType];
1174+
}
1175+
1176+
if ($otherArrayTypes !== []) {
1177+
$groups[] = $otherArrayTypes;
1178+
}
1179+
1180+
return $groups;
1181+
}
1182+
11351183
/**
11361184
* @param list<Type> $arrayTypes
11371185
* @return Type[]
@@ -1142,6 +1190,16 @@ private static function processArrayTypes(array $arrayTypes): array
11421190
return [];
11431191
}
11441192

1193+
$arrayTypeGroups = self::groupArrayTypesByTemplateType($arrayTypes);
1194+
if (count($arrayTypeGroups) > 1) {
1195+
$processedArrayTypes = [];
1196+
foreach ($arrayTypeGroups as $arrayTypeGroup) {
1197+
$processedArrayTypes = array_merge($processedArrayTypes, self::processArrayTypes($arrayTypeGroup));
1198+
}
1199+
1200+
return $processedArrayTypes;
1201+
}
1202+
11451203
$emptyArray = self::findEmptyArrayNextToOversizedArray($arrayTypes);
11461204
if ($emptyArray !== null) {
11471205
// like reduceArrays() does when it generalizes to an oversized array:

‎src/Type/UnionType.php‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,11 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult
264264
}
265265

266266
if ($type instanceof IterableType) {
267-
return $this->accepts($type->toArrayOrTraversable(), $strictTypes);
267+
$result = $this->accepts($type->toArrayOrTraversable(), $strictTypes);
268+
// TA|TB accepts TA, but not the array|Traversable it stands for
269+
if ($result->yes() || !$type instanceof TemplateIterableType) {
270+
return $result;
271+
}
268272
}
269273

270274
foreach (self::EQUAL_UNION_CLASSES as $baseClass => $classes) {
@@ -1407,14 +1411,42 @@ public function inferTemplateTypes(Type $receivedType): TemplateTypeMap
14071411
$receivedType = TypeCombinator::union(...$remainingReceivedTypes);
14081412
}
14091413

1414+
$nakedTemplateTypes = [];
1415+
$otherTypes = [];
14101416
foreach ($this->types as $type) {
14111417
if (self::getNakedTemplateType($type) !== null) {
1418+
$nakedTemplateTypes[] = $type;
14121419
continue;
14131420
}
1421+
$otherTypes[] = $type;
14141422
$types = $types->union($type->inferTemplateTypes($receivedType));
14151423
}
14161424

14171425
if (!$types->isEmpty()) {
1426+
if ($nakedTemplateTypes === []) {
1427+
return $types;
1428+
}
1429+
1430+
// T|Box<T> receiving stdClass|Box<int>: what the other members
1431+
// infer nothing from is what the naked template types receive
1432+
$unexplainedReceivedTypes = [];
1433+
foreach ($remainingReceivedTypes as $remainingReceivedType) {
1434+
foreach ($otherTypes as $otherType) {
1435+
if (!$otherType->inferTemplateTypes($remainingReceivedType)->isEmpty()) {
1436+
continue 2;
1437+
}
1438+
}
1439+
$unexplainedReceivedTypes[] = $remainingReceivedType;
1440+
}
1441+
if ($unexplainedReceivedTypes === []) {
1442+
return $types;
1443+
}
1444+
1445+
$unexplainedReceivedType = TypeCombinator::union(...$unexplainedReceivedTypes);
1446+
foreach ($nakedTemplateTypes as $nakedTemplateType) {
1447+
$types = $types->union($nakedTemplateType->inferTemplateTypes($unexplainedReceivedType));
1448+
}
1449+
14181450
return $types;
14191451
}
14201452

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug15431Nsrt;
4+
5+
use stdClass;
6+
use function PHPStan\Testing\assertType;
7+
8+
/**
9+
* @template T of array<mixed>
10+
*/
11+
class Box
12+
{
13+
}
14+
15+
/**
16+
* @template TKey of object|array<mixed>
17+
* @template TValue of object|array<mixed>
18+
*/
19+
class Map
20+
{
21+
22+
/**
23+
* @template T of TKey|TValue
24+
* @param (T is object ? T : Box<T>) $value
25+
* @return T
26+
*/
27+
protected function unbox(object $value)
28+
{
29+
assertType('Bug15431Nsrt\Box<T of TKey of array<mixed> (class Bug15431Nsrt\Map, argument)|TValue of array<mixed> (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);
30+
}
31+
32+
/**
33+
* @param (TValue is object ? TValue : Box<TValue>) $v
34+
* @param Box<TValue&array<mixed>> $b
35+
* @param TValue&object $o
36+
*/
37+
public function doFoo($v, $b, $o): void
38+
{
39+
assertType('TValue of array<mixed> (class Bug15431Nsrt\Map, argument)|TValue of object (class Bug15431Nsrt\Map, argument)', $this->unbox($v));
40+
assertType('TValue of array<mixed> (class Bug15431Nsrt\Map, argument)', $this->unbox($b));
41+
assertType('TValue of object (class Bug15431Nsrt\Map, argument)', $this->unbox($o));
42+
}
43+
44+
/**
45+
* @param (TKey&array<mixed>)|(TValue&array<mixed>) $arrays
46+
* @param (TKey&list<mixed>)|(TValue&list<mixed>) $lists
47+
*/
48+
public function doBar($arrays, $lists): void
49+
{
50+
assertType('TKey of array<mixed> (class Bug15431Nsrt\Map, argument)|TValue of array<mixed> (class Bug15431Nsrt\Map, argument)', $arrays);
51+
assertType('TKey of list<mixed> (class Bug15431Nsrt\Map, argument)|TValue of list<mixed> (class Bug15431Nsrt\Map, argument)', $lists);
52+
}
53+
54+
}
55+
56+
/**
57+
* @template TA of array{a: int}
58+
* @template TB of array{a: int}
59+
* @template TC of array<string, int>
60+
* @template TD of list<int>
61+
* @param TA|TB $ab
62+
* @param TC|TD $cd
63+
* @param TA|array<int, string> $mixed
64+
*/
65+
function constantArrays($ab, $cd, $mixed): void
66+
{
67+
assertType('TA of array{a: int} (function Bug15431Nsrt\constantArrays(), argument)|TB of array{a: int} (function Bug15431Nsrt\constantArrays(), argument)', $ab);
68+
assertType('TC of array<string, int> (function Bug15431Nsrt\constantArrays(), argument)|TD of list<int> (function Bug15431Nsrt\constantArrays(), argument)', $cd);
69+
assertType('array<int, string>|TA of array{a: int} (function Bug15431Nsrt\constantArrays(), argument)', $mixed);
70+
}
71+
72+
/**
73+
* @template T of object|array<mixed>
74+
* @param T|Box<T> $value
75+
* @return T
76+
*/
77+
function unboxNaked($value)
78+
{
79+
}
80+
81+
/**
82+
* @template T of object|array<mixed>
83+
* @param (T is object ? T : Box<T>) $value
84+
* @return T
85+
*/
86+
function unboxConditional(object $value)
87+
{
88+
}
89+
90+
/**
91+
* @template T
92+
* @param T|array<T> $value
93+
* @return T
94+
*/
95+
function unwrapArray($value)
96+
{
97+
}
98+
99+
/**
100+
* @param stdClass|Box<array{int}> $a
101+
* @param int|array<string> $b
102+
* @param array<string> $c
103+
*/
104+
function doBaz($a, $b, $c): void
105+
{
106+
assertType('array{int}|stdClass', unboxNaked($a));
107+
assertType('array{int}|stdClass', unboxConditional($a));
108+
assertType('int|string', unwrapArray($b));
109+
assertType('string', unwrapArray($c));
110+
}
111+
112+
/**
113+
* @template TA of iterable<mixed>
114+
* @template TB of iterable<int>
115+
* @param TA $a
116+
* @param TB $b
117+
*/
118+
function iterables($a, $b): void
119+
{
120+
$c = rand(0, 1) === 1 ? $a : $b;
121+
assertType('TA of iterable (function Bug15431Nsrt\iterables(), argument)|TB of iterable<int> (function Bug15431Nsrt\iterables(), argument)', $c);
122+
}

‎tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4586,4 +4586,12 @@ public function testClassConstantWildcardArguments(): void
45864586
]);
45874587
}
45884588

4589+
public function testBug15431(): void
4590+
{
4591+
$this->checkThisOnly = false;
4592+
$this->checkNullables = true;
4593+
$this->checkUnionTypes = true;
4594+
$this->analyse([__DIR__ . '/data/bug-15431.php'], []);
4595+
}
4596+
45894597
}

‎tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1420,4 +1420,22 @@ public function testBug13325(): void
14201420
$this->analyse([__DIR__ . '/data/bug-13325.php'], []);
14211421
}
14221422

1423+
public function testBug15431(): void
1424+
{
1425+
$this->analyse([__DIR__ . '/data/bug-15431.php'], [
1426+
[
1427+
'Method Bug15431\\WeakAnalysingMap::unboxValue() should return T of (TKey of array<mixed>|object)|(TValue of array<mixed>|object) but returns array<mixed>|(T of (TKey of object)|TValue of object).',
1428+
30,
1429+
'• Type #1 from the union: Type array<mixed> is not always the same as TKey. It breaks the contract for some argument types, typically subtypes.
1430+
• Type #2 from the union: Type array<mixed> is not always the same as TValue. It breaks the contract for some argument types, typically subtypes.
1431+
• Type array<mixed> is not always the same as T. It breaks the contract for some argument types, typically subtypes.',
1432+
],
1433+
]);
1434+
}
1435+
1436+
public function testBug15431Iterable(): void
1437+
{
1438+
$this->analyse([__DIR__ . '/data/bug-15431-iterable.php'], []);
1439+
}
1440+
14231441
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug15431Iterable;
4+
5+
/**
6+
* @template TKey of object|iterable<mixed>
7+
* @template TValue of object|array<mixed>
8+
*/
9+
class Map
10+
{
11+
12+
/**
13+
* @param TKey&iterable<mixed> $a
14+
* @param TValue&array<mixed> $b
15+
* @param (TValue&array<mixed>)|(TKey&iterable<mixed>) $c
16+
* @return TKey|TValue
17+
*/
18+
public function doFoo($a, $b, $c)
19+
{
20+
if (rand(0, 1) === 1) {
21+
return $a;
22+
}
23+
if (rand(0, 1) === 1) {
24+
return $b;
25+
}
26+
27+
return $c;
28+
}
29+
30+
}
31+
32+
class Foo
33+
{
34+
35+
/**
36+
* @template TA of iterable<mixed>
37+
* @template TB of iterable<mixed>
38+
* @param TA $a
39+
* @param TB $b
40+
* @return TA|TB
41+
*/
42+
public function doFoo($a, $b)
43+
{
44+
return rand(0, 1) === 1 ? $a : $b;
45+
}
46+
47+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug15431;
4+
5+
/**
6+
* @template T of array<mixed>
7+
*/
8+
class WeakAnalysingBoxedArray
9+
{
10+
/** @var T */
11+
public array $value = [];
12+
}
13+
14+
/**
15+
* @template TKey of object|array<mixed>
16+
* @template TValue of object|array<mixed>
17+
* @template TOwner of object
18+
*/
19+
class WeakAnalysingMap
20+
{
21+
/**
22+
* @template T of TKey|TValue
23+
*
24+
* @param (T is object ? T : WeakAnalysingBoxedArray<T>) $value
25+
*
26+
* @return T
27+
*/
28+
protected function unboxValue(object $value)
29+
{
30+
return $value instanceof WeakAnalysingBoxedArray
31+
? $value->value
32+
: $value;
33+
}
34+
35+
/**
36+
* @param (TValue is object ? TValue : WeakAnalysingBoxedArray<TValue>) $v
37+
*
38+
* @return TValue|null
39+
*/
40+
public function unboxValueOnly($v)
41+
{
42+
return $this->unboxValue($v);
43+
}
44+
}

0 commit comments

Comments
 (0)