Skip to content

Commit 24cde24

Browse files
committed
Preserve surviving offsets when popping or shifting a list
hasOffsetValue(n, T) on a list proves indices 0..n exist, so popping the highest index keeps hasOffset(0..n-1) - previously the accessory answered with no opinion and the whole intersection degraded to a possibly-empty list, so a second array_pop() in the same statement typed as nullable. Shifting reindexes, so the value known at n moves to n - 1 intact. TemplateType members pass through unchanged, as in intersectTypesPreserveTemplateType().
1 parent bbd47cb commit 24cde24

2 files changed

Lines changed: 164 additions & 0 deletions

File tree

‎src/Type/IntersectionType.php‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,6 +1210,29 @@ public function intersectKeyArray(Type $otherArraysType): Type
12101210

12111211
public function popArray(): Type
12121212
{
1213+
if ($this->isList()->yes()) {
1214+
// hasOffsetValue(n, T) on a list proves indices 0..n exist; popping
1215+
// removes the highest index, so offsets up to n - 1 survive. Their
1216+
// values are unknown - the value known at n may have been the popped one.
1217+
$members = [];
1218+
foreach ($this->types as $type) {
1219+
if ($type instanceof TemplateType) {
1220+
$members[] = $type;
1221+
continue;
1222+
}
1223+
if ($type instanceof HasOffsetValueType || $type instanceof HasOffsetType) {
1224+
$offsetType = $type->getOffsetType();
1225+
if ($offsetType instanceof ConstantIntegerType && $offsetType->getValue() >= 1) {
1226+
$members[] = new HasOffsetType(new ConstantIntegerType($offsetType->getValue() - 1));
1227+
}
1228+
continue;
1229+
}
1230+
$members[] = $type->popArray();
1231+
}
1232+
1233+
return TypeCombinator::intersect(...$members);
1234+
}
1235+
12131236
return $this->intersectTypesPreserveTemplateType(static fn (Type $type): Type => $type->popArray());
12141237
}
12151238

@@ -1225,6 +1248,34 @@ public function searchArray(Type $needleType, ?TrinaryLogic $strict = null): Typ
12251248

12261249
public function shiftArray(): Type
12271250
{
1251+
if ($this->isList()->yes()) {
1252+
// shifting a list reindexes: index n's value always moves to n - 1
1253+
$members = [];
1254+
foreach ($this->types as $type) {
1255+
if ($type instanceof TemplateType) {
1256+
$members[] = $type;
1257+
continue;
1258+
}
1259+
if ($type instanceof HasOffsetValueType) {
1260+
$offsetType = $type->getOffsetType();
1261+
if ($offsetType instanceof ConstantIntegerType && $offsetType->getValue() >= 1) {
1262+
$members[] = new HasOffsetValueType(new ConstantIntegerType($offsetType->getValue() - 1), $type->getValueType());
1263+
}
1264+
continue;
1265+
}
1266+
if ($type instanceof HasOffsetType) {
1267+
$offsetType = $type->getOffsetType();
1268+
if ($offsetType instanceof ConstantIntegerType && $offsetType->getValue() >= 1) {
1269+
$members[] = new HasOffsetType(new ConstantIntegerType($offsetType->getValue() - 1));
1270+
}
1271+
continue;
1272+
}
1273+
$members[] = $type->shiftArray();
1274+
}
1275+
1276+
return TypeCombinator::intersect(...$members);
1277+
}
1278+
12281279
return $this->intersectTypesPreserveTemplateType(static fn (Type $type): Type => $type->shiftArray());
12291280
}
12301281

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ArrayPopShiftHasOffset;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
function testPop(string $addressLine): void
8+
{
9+
$words = explode(' ', $addressLine);
10+
11+
if (count($words) < 3) {
12+
return;
13+
}
14+
15+
assertType('non-empty-list<string>&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $words);
16+
$a = array_pop($words);
17+
assertType('string', $a);
18+
assertType('non-empty-list<string>&hasOffset(0)&hasOffset(1)', $words);
19+
$b = array_pop($words);
20+
assertType('string', $b);
21+
assertType('non-empty-list<string>&hasOffset(0)', $words);
22+
$c = array_pop($words);
23+
assertType('string', $c);
24+
assertType('list<string>', $words);
25+
$d = array_pop($words);
26+
assertType('string|null', $d);
27+
28+
// the pattern this locks in: both pops inside the literal stay strings
29+
$words2 = explode(' ', $addressLine);
30+
if (count($words2) < 3) {
31+
return;
32+
}
33+
$lastTwo = [array_pop($words2), array_pop($words2)];
34+
assertType('array{string, string}', $lastTwo);
35+
}
36+
37+
function testShift(string $addressLine): void
38+
{
39+
$words = explode(' ', $addressLine);
40+
41+
if (count($words) < 3) {
42+
return;
43+
}
44+
45+
$a = array_shift($words);
46+
assertType('string', $a);
47+
assertType('non-empty-list<string>&hasOffsetValue(0, string)&hasOffsetValue(1, string)', $words);
48+
$b = array_shift($words);
49+
assertType('string', $b);
50+
assertType('non-empty-list<string>&hasOffsetValue(0, string)', $words);
51+
$c = array_shift($words);
52+
assertType('string', $c);
53+
assertType('list<string>', $words);
54+
$d = array_shift($words);
55+
assertType('string|null', $d);
56+
}
57+
58+
/**
59+
* A list can never carry a string offset - narrowing by one is provably false,
60+
* so the list branches only ever see integer offsets.
61+
*
62+
* @param list<string> $list
63+
*/
64+
function testStringOffsetOnListIsImpossible(array $list): void
65+
{
66+
assertType('false', isset($list['foo']));
67+
}
68+
69+
/**
70+
* With a string offset in the mix the type is not a list, so pop/shift keep
71+
* their previous behavior: pop may remove any known offset (iteration order
72+
* decides), so all offset knowledge is dropped.
73+
*
74+
* @param array<string> $arr
75+
*/
76+
function testPopMixedOffsets(array $arr): void
77+
{
78+
if (!array_key_exists('name', $arr)) {
79+
return;
80+
}
81+
if (!array_key_exists(0, $arr)) {
82+
return;
83+
}
84+
if (!array_key_exists(1, $arr)) {
85+
return;
86+
}
87+
88+
assertType("non-empty-array<string>&hasOffset('name')&hasOffset(0)&hasOffset(1)", $arr);
89+
$a = array_pop($arr);
90+
assertType('string', $a);
91+
assertType('array<string>', $arr);
92+
}
93+
94+
/**
95+
* @param array<string> $arr
96+
*/
97+
function testShiftMixedOffsets(array $arr): void
98+
{
99+
if (!array_key_exists('name', $arr)) {
100+
return;
101+
}
102+
if (!array_key_exists(0, $arr)) {
103+
return;
104+
}
105+
if (!array_key_exists(1, $arr)) {
106+
return;
107+
}
108+
109+
assertType("non-empty-array<string>&hasOffset('name')&hasOffset(0)&hasOffset(1)", $arr);
110+
$a = array_shift($arr);
111+
assertType('string', $a);
112+
assertType('array<string>', $arr);
113+
}

0 commit comments

Comments
 (0)