Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/Type/IntersectionType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need a nsrt test for a list which contains a mix of string and int offset-types

$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());
}

Expand All @@ -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());
}

Expand Down
113 changes: 113 additions & 0 deletions tests/PHPStan/Analyser/nsrt/array-pop-shift-has-offset.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php declare(strict_types = 1);

namespace ArrayPopShiftHasOffset;

use function PHPStan\Testing\assertType;

function testPop(string $addressLine): void
{
$words = explode(' ', $addressLine);

if (count($words) < 3) {
return;
}

assertType('non-empty-list<string>&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $words);
$a = array_pop($words);
assertType('string', $a);
assertType('non-empty-list<string>&hasOffset(0)&hasOffset(1)', $words);
$b = array_pop($words);
assertType('string', $b);
assertType('non-empty-list<string>&hasOffset(0)', $words);
$c = array_pop($words);
assertType('string', $c);
assertType('list<string>', $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<string>&hasOffsetValue(0, string)&hasOffsetValue(1, string)', $words);
$b = array_shift($words);
assertType('string', $b);
assertType('non-empty-list<string>&hasOffsetValue(0, string)', $words);
$c = array_shift($words);
assertType('string', $c);
assertType('list<string>', $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<string> $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<string> $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<string>&hasOffset('name')&hasOffset(0)&hasOffset(1)", $arr);
$a = array_pop($arr);
assertType('string', $a);
assertType('array<string>', $arr);
}

/**
* @param array<string> $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<string>&hasOffset('name')&hasOffset(0)&hasOffset(1)", $arr);
$a = array_shift($arr);
assertType('string', $a);
assertType('array<string>', $arr);
}
Loading