From 5ee6f45d4db6cfbd66e6940e35693491bcee7801 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 15 Aug 2026 15:44:20 +0200 Subject: [PATCH] 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(). --- src/Type/IntersectionType.php | 51 ++++++++ .../nsrt/array-pop-shift-has-offset.php | 113 ++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/array-pop-shift-has-offset.php diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index eeedf5452c5..38d94e344ad 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -1210,6 +1210,29 @@ public function intersectKeyArray(Type $otherArraysType): Type public function popArray(): Type { + if ($this->isList()->yes()) { + // hasOffsetValue(n, T) on a list proves indices 0..n exist; popping + // removes the highest index, so offsets up to n - 1 survive. Their + // values are unknown - the value known at n may have been the popped one. + $members = []; + foreach ($this->types as $type) { + if ($type instanceof TemplateType) { + $members[] = $type; + continue; + } + if ($type instanceof HasOffsetValueType || $type instanceof HasOffsetType) { + $offsetType = $type->getOffsetType(); + if ($offsetType instanceof ConstantIntegerType && $offsetType->getValue() >= 1) { + $members[] = new HasOffsetType(new ConstantIntegerType($offsetType->getValue() - 1)); + } + continue; + } + $members[] = $type->popArray(); + } + + return TypeCombinator::intersect(...$members); + } + return $this->intersectTypesPreserveTemplateType(static fn (Type $type): Type => $type->popArray()); } @@ -1225,6 +1248,34 @@ public function searchArray(Type $needleType, ?TrinaryLogic $strict = null): Typ public function shiftArray(): Type { + if ($this->isList()->yes()) { + // shifting a list reindexes: index n's value always moves to n - 1 + $members = []; + foreach ($this->types as $type) { + if ($type instanceof TemplateType) { + $members[] = $type; + continue; + } + if ($type instanceof HasOffsetValueType) { + $offsetType = $type->getOffsetType(); + if ($offsetType instanceof ConstantIntegerType && $offsetType->getValue() >= 1) { + $members[] = new HasOffsetValueType(new ConstantIntegerType($offsetType->getValue() - 1), $type->getValueType()); + } + continue; + } + if ($type instanceof HasOffsetType) { + $offsetType = $type->getOffsetType(); + if ($offsetType instanceof ConstantIntegerType && $offsetType->getValue() >= 1) { + $members[] = new HasOffsetType(new ConstantIntegerType($offsetType->getValue() - 1)); + } + continue; + } + $members[] = $type->shiftArray(); + } + + return TypeCombinator::intersect(...$members); + } + return $this->intersectTypesPreserveTemplateType(static fn (Type $type): Type => $type->shiftArray()); } diff --git a/tests/PHPStan/Analyser/nsrt/array-pop-shift-has-offset.php b/tests/PHPStan/Analyser/nsrt/array-pop-shift-has-offset.php new file mode 100644 index 00000000000..6eb5da98c6f --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-pop-shift-has-offset.php @@ -0,0 +1,113 @@ +&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $words); + $a = array_pop($words); + assertType('string', $a); + assertType('non-empty-list&hasOffset(0)&hasOffset(1)', $words); + $b = array_pop($words); + assertType('string', $b); + assertType('non-empty-list&hasOffset(0)', $words); + $c = array_pop($words); + assertType('string', $c); + assertType('list', $words); + $d = array_pop($words); + assertType('string|null', $d); + + // the pattern this locks in: both pops inside the literal stay strings + $words2 = explode(' ', $addressLine); + if (count($words2) < 3) { + return; + } + $lastTwo = [array_pop($words2), array_pop($words2)]; + assertType('array{string, string}', $lastTwo); +} + +function testShift(string $addressLine): void +{ + $words = explode(' ', $addressLine); + + if (count($words) < 3) { + return; + } + + $a = array_shift($words); + assertType('string', $a); + assertType('non-empty-list&hasOffsetValue(0, string)&hasOffsetValue(1, string)', $words); + $b = array_shift($words); + assertType('string', $b); + assertType('non-empty-list&hasOffsetValue(0, string)', $words); + $c = array_shift($words); + assertType('string', $c); + assertType('list', $words); + $d = array_shift($words); + assertType('string|null', $d); +} + +/** + * A list can never carry a string offset - narrowing by one is provably false, + * so the list branches only ever see integer offsets. + * + * @param list $list + */ +function testStringOffsetOnListIsImpossible(array $list): void +{ + assertType('false', isset($list['foo'])); +} + +/** + * With a string offset in the mix the type is not a list, so pop/shift keep + * their previous behavior: pop may remove any known offset (iteration order + * decides), so all offset knowledge is dropped. + * + * @param array $arr + */ +function testPopMixedOffsets(array $arr): void +{ + if (!array_key_exists('name', $arr)) { + return; + } + if (!array_key_exists(0, $arr)) { + return; + } + if (!array_key_exists(1, $arr)) { + return; + } + + assertType("non-empty-array&hasOffset('name')&hasOffset(0)&hasOffset(1)", $arr); + $a = array_pop($arr); + assertType('string', $a); + assertType('array', $arr); +} + +/** + * @param array $arr + */ +function testShiftMixedOffsets(array $arr): void +{ + if (!array_key_exists('name', $arr)) { + return; + } + if (!array_key_exists(0, $arr)) { + return; + } + if (!array_key_exists(1, $arr)) { + return; + } + + assertType("non-empty-array&hasOffset('name')&hasOffset(0)&hasOffset(1)", $arr); + $a = array_shift($arr); + assertType('string', $a); + assertType('array', $arr); +}