Skip to content

Commit

Permalink
Consider optional keys in ConstantArrayType::slice
Browse files Browse the repository at this point in the history
  • Loading branch information
herndlm committed May 25, 2022
1 parent 34ad812 commit a70fb09
Show file tree
Hide file tree
Showing 2 changed files with 121 additions and 26 deletions.
90 changes: 79 additions & 11 deletions src/Type/Constant/ConstantArrayType.php
Expand Up @@ -31,6 +31,7 @@
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\UnionType;
use PHPStan\Type\VerbosityLevel;
use function abs;
use function array_keys;
use function array_map;
use function array_merge;
Expand All @@ -45,7 +46,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 @@ -595,25 +596,92 @@ public function removeFirst(): Type

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
$keyTypesCount += $limit;
$limit = $keyTypesCount;
}

/** @var int|float $nextAutoIndex */
$nextAutoIndex = 0;
foreach ($keyTypes as $keyType) {
if (!$keyType instanceof ConstantIntegerType) {
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 reversation.
* 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 reversation 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 = abs($offset);
$reversedLimit = min($limit, $offset);
$reversedOffset = $offset - $reversedLimit;
return $this->reverse(true)
->slice($reversedOffset, $reversedLimit, $preserveKeys)
->reverse(true);
}

$builder = ConstantArrayTypeBuilder::createEmpty();

$elementsToAdd = $limit;
$optionalOffset = 0;
$hasOptional = false;
for ($keyIndex = 0; $elementsToAdd > 0 && $keyIndex < $keyTypesCount; $keyIndex++) {
$isOptional = $this->isOptionalKey($keyIndex);

if ($keyIndex < $offset) {
if ($isOptional) {
// All optional keys before the given offset lead to a shifted array
// which makes non-optional keys inside the offset optional
$optionalOffset++;
}
continue;
}

/** @var int|float $nextAutoIndex */
$nextAutoIndex = max($nextAutoIndex, $keyType->getValue() + 1);
if (!$isOptional && $optionalOffset > 0) {
$isOptional = true;
$optionalOffset--;
}

if (!$isOptional) {
$elementsToAdd--;
}

$isLastElement = $elementsToAdd <= 0 || $keyIndex + 1 >= $keyTypesCount;
if ($isLastElement && $limit < $keyTypesCount && $hasOptional) {
// If the slice is not full yet, but has at least one optional key
// then 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;
}

$builder->setOffsetValueType($this->keyTypes[$keyIndex], $this->valueTypes[$keyIndex], $isOptional);

if (!$isOptional) {
continue;
}

$hasOptional = true;
}

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

return $preserveKeys ? $slice : $slice->reindex();
}
Expand Down
57 changes: 42 additions & 15 deletions tests/PHPStan/Analyser/data/array-slice.php
Expand Up @@ -23,22 +23,49 @@ 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 preserveTypes(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<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));

/** @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));

/** @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));

/** @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));
}

}

0 comments on commit a70fb09

Please sign in to comment.