diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 27d0870572..0ed0c0ecb5 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -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; @@ -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, @@ -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(); } diff --git a/tests/PHPStan/Analyser/data/array-slice.php b/tests/PHPStan/Analyser/data/array-slice.php index 3a863ad22c..7a39157d91 100644 --- a/tests/PHPStan/Analyser/data/array-slice.php +++ b/tests/PHPStan/Analyser/data/array-slice.php @@ -23,22 +23,66 @@ public function fromMixed($arr): void assertType('array', array_slice($arr, 1, 2)); } - /** - * @param array $arr1 - * @param array $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 $arr */ + assertType('array', array_slice($arr, 1, 2)); + assertType('array', array_slice($arr, 1, 2, true)); + + /** @var array $arr */ + assertType('array', array_slice($arr, 1, 2)); + assertType('array', 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', array_slice($arr1, 1, 2)); - assertType('array', array_slice($arr1, 1, 2, true)); - assertType('array', array_slice($arr2, 1, 2)); - assertType('array', 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)); } }