Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Consider optional keys in ConstantArrayType::slice #1345

Merged
merged 2 commits into from May 28, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
154 changes: 118 additions & 36 deletions src/Type/Constant/ConstantArrayType.php
Expand Up @@ -45,7 +45,7 @@
use function in_array;
use function is_int;
use function is_string;
use function max;
use function min;
use function pow;
use function sort;
use function sprintf;
Expand Down Expand Up @@ -549,32 +549,48 @@ public function isIterableAtLeastOnce(): TrinaryLogic

public function removeLast(): self
{
if (count($this->keyTypes) === 0) {
return $this->removeLastElements(1);
}

/** @param positive-int $length */
private function removeLastElements(int $length): self
{
$keyTypesCount = count($this->keyTypes);
if ($keyTypesCount === 0) {
return $this;
}

$i = count($this->keyTypes) - 1;

$keyTypes = $this->keyTypes;
$valueTypes = $this->valueTypes;
$optionalKeys = $this->optionalKeys;
$nextAutoindex = $this->nextAutoIndexes;

$optionalKeysRemoved = 0;
$newLength = $keyTypesCount - $length;
for ($i = $keyTypesCount - 1; $i >= 0; $i--) {
$isOptional = $this->isOptionalKey($i);

if ($this->isOptionalKey($i)) {
unset($optionalKeys[$i]);
// Removing the last optional element makes the previous non-optional element optional
for ($j = $i - 1; $j >= 0; $j--) {
if (!$this->isOptionalKey($j)) {
$optionalKeys[] = $j;
break;
if ($i >= $newLength) {
if ($isOptional) {
$optionalKeysRemoved++;
unset($optionalKeys[$i]);
}

$removedKeyType = array_pop($keyTypes);
array_pop($valueTypes);
$nextAutoindex = $removedKeyType instanceof ConstantIntegerType
? $removedKeyType->getValue()
: $this->getNextAutoIndex(); // @phpstan-ignore-line
continue;
}

if ($isOptional || $optionalKeysRemoved <= 0) {
continue;
}
}

$removedKeyType = array_pop($keyTypes);
array_pop($valueTypes);
$nextAutoindex = $removedKeyType instanceof ConstantIntegerType
? $removedKeyType->getValue()
: $this->getNextAutoIndex(); // @phpstan-ignore-line
$optionalKeys[] = $i;
$optionalKeysRemoved--;
}

return new self(
$keyTypes,
Expand All @@ -584,54 +600,120 @@ public function removeLast(): self
);
}

public function removeFirst(): Type
public function removeFirst(): self
{
return $this->removeFirstElements(1);
}

/** @param positive-int $length */
private function removeFirstElements(int $length, bool $reindex = true): self
{
$builder = ConstantArrayTypeBuilder::createEmpty();
$makeNextNonOptionalOptional = false;

$optionalKeysIgnored = 0;
foreach ($this->keyTypes as $i => $keyType) {
$isOptional = $this->isOptionalKey($i);
if ($i === 0) {
$makeNextNonOptionalOptional = $isOptional;
if ($i <= $length - 1) {
if ($isOptional) {
$optionalKeysIgnored++;
}
continue;
}

if (!$isOptional && $makeNextNonOptionalOptional) {
if (!$isOptional && $optionalKeysIgnored > 0) {
$isOptional = true;
$makeNextNonOptionalOptional = false;
$optionalKeysIgnored--;
}

$valueType = $this->valueTypes[$i];
if ($keyType instanceof ConstantIntegerType) {
if ($reindex && $keyType instanceof ConstantIntegerType) {
$keyType = null;
}

$builder->setOffsetValueType($keyType, $valueType, $isOptional);
}

return $builder->getArray();
$array = $builder->getArray();
if (!$array instanceof self) {
throw new ShouldNotHappenException();
}

return $array;
}

public function slice(int $offset, ?int $limit, bool $preserveKeys = false): self
{
if (count($this->keyTypes) === 0) {
$keyTypesCount = count($this->keyTypes);
if ($keyTypesCount === 0) {
return $this;
}

$keyTypes = array_slice($this->keyTypes, $offset, $limit);
$valueTypes = array_slice($this->valueTypes, $offset, $limit);
$limit ??= $keyTypesCount;
if ($limit < 0) {
// Negative limits prevent access to the most right n elements
return $this->removeLastElements($limit * -1)
->slice($offset, null, $preserveKeys);
}

/** @var int|float $nextAutoIndex */
$nextAutoIndex = 0;
foreach ($keyTypes as $keyType) {
if (!$keyType instanceof ConstantIntegerType) {
continue;
if ($keyTypesCount + $offset <= 0) {
// A negative offset cannot reach left outside the array
$offset = 0;
}

if ($offset < 0) {
/*
* Transforms the problem with the negative offset in one with a positive offset using array reversion.
* The reason is belows handling of optional keys which works only from left to right.
*
* e.g.
* array{a: 0, b: 1, c: 2, d: 3, e: 4}
* with offset -4 and limit 2 (which would be sliced to array{b: 1, c: 2})
*
* is transformed via reversion to
*
* array{e: 4, d: 3, c: 2, b: 1, a: 0}
* with offset 2 and limit 2 (which will be sliced to array{c: 2, b: 1} and then reversed again)
*/
$offset *= -1;
$reversedLimit = min($limit, $offset);
$reversedOffset = $offset - $reversedLimit;
return $this->reverse(true)
->slice($reversedOffset, $reversedLimit, $preserveKeys)
->reverse(true);
}

if ($offset > 0) {
return $this->removeFirstElements($offset, false)
->slice(0, $limit, $preserveKeys);
}

$builder = ConstantArrayTypeBuilder::createEmpty();

$nonOptionalElementsCount = 0;
$hasOptional = false;
for ($i = 0; $nonOptionalElementsCount < $limit && $i < $keyTypesCount; $i++) {
$isOptional = $this->isOptionalKey($i);
if (!$isOptional) {
$nonOptionalElementsCount++;
} else {
$hasOptional = true;
}

$isLastElement = $nonOptionalElementsCount >= $limit || $i + 1 >= $keyTypesCount;
if ($isLastElement && $limit < $keyTypesCount && $hasOptional) {
// If the slice is not full yet, but has at least one optional key
// the last non-optional element is going to be optional.
// Otherwise, it would not fit into the slice if previous non-optional keys are there.
$isOptional = true;
}

/** @var int|float $nextAutoIndex */
$nextAutoIndex = max($nextAutoIndex, $keyType->getValue() + 1);
$builder->setOffsetValueType($this->keyTypes[$i], $this->valueTypes[$i], $isOptional);
}

$slice = new self($keyTypes, $valueTypes, (int) $nextAutoIndex, []);
$slice = $builder->getArray();
if (!$slice instanceof self) {
throw new ShouldNotHappenException();
}

return $preserveKeys ? $slice : $slice->reindex();
}
Expand Down
74 changes: 59 additions & 15 deletions tests/PHPStan/Analyser/data/array-slice.php
Expand Up @@ -23,22 +23,66 @@ public function fromMixed($arr): void
assertType('array', array_slice($arr, 1, 2));
}

/**
* @param array<int, bool> $arr1
* @param array<string, int> $arr2
* @param array{17: 'foo', b: 'bar', 19: 'baz'} $arr3
* @param array{17: 'foo', 19: 'bar', 21: 'baz'}|array{foo: 17, bar: 19, baz: 21} $arr4
*/
public function preserveTypes(array $arr1, array $arr2, array $arr3, array $arr4): void
public function normalArrays(array $arr): void
{
/** @var array<int, bool> $arr */
assertType('array<int, bool>', array_slice($arr, 1, 2));
assertType('array<int, bool>', array_slice($arr, 1, 2, true));

/** @var array<string, int> $arr */
assertType('array<string, int>', array_slice($arr, 1, 2));
assertType('array<string, int>', array_slice($arr, 1, 2, true));
}

public function constantArrays(array $arr): void
{
/** @var array{17: 'foo', b: 'bar', 19: 'baz'} $arr */
assertType('array{b: \'bar\', 0: \'baz\'}', array_slice($arr, 1, 2));
assertType('array{b: \'bar\', 19: \'baz\'}', array_slice($arr, 1, 2, true));

/** @var array{17: 'foo', 19: 'bar', 21: 'baz'}|array{foo: 17, bar: 19, baz: 21} $arr */
assertType('array{\'bar\', \'baz\'}|array{bar: 19, baz: 21}', array_slice($arr, 1, 2));
assertType('array{19: \'bar\', 21: \'baz\'}|array{bar: 19, baz: 21}', array_slice($arr, 1, 2, true));
}

public function constantArraysWithOptionalKeys(array $arr): void
{
assertType('array<int, bool>', array_slice($arr1, 1, 2));
assertType('array<int, bool>', array_slice($arr1, 1, 2, true));
assertType('array<string, int>', array_slice($arr2, 1, 2));
assertType('array<string, int>', array_slice($arr2, 1, 2, true));
assertType('array{b: \'bar\', 0: \'baz\'}', array_slice($arr3, 1, 2));
assertType('array{b: \'bar\', 19: \'baz\'}', array_slice($arr3, 1, 2, true));
assertType('array{\'bar\', \'baz\'}|array{bar: 19, baz: 21}', array_slice($arr4, 1, 2));
assertType('array{19: \'bar\', 21: \'baz\'}|array{bar: 19, baz: 21}', array_slice($arr4, 1, 2, true));
/** @var array{a?: 0, b: 1, c: 2} $arr */
assertType('array{a?: 0, b?: 1}', array_slice($arr, 0, 1));
assertType('array{a?: 0, b: 1, c: 2}', array_slice($arr, 0));
assertType('array{a?: 0, b: 1, c: 2}', array_slice($arr, -99));
assertType('array{a?: 0, b: 1, c: 2}', array_slice($arr, 0, 99));
assertType('array{a?: 0}', array_slice($arr, 0, -2));
assertType('array{}', array_slice($arr, 0, -3));
assertType('array{}', array_slice($arr, 0, -99));
assertType('array{}', array_slice($arr, -99, -99));
assertType('array{}', array_slice($arr, 99));

/** @var array{a?: 0, b?: 1, c: 2, d: 3, e: 4} $arr */
assertType('array{c?: 2, d?: 3, e?: 4}', array_slice($arr, 2, 1));
assertType('array{b?: 1, c?: 2, d: 3, e?: 4}', array_slice($arr, 1, 3));
assertType('array{e: 4}', array_slice($arr, -1));
assertType('array{d: 3}', array_slice($arr, -2, 1));

/** @var array{a: 0, b: 1, c: 2, d?: 3, e?: 4} $arr */
assertType('array{c: 2}', array_slice($arr, 2, 1));
assertType('array{c?: 2, d?: 3, e?: 4}', array_slice($arr, -1));
assertType('array{b?: 1, c?: 2, d?: 3}', array_slice($arr, -2, 1));
assertType('array{a: 0, b: 1, c?: 2, d?: 3}', array_slice($arr, 0, -1));
assertType('array{a: 0, b?: 1, c?: 2}', array_slice($arr, 0, -2));

/** @var array{a: 0, b?: 1, c: 2, d?: 3, e: 4} $arr */
assertType('array{b?: 1, c: 2, d?: 3, e?: 4}', array_slice($arr, 1, 2));
assertType('array{a: 0, b?: 1, c?: 2}', array_slice($arr, 0, 2));
assertType('array{a: 0}', array_slice($arr, 0, 1));
assertType('array{b?: 1, c?: 2}', array_slice($arr, 1, 1));
assertType('array{c?: 2, d?: 3, e?: 4}', array_slice($arr, 2, 1));
assertType('array{a: 0, b?: 1, c: 2, d?: 3}', array_slice($arr, 0, -1));
assertType('array{c?: 2, d?: 3, e: 4}', array_slice($arr, -2));

/** @var array{a: 0, b?: 1, c: 2} $arr */
assertType('array{a: 0, b?: 1}', array_slice($arr, 0, -1));
assertType('array{a: 0}', array_slice($arr, -3, 1));
}

}