Skip to content

Commit a1d48d8

Browse files
phpstan-botstaabmclaude
authored
Preserve TemplateType in ArrayType and IntersectionType array-mutating methods (#5698)
Co-authored-by: staabm <120441+staabm@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Markus Staab <markus.staab@redaxo.de>
1 parent 33c983f commit a1d48d8

3 files changed

Lines changed: 220 additions & 25 deletions

File tree

‎src/Type/ArrayType.php‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ public function intersectKeyArray(Type $otherArraysType): Type
493493
return $this;
494494
}
495495

496-
return new self($otherArraysType->getIterableKeyType(), $this->getIterableValueType());
496+
return $this->withTypes($otherArraysType->getIterableKeyType(), $this->getIterableValueType());
497497
}
498498

499499
public function popArray(): Type
@@ -533,7 +533,7 @@ public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $pre
533533
}
534534

535535
if ($preserveKeys->no() && $this->keyType->isInteger()->yes()) {
536-
return new IntersectionType([new self(IntegerRangeType::createAllGreaterThanOrEqualTo(0), $this->itemType), new AccessoryArrayListType()]);
536+
return new IntersectionType([$this->withTypes(IntegerRangeType::createAllGreaterThanOrEqualTo(0), $this->itemType), new AccessoryArrayListType()]);
537537
}
538538

539539
return $this;
@@ -561,7 +561,7 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen
561561
return $type;
562562
});
563563

564-
$arrayType = new self(
564+
$arrayType = $this->withTypes(
565565
TypeCombinator::union($keyType, $replacementArrayType->getKeysArray()->getIterableKeyType()),
566566
TypeCombinator::union($this->getIterableValueType(), $replacementArrayType->getIterableValueType()),
567567
);
@@ -591,12 +591,12 @@ public function makeListMaybe(): Type
591591

592592
public function mapValueType(callable $cb): Type
593593
{
594-
return new ArrayType($this->keyType, $cb($this->getItemType()));
594+
return $this->withTypes($this->keyType, $cb($this->getItemType()));
595595
}
596596

597597
public function mapKeyType(callable $cb): Type
598598
{
599-
return new ArrayType($cb($this->keyType), $this->getItemType());
599+
return $this->withTypes($cb($this->keyType), $this->getItemType());
600600
}
601601

602602
public function makeAllArrayKeysOptional(): Type
@@ -647,7 +647,7 @@ public function changeKeyCaseArray(?int $case): Type
647647
return $type;
648648
});
649649

650-
return new ArrayType($newKeyType, $this->getItemType());
650+
return $this->withTypes($newKeyType, $this->getItemType());
651651
}
652652

653653
public function filterArrayRemovingFalsey(): Type
@@ -658,7 +658,7 @@ public function filterArrayRemovingFalsey(): Type
658658
return new ConstantArrayType([], []);
659659
}
660660

661-
return new ArrayType($this->keyType, $valueType);
661+
return $this->withTypes($this->keyType, $valueType);
662662
}
663663

664664
private static function foldConstantStringKeyCase(ConstantStringType $type, ?int $case): Type

‎src/Type/IntersectionType.php‎

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,7 +1151,11 @@ public function getKeysArray(): Type
11511151

11521152
public function getValuesArray(): Type
11531153
{
1154-
return $this->intersectTypes(static fn (Type $type): Type => $type->getValuesArray());
1154+
$cb = static fn (Type $type): Type => $type->getValuesArray();
1155+
if ($this->isList()->yes()) {
1156+
return $this;
1157+
}
1158+
return $this->intersectTypes($cb);
11551159
}
11561160

11571161
public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type
@@ -1171,17 +1175,17 @@ public function flipArray(): Type
11711175

11721176
public function intersectKeyArray(Type $otherArraysType): Type
11731177
{
1174-
return $this->intersectTypes(static fn (Type $type): Type => $type->intersectKeyArray($otherArraysType));
1178+
return $this->intersectTypesPreserveTemplateType(static fn (Type $type): Type => $type->intersectKeyArray($otherArraysType));
11751179
}
11761180

11771181
public function popArray(): Type
11781182
{
1179-
return $this->intersectTypes(static fn (Type $type): Type => $type->popArray());
1183+
return $this->intersectTypesPreserveTemplateType(static fn (Type $type): Type => $type->popArray());
11801184
}
11811185

11821186
public function reverseArray(TrinaryLogic $preserveKeys): Type
11831187
{
1184-
return $this->intersectTypes(static fn (Type $type): Type => $type->reverseArray($preserveKeys));
1188+
return $this->intersectTypesPreserveTemplateType(static fn (Type $type): Type => $type->reverseArray($preserveKeys));
11851189
}
11861190

11871191
public function searchArray(Type $needleType, ?TrinaryLogic $strict = null): Type
@@ -1191,23 +1195,21 @@ public function searchArray(Type $needleType, ?TrinaryLogic $strict = null): Typ
11911195

11921196
public function shiftArray(): Type
11931197
{
1194-
return $this->intersectTypes(static fn (Type $type): Type => $type->shiftArray());
1198+
return $this->intersectTypesPreserveTemplateType(static fn (Type $type): Type => $type->shiftArray());
11951199
}
11961200

11971201
public function shuffleArray(): Type
11981202
{
1199-
$isList = $this->isList()->yes();
1200-
return $this->intersectTypes(static function (Type $type) use ($isList): Type {
1201-
if ($isList && $type instanceof TemplateType) {
1202-
return $type;
1203-
}
1204-
return $type->shuffleArray();
1205-
});
1203+
$cb = static fn (Type $type): Type => $type->shuffleArray();
1204+
if ($this->isList()->yes()) {
1205+
return $this->intersectTypesPreserveTemplateType($cb);
1206+
}
1207+
return $this->intersectTypes($cb);
12061208
}
12071209

12081210
public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $preserveKeys): Type
12091211
{
1210-
$result = $this->intersectTypes(static fn (Type $type): Type => $type->sliceArray($offsetType, $lengthType, $preserveKeys));
1212+
$result = $this->intersectTypesPreserveTemplateType(static fn (Type $type): Type => $type->sliceArray($offsetType, $lengthType, $preserveKeys));
12111213

12121214
if (
12131215
$this->isList()->yes()
@@ -1223,7 +1225,7 @@ public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $pre
12231225

12241226
public function spliceArray(Type $offsetType, Type $lengthType, Type $replacementType): Type
12251227
{
1226-
return $this->intersectTypes(static fn (Type $type): Type => $type->spliceArray($offsetType, $lengthType, $replacementType));
1228+
return $this->intersectTypesPreserveTemplateType(static fn (Type $type): Type => $type->spliceArray($offsetType, $lengthType, $replacementType));
12271229
}
12281230

12291231
public function makeListMaybe(): Type
@@ -1233,12 +1235,12 @@ public function makeListMaybe(): Type
12331235

12341236
public function mapValueType(callable $cb): Type
12351237
{
1236-
return $this->intersectTypes(static fn (Type $type): Type => $type->mapValueType($cb));
1238+
return $this->intersectTypesPreserveTemplateType(static fn (Type $type): Type => $type->mapValueType($cb));
12371239
}
12381240

12391241
public function mapKeyType(callable $cb): Type
12401242
{
1241-
return $this->intersectTypes(static fn (Type $type): Type => $type->mapKeyType($cb));
1243+
return $this->intersectTypesPreserveTemplateType(static fn (Type $type): Type => $type->mapKeyType($cb));
12421244
}
12431245

12441246
public function makeAllArrayKeysOptional(): Type
@@ -1248,12 +1250,12 @@ public function makeAllArrayKeysOptional(): Type
12481250

12491251
public function changeKeyCaseArray(?int $case): Type
12501252
{
1251-
return $this->intersectTypes(static fn (Type $type): Type => $type->changeKeyCaseArray($case));
1253+
return $this->intersectTypesPreserveTemplateType(static fn (Type $type): Type => $type->changeKeyCaseArray($case));
12521254
}
12531255

12541256
public function filterArrayRemovingFalsey(): Type
12551257
{
1256-
return $this->intersectTypes(static fn (Type $type): Type => $type->filterArrayRemovingFalsey());
1258+
return $this->intersectTypesPreserveTemplateType(static fn (Type $type): Type => $type->filterArrayRemovingFalsey());
12571259
}
12581260

12591261
public function getEnumCases(): array
@@ -1714,6 +1716,19 @@ private function intersectTypes(callable $getType): Type
17141716
return $result;
17151717
}
17161718

1719+
/**
1720+
* @param callable(Type $type): Type $getType
1721+
*/
1722+
private function intersectTypesPreserveTemplateType(callable $getType): Type
1723+
{
1724+
return $this->intersectTypes(static function (Type $type) use ($getType): Type {
1725+
if ($type instanceof TemplateType) {
1726+
return $type;
1727+
}
1728+
return $getType($type);
1729+
});
1730+
}
1731+
17171732
public function toPhpDocNode(): TypeNode
17181733
{
17191734
$baseTypes = [];
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Bug14633;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/**
8+
* Tests for IntersectionType preserving TemplateType in array methods.
9+
* Pattern: @template T with T&list<V> or T&array<K,V>
10+
*/
11+
class IntersectionTemplatePreservation
12+
{
13+
14+
/**
15+
* @template T
16+
* @param T&list<int> $items
17+
*/
18+
public function popList(array $items): void
19+
{
20+
array_pop($items);
21+
assertType('list<int>&T (method Bug14633\IntersectionTemplatePreservation::popList(), argument)', $items);
22+
}
23+
24+
/**
25+
* @template T
26+
* @param T&array<string, int> $items
27+
*/
28+
public function popArray(array $items): void
29+
{
30+
array_pop($items);
31+
assertType('array<string, int>&T (method Bug14633\IntersectionTemplatePreservation::popArray(), argument)', $items);
32+
}
33+
34+
/**
35+
* @template T
36+
* @param T&list<int> $items
37+
*/
38+
public function shiftList(array $items): void
39+
{
40+
array_shift($items);
41+
assertType('list<int>&T (method Bug14633\IntersectionTemplatePreservation::shiftList(), argument)', $items);
42+
}
43+
44+
/**
45+
* @template T
46+
* @param T&array<string, int> $items
47+
*/
48+
public function shiftArray(array $items): void
49+
{
50+
array_shift($items);
51+
assertType('array<string, int>&T (method Bug14633\IntersectionTemplatePreservation::shiftArray(), argument)', $items);
52+
}
53+
54+
/**
55+
* @template T
56+
* @param T&list<int> $items
57+
*/
58+
public function reverseList(array $items): void
59+
{
60+
$reversed = array_reverse($items);
61+
assertType('list<int>&T (method Bug14633\IntersectionTemplatePreservation::reverseList(), argument)', $reversed);
62+
}
63+
64+
/**
65+
* @template T
66+
* @param T&array<string, int> $items
67+
*/
68+
public function reverseArrayPreserveKeys(array $items): void
69+
{
70+
$reversed = array_reverse($items, true);
71+
assertType('array<string, int>&T (method Bug14633\IntersectionTemplatePreservation::reverseArrayPreserveKeys(), argument)', $reversed);
72+
}
73+
74+
/**
75+
* @template T
76+
* @param T&list<int> $items
77+
*/
78+
public function sliceList(array $items): void
79+
{
80+
$sliced = array_slice($items, 1);
81+
assertType('list<int>&T (method Bug14633\IntersectionTemplatePreservation::sliceList(), argument)', $sliced);
82+
}
83+
84+
/**
85+
* @template T
86+
* @param T&array<string, int> $items
87+
*/
88+
public function sliceArrayPreserveKeys(array $items): void
89+
{
90+
$sliced = array_slice($items, 0, 5, true);
91+
assertType('array<string, int>&T (method Bug14633\IntersectionTemplatePreservation::sliceArrayPreserveKeys(), argument)', $sliced);
92+
}
93+
94+
/**
95+
* @template T
96+
* @param T&list<int> $items
97+
*/
98+
public function arrayValuesOnList(array $items): void
99+
{
100+
$values = array_values($items);
101+
assertType('list<int>&T (method Bug14633\IntersectionTemplatePreservation::arrayValuesOnList(), argument)', $values);
102+
}
103+
104+
/**
105+
* array_values() on a non-list must NOT preserve T — keys change from string to int.
106+
*
107+
* @template T
108+
* @param T&array<string, int> $items
109+
*/
110+
public function arrayValuesOnNonList(array $items): void
111+
{
112+
$values = array_values($items);
113+
assertType('list<int>', $values);
114+
}
115+
116+
/**
117+
* shuffle() on a list preserves T — keys are already sequential integers.
118+
*
119+
* @template T
120+
* @param T&list<int> $items
121+
*/
122+
public function shuffleOnList(array $items): void
123+
{
124+
shuffle($items);
125+
assertType('list<int>&T (method Bug14633\IntersectionTemplatePreservation::shuffleOnList(), argument)', $items);
126+
}
127+
128+
/**
129+
* shuffle() on a non-list must NOT preserve T — keys change from string to int.
130+
*
131+
* @template T
132+
* @param T&array<string, int> $items
133+
*/
134+
public function shuffleOnNonList(array $items): void
135+
{
136+
shuffle($items);
137+
assertType('list<int>', $items);
138+
}
139+
140+
}
141+
142+
/**
143+
* Tests for ArrayType methods preserving template via $this->withTypes().
144+
* Pattern: @template T of array<K,V>
145+
*/
146+
class ArrayTypeTemplatePreservation
147+
{
148+
149+
/**
150+
* @template T of array<string, int>
151+
* @param T $items
152+
*/
153+
public function filterArrayRemovingFalsey(array $items): void
154+
{
155+
$result = array_filter($items);
156+
assertType('T of array<string, int<min, -1>|int<1, max>> (method Bug14633\ArrayTypeTemplatePreservation::filterArrayRemovingFalsey(), argument)', $result);
157+
}
158+
159+
/**
160+
* @template T of array<string, int>
161+
* @param T $items
162+
* @param array<string, mixed> $other
163+
*/
164+
public function intersectKeyArray(array $items, array $other): void
165+
{
166+
$result = array_intersect_key($items, $other);
167+
assertType('T of array<string, int> (method Bug14633\ArrayTypeTemplatePreservation::intersectKeyArray(), argument)', $result);
168+
}
169+
170+
/**
171+
* @template T of array<int, int>
172+
* @param T $items
173+
*/
174+
public function sliceArray(array $items): void
175+
{
176+
$result = array_slice($items, 1);
177+
assertType('T of array<int<0, max>, int> (method Bug14633\ArrayTypeTemplatePreservation::sliceArray(), argument)&list', $result);
178+
}
179+
180+
}

0 commit comments

Comments
 (0)