diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index f9856ee4dbd..cac3323ad44 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -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; @@ -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; @@ -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 = max(0, $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 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 = 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(); } diff --git a/tests/PHPStan/Analyser/data/array-slice.php b/tests/PHPStan/Analyser/data/array-slice.php index 3a863ad22c6..a8a7f390454 100644 --- a/tests/PHPStan/Analyser/data/array-slice.php +++ b/tests/PHPStan/Analyser/data/array-slice.php @@ -23,22 +23,64 @@ 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)); + + /** @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)); } }