From 49e476f18c03a4d6663e4c14b896a8b3f4adb7ea Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 27 Feb 2026 14:03:50 +0100 Subject: [PATCH 1/8] Improve intersection of list and constantArrayType --- src/Type/Constant/ConstantArrayType.php | 57 ++++++++++++++++--- src/Type/IntersectionType.php | 3 +- src/Type/TypeCombinator.php | 14 +++++ tests/PHPStan/Analyser/nsrt/array-chunk.php | 6 +- tests/PHPStan/Analyser/nsrt/array-reverse.php | 2 +- tests/PHPStan/Analyser/nsrt/array_keys.php | 2 +- tests/PHPStan/Analyser/nsrt/array_values.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14177.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-4700.php | 4 +- .../nsrt/constant-array-optional-set.php | 6 +- .../Analyser/nsrt/preg_match_shapes.php | 8 +-- .../Constant/ConstantArrayTypeBuilderTest.php | 6 +- 12 files changed, 83 insertions(+), 29 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index a7e0c62a92..0ad6f9b2cf 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -715,7 +715,7 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $builder->getArray(); } - public function unsetOffset(Type $offsetType): Type + public function unsetOffset(Type $offsetType, bool $preserveListCertainty = false): Type { $offsetType = $offsetType->toArrayKey(); if ($offsetType instanceof ConstantIntegerType || $offsetType instanceof ConstantStringType) { @@ -748,6 +748,7 @@ public function unsetOffset(Type $offsetType): Type $newOptionalKeys, $this->isList, in_array($i, $this->optionalKeys, true), + $preserveListCertainty, ); return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList); @@ -790,6 +791,7 @@ public function unsetOffset(Type $offsetType): Type $optionalKeys, $this->isList, count($optionalKeys) === count($this->optionalKeys), + $preserveListCertainty, ); return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList); @@ -815,6 +817,7 @@ public function unsetOffset(Type $offsetType): Type $optionalKeys, $this->isList, count($optionalKeys) === count($this->optionalKeys), + $preserveListCertainty, ); return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList); @@ -827,7 +830,7 @@ public function unsetOffset(Type $offsetType): Type * @param list $newKeyTypes * @param int[] $newOptionalKeys */ - private static function isListAfterUnset(array $newKeyTypes, array $newOptionalKeys, TrinaryLogic $arrayIsList, bool $unsetOptionalKey): TrinaryLogic + private static function isListAfterUnset(array $newKeyTypes, array $newOptionalKeys, TrinaryLogic $arrayIsList, bool $unsetOptionalKey, bool $preserveListCertainty): TrinaryLogic { if (!$unsetOptionalKey || $arrayIsList->no()) { return TrinaryLogic::createNo(); @@ -851,7 +854,7 @@ private static function isListAfterUnset(array $newKeyTypes, array $newOptionalK } } - return TrinaryLogic::createMaybe(); + return $preserveListCertainty ? $arrayIsList : TrinaryLogic::createMaybe(); } public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type @@ -1531,7 +1534,9 @@ private function getKeysOrValuesArray(array $types): self public function describe(VerbosityLevel $level): string { - $describeValue = function (bool $truncate) use ($level): string { + $arrayName = $this->shouldBeDescribedAsAList() ? 'list' : 'array'; + + $describeValue = function (bool $truncate) use ($level, $arrayName): string { $items = []; $values = []; $exportValuesOnly = true; @@ -1570,18 +1575,36 @@ public function describe(VerbosityLevel $level): string } return sprintf( - 'array{%s%s}', + '%s{%s%s}', + $arrayName, implode(', ', $exportValuesOnly ? $values : $items), $append, ); }; return $level->handle( - fn (): string => $this->isIterableAtLeastOnce()->no() ? 'array' : sprintf('array<%s, %s>', $this->getIterableKeyType()->describe($level), $this->getIterableValueType()->describe($level)), + fn (): string => $this->isIterableAtLeastOnce()->no() ? $arrayName : sprintf('%s<%s, %s>', $arrayName, $this->getIterableKeyType()->describe($level), $this->getIterableValueType()->describe($level)), static fn (): string => $describeValue(true), static fn (): string => $describeValue(false), ); } + private function shouldBeDescribedAsAList(): bool + { + if (!$this->isList->yes()) { + return false; + } + + if (count($this->optionalKeys) === 0) { + return false; + } + + if (count($this->optionalKeys) === 2) { + return true; + } + + return $this->optionalKeys[0] !== count($this->keyTypes) - 1; + } + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap { if ($receivedType instanceof UnionType || $receivedType instanceof IntersectionType) { @@ -1643,11 +1666,11 @@ public function tryRemove(Type $typeToRemove): ?Type } if ($typeToRemove instanceof HasOffsetType) { - return $this->unsetOffset($typeToRemove->getOffsetType()); + return $this->unsetOffset($typeToRemove->getOffsetType(), true); } if ($typeToRemove instanceof HasOffsetValueType) { - return $this->unsetOffset($typeToRemove->getOffsetType()); + return $this->unsetOffset($typeToRemove->getOffsetType(), true); } return null; @@ -1823,6 +1846,19 @@ public function makeOffsetRequired(Type $offsetType): self return $this; } + public function makeList(): Type + { + if ($this->isList->yes()) { + return $this; + } + + if ($this->isList->no()) { + return new NeverType(); + } + + return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createYes()); + } + public function toPhpDocNode(): TypeNode { $items = []; @@ -1863,7 +1899,10 @@ public function toPhpDocNode(): TypeNode ); } - return ArrayShapeNode::createSealed($exportValuesOnly ? $values : $items); + return ArrayShapeNode::createSealed( + $exportValuesOnly ? $values : $items, + $this->shouldBeDescribedAsAList() ? ArrayShapeNode::KIND_LIST : ArrayShapeNode::KIND_ARRAY, + ); } public static function isValidIdentifier(string $value): bool diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 06e3d781ef..8c0d4d151d 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -448,7 +448,8 @@ private function describeItself(VerbosityLevel $level, bool $skipAccessoryTypes) continue; } elseif ($type instanceof ConstantArrayType) { $description = $type->describe($level); - $descriptionWithoutKind = substr($description, strlen('array')); + $kind = str_starts_with($description, 'list') ? 'list' : 'array'; + $descriptionWithoutKind = substr($description, strlen($kind)); $begin = $isList ? 'list' : 'array'; if ($isNonEmptyArray && !$type->isIterableAtLeastOnce()->yes()) { $begin = 'non-empty-' . $begin; diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index aa5f933532..6f564875a1 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1331,6 +1331,20 @@ public static function intersect(Type ...$types): Type continue 2; } + if ($types[$i] instanceof ConstantArrayType && $types[$j] instanceof AccessoryArrayListType) { + $types[$i] = $types[$i]->makeList(); + array_splice($types, $j--, 1); + $typesCount--; + continue; + } + + if ($types[$j] instanceof ConstantArrayType && $types[$i] instanceof AccessoryArrayListType) { + $types[$j] = $types[$j]->makeList(); + array_splice($types, $i--, 1); + $typesCount--; + continue 2; + } + if ( $types[$i] instanceof ConstantArrayType && count($types[$i]->getKeyTypes()) === 1 diff --git a/tests/PHPStan/Analyser/nsrt/array-chunk.php b/tests/PHPStan/Analyser/nsrt/array-chunk.php index cedb50ddb7..645a982dff 100644 --- a/tests/PHPStan/Analyser/nsrt/array-chunk.php +++ b/tests/PHPStan/Analyser/nsrt/array-chunk.php @@ -49,9 +49,9 @@ public function constantArraysWithOptionalKeys(array $arr): void */ public function chunkUnionTypeLength(array $arr, $positiveRange, $positiveUnion) { /** @var array{a: 0, b?: 1, c: 2} $arr */ - assertType('array{0: array{0: 0, 1?: 1|2, 2?: 2}, 1?: array{0?: 2}}', array_chunk($arr, $positiveRange)); + assertType('array{0: list{0: 0, 1?: 1|2, 2?: 2}, 1?: array{0?: 2}}', array_chunk($arr, $positiveRange)); assertType('array{0: array{a: 0, b?: 1, c?: 2}, 1?: array{c?: 2}}', array_chunk($arr, $positiveRange, true)); - assertType('array{0: array{0: 0, 1?: 1|2, 2?: 2}, 1?: array{0?: 2}}', array_chunk($arr, $positiveUnion)); + assertType('array{0: list{0: 0, 1?: 1|2, 2?: 2}, 1?: array{0?: 2}}', array_chunk($arr, $positiveUnion)); assertType('array{0: array{a: 0, b?: 1, c?: 2}, 1?: array{c?: 2}}', array_chunk($arr, $positiveUnion, true)); } @@ -70,7 +70,7 @@ public function lengthIntRanges(array $arr, int $positiveInt, int $bigger50) { */ function testLimits(array $arr, int $oneToFour, int $tooBig) { /** @var array{a: 0, b?: 1, c: 2, d: 3} $arr */ - assertType('array{0: array{0: 0, 1?: 1|2, 2?: 2|3, 3?: 3}, 1?: array{0?: 2|3, 1?: 3}}|array{array{0}, array{0?: 1|2, 1?: 2}, array{0?: 2|3, 1?: 3}, array{0?: 3}}', array_chunk($arr, $oneToFour)); + assertType('array{0: list{0: 0, 1?: 1|2, 2?: 2|3, 3?: 3}, 1?: list{0?: 2|3, 1?: 3}}|array{array{0}, list{0?: 1|2, 1?: 2}, list{0?: 2|3, 1?: 3}, array{0?: 3}}', array_chunk($arr, $oneToFour)); assertType('non-empty-list>', array_chunk($arr, $tooBig)); } diff --git a/tests/PHPStan/Analyser/nsrt/array-reverse.php b/tests/PHPStan/Analyser/nsrt/array-reverse.php index 86a3bb72cf..db05a1d57e 100644 --- a/tests/PHPStan/Analyser/nsrt/array-reverse.php +++ b/tests/PHPStan/Analyser/nsrt/array-reverse.php @@ -49,7 +49,7 @@ public function constantArrays(array $a, array $b, array $c): void assertType('array{\'bar\', \'foo\'}|array{bar: 19, foo: 17}', array_reverse($b)); assertType('array{19: \'bar\', 17: \'foo\'}|array{bar: 19, foo: 17}', array_reverse($b, true)); - assertType("array{0: 'A'|'B'|'C', 1?: 'A'|'B', 2?: 'A'}", array_reverse($c)); + assertType("list{0: 'A'|'B'|'C', 1?: 'A'|'B', 2?: 'A'}", array_reverse($c)); assertType("array{2?: 'C', 1?: 'B', 0: 'A'}", array_reverse($c, true)); } diff --git a/tests/PHPStan/Analyser/nsrt/array_keys.php b/tests/PHPStan/Analyser/nsrt/array_keys.php index 6808bf36b3..ddeebafddd 100644 --- a/tests/PHPStan/Analyser/nsrt/array_keys.php +++ b/tests/PHPStan/Analyser/nsrt/array_keys.php @@ -22,6 +22,6 @@ public function constantArrayType(): void [1 => 'a', 2 => 'b', 3 => 'c'], static fn ($value) => mt_rand(0, 1) === 0, ); - assertType("array{0?: 1|2|3, 1?: 2|3, 2?: 3}", array_keys($numbers)); + assertType("list{0?: 1|2|3, 1?: 2|3, 2?: 3}", array_keys($numbers)); } } diff --git a/tests/PHPStan/Analyser/nsrt/array_values.php b/tests/PHPStan/Analyser/nsrt/array_values.php index 18074963a4..16f22215d8 100644 --- a/tests/PHPStan/Analyser/nsrt/array_values.php +++ b/tests/PHPStan/Analyser/nsrt/array_values.php @@ -35,7 +35,7 @@ public function constantArrayType(): void [1 => 'a', 2 => 'b', 3 => 'c'], static fn ($value) => mt_rand(0, 1) === 0, ); - assertType("array{0?: 'a'|'b'|'c', 1?: 'b'|'c', 2?: 'c'}", array_values($numbers)); + assertType("list{0?: 'a'|'b'|'c', 1?: 'b'|'c', 2?: 'c'}", array_values($numbers)); } /** diff --git a/tests/PHPStan/Analyser/nsrt/bug-14177.php b/tests/PHPStan/Analyser/nsrt/bug-14177.php index 1610725170..416b69aaa8 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14177.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14177.php @@ -14,7 +14,7 @@ public function testList(array $b): void if (array_key_exists(3, $b)) { assertType('list{0: string, 1: string, 2?: string, 3: string}', $b); } else { - assertType('list{0: string, 1: string, 2?: string}', $b); + assertType('array{0: string, 1: string, 2?: string}', $b); } assertType('list{0: string, 1: string, 2?: string, 3?: string}', $b); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-4700.php b/tests/PHPStan/Analyser/nsrt/bug-4700.php index 9d386b0c50..24a680e387 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-4700.php +++ b/tests/PHPStan/Analyser/nsrt/bug-4700.php @@ -19,7 +19,7 @@ function(array $array, int $count): void { if (isset($array['e'])) $a[] = $array['e']; if (count($a) >= $count) { assertType('int<1, 5>', count($a)); - assertType('array{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a); + assertType('list{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a); } else { assertType('0', count($a)); assertType('array{}', $a); @@ -44,6 +44,6 @@ function(array $array, int $count): void { assertType('list{0: mixed~null, 1: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a); } else { assertType('int<0, 5>', count($a)); // Could be int<0, 1> - assertType('array{}|array{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a); // Could be array{}|array{0: mixed~null} + assertType('array{}|list{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a); // Could be array{}|array{0: mixed~null} } }; diff --git a/tests/PHPStan/Analyser/nsrt/constant-array-optional-set.php b/tests/PHPStan/Analyser/nsrt/constant-array-optional-set.php index fe3512a45b..08579c67e4 100644 --- a/tests/PHPStan/Analyser/nsrt/constant-array-optional-set.php +++ b/tests/PHPStan/Analyser/nsrt/constant-array-optional-set.php @@ -17,15 +17,15 @@ public function doFoo() if (rand(0, 1)) { $a[] = 3; } - assertType('array{0: 1, 1?: 2|3, 2?: 3}', $a); + assertType('list{0: 1, 1?: 2|3, 2?: 3}', $a); if (rand(0, 1)) { $a[] = 4; } - assertType('array{0: 1, 1?: 2|3|4, 2?: 3|4, 3?: 4}', $a); + assertType('list{0: 1, 1?: 2|3|4, 2?: 3|4, 3?: 4}', $a); if (rand(0, 1)) { $a[] = 5; } - assertType('array{0: 1, 1?: 2|3|4|5, 2?: 3|4|5, 3?: 4|5, 4?: 5}', $a); + assertType('list{0: 1, 1?: 2|3|4|5, 2?: 3|4|5, 3?: 4|5, 4?: 5}', $a); } public function doBar() diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php index 0a4cc7f6e1..bf26bd5506 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php @@ -239,7 +239,7 @@ function doFoo(string $row): void assertType("array{0: non-falsy-string, 1: non-falsy-string, 2?: 'b'}", $matches); } if (preg_match('~^(a(b)?)?$~', $row, $matches) === 1) { - assertType("array{0: string, 1?: non-falsy-string, 2?: 'b'}", $matches); + assertType("list{0: string, 1?: non-falsy-string, 2?: 'b'}", $matches); } } @@ -286,7 +286,7 @@ function (string $size): void { if (preg_match('~^a\.b(c(\d+)?)?d~', $size, $matches) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType('array{0: non-falsy-string, 1?: non-falsy-string, 2?: numeric-string}', $matches); + assertType('list{0: non-falsy-string, 1?: non-falsy-string, 2?: numeric-string}', $matches); }; function (string $size): void { @@ -346,11 +346,11 @@ function bug11277b(string $value): void // https://3v4l.org/09qdT function bug11291(string $s): void { if (preg_match('/(?|(a)|(b)(c)|(d)(e)(f))/', $s, $matches)) { - assertType('array{0: non-empty-string, 1: non-empty-string, 2?: non-empty-string, 3?: non-empty-string}', $matches); + assertType('list{0: non-empty-string, 1: non-empty-string, 2?: non-empty-string, 3?: non-empty-string}', $matches); } else { assertType('array{}', $matches); } - assertType('array{}|array{0: non-empty-string, 1: non-empty-string, 2?: non-empty-string, 3?: non-empty-string}', $matches); + assertType('array{}|list{0: non-empty-string, 1: non-empty-string, 2?: non-empty-string, 3?: non-empty-string}', $matches); } function bug11323a(string $s): void diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php index 12b8352566..15f4cd8df1 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php @@ -37,7 +37,7 @@ public function testOptionalKeysNextAutoIndex(): void $this->assertTrue($array3->isKeysSupersetOf($array2)); $array2MergedWith3 = $array3->mergeWith($array2); - $this->assertSame('array{0: 1, 1?: 2|3, 2?: 3}', $array2MergedWith3->describe(VerbosityLevel::precise())); + $this->assertSame('list{0: 1, 1?: 2|3, 2?: 3}', $array2MergedWith3->describe(VerbosityLevel::precise())); $this->assertSame([1, 2, 3], $array2MergedWith3->getNextAutoIndexes()); $builder->setOffsetValueType(null, new ConstantIntegerType(4)); @@ -95,10 +95,10 @@ public function testAppendingOptionalKeys(): void $this->assertSame('array{0?: bool}', $builder->getArray()->describe(VerbosityLevel::precise())); $builder->setOffsetValueType(null, new NullType(), true); - $this->assertSame('array{0?: bool|null, 1?: null}', $builder->getArray()->describe(VerbosityLevel::precise())); + $this->assertSame('list{0?: bool|null, 1?: null}', $builder->getArray()->describe(VerbosityLevel::precise())); $builder->setOffsetValueType(null, new ConstantIntegerType(17)); - $this->assertSame('array{0: 17|bool|null, 1?: 17|null, 2?: 17}', $builder->getArray()->describe(VerbosityLevel::precise())); + $this->assertSame('list{0: 17|bool|null, 1?: 17|null, 2?: 17}', $builder->getArray()->describe(VerbosityLevel::precise())); } public function testDegradedArrayIsNotAlwaysOversized(): void From 8df03dc0a0c8ca02941a2deee4eeabeb8fbf4ce0 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 27 Feb 2026 15:00:12 +0100 Subject: [PATCH 2/8] Fix --- tests/PHPStan/Analyser/nsrt/array-chunk.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/array-chunk.php b/tests/PHPStan/Analyser/nsrt/array-chunk.php index 645a982dff..36aa95fca1 100644 --- a/tests/PHPStan/Analyser/nsrt/array-chunk.php +++ b/tests/PHPStan/Analyser/nsrt/array-chunk.php @@ -70,7 +70,7 @@ public function lengthIntRanges(array $arr, int $positiveInt, int $bigger50) { */ function testLimits(array $arr, int $oneToFour, int $tooBig) { /** @var array{a: 0, b?: 1, c: 2, d: 3} $arr */ - assertType('array{0: list{0: 0, 1?: 1|2, 2?: 2|3, 3?: 3}, 1?: list{0?: 2|3, 1?: 3}}|array{array{0}, list{0?: 1|2, 1?: 2}, list{0?: 2|3, 1?: 3}, array{0?: 3}}', array_chunk($arr, $oneToFour)); + assertType('array{0: array{0: 0, 1?: 1|2, 2?: 2|3, 3?: 3}, 1?: list{0?: 2|3, 1?: 3}}|array{array{0}, list{0?: 1|2, 1?: 2}, list{0?: 2|3, 1?: 3}, array{0?: 3}}', array_chunk($arr, $oneToFour)); assertType('non-empty-list>', array_chunk($arr, $tooBig)); } From a355076b666766902213fc83c2ebe178c47dae32 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 27 Feb 2026 15:24:37 +0100 Subject: [PATCH 3/8] Fix --- src/Type/Constant/ConstantArrayType.php | 2 +- tests/PHPStan/Analyser/nsrt/array-chunk.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 0ad6f9b2cf..69a85922b4 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1598,7 +1598,7 @@ private function shouldBeDescribedAsAList(): bool return false; } - if (count($this->optionalKeys) === 2) { + if (count($this->optionalKeys) > 1) { return true; } diff --git a/tests/PHPStan/Analyser/nsrt/array-chunk.php b/tests/PHPStan/Analyser/nsrt/array-chunk.php index 36aa95fca1..645a982dff 100644 --- a/tests/PHPStan/Analyser/nsrt/array-chunk.php +++ b/tests/PHPStan/Analyser/nsrt/array-chunk.php @@ -70,7 +70,7 @@ public function lengthIntRanges(array $arr, int $positiveInt, int $bigger50) { */ function testLimits(array $arr, int $oneToFour, int $tooBig) { /** @var array{a: 0, b?: 1, c: 2, d: 3} $arr */ - assertType('array{0: array{0: 0, 1?: 1|2, 2?: 2|3, 3?: 3}, 1?: list{0?: 2|3, 1?: 3}}|array{array{0}, list{0?: 1|2, 1?: 2}, list{0?: 2|3, 1?: 3}, array{0?: 3}}', array_chunk($arr, $oneToFour)); + assertType('array{0: list{0: 0, 1?: 1|2, 2?: 2|3, 3?: 3}, 1?: list{0?: 2|3, 1?: 3}}|array{array{0}, list{0?: 1|2, 1?: 2}, list{0?: 2|3, 1?: 3}, array{0?: 3}}', array_chunk($arr, $oneToFour)); assertType('non-empty-list>', array_chunk($arr, $tooBig)); } From 749303d9071f5b7b36b05d426e96f30011e519e9 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 27 Feb 2026 15:30:13 +0100 Subject: [PATCH 4/8] Fix --- phpstan-baseline.neon | 2 +- src/Type/IntersectionType.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index d91eb9b78b..2bf54f9c9e 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1680,7 +1680,7 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantArrayType is error-prone and deprecated. Use Type::getConstantArrays() instead.' identifier: phpstanApi.instanceofType - count: 16 + count: 18 path: src/Type/TypeCombinator.php - diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 8c0d4d151d..6ce095420e 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -57,6 +57,7 @@ use function sprintf; use function strcasecmp; use function strlen; +use function str_starts_with; use function substr; use function usort; From b9d204d19c792c5c7ef6e4760b2e6c44d31a2bf7 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 27 Feb 2026 15:52:50 +0100 Subject: [PATCH 5/8] Fix tests --- tests/PHPStan/Type/TypeToPhpDocNodeTest.php | 42 ++++++++++----------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/tests/PHPStan/Type/TypeToPhpDocNodeTest.php b/tests/PHPStan/Type/TypeToPhpDocNodeTest.php index 29bfe8f70a..aec8c53ea9 100644 --- a/tests/PHPStan/Type/TypeToPhpDocNodeTest.php +++ b/tests/PHPStan/Type/TypeToPhpDocNodeTest.php @@ -369,7 +369,8 @@ public static function dataToPhpDocNode(): iterable ]), 'non-empty-array', ]; - $constantArrayWithOptionalKeys = new ConstantArrayType([ + + $listWithOptionalKeys = new ConstantArrayType([ new ConstantIntegerType(0), new ConstantIntegerType(1), new ConstantIntegerType(2), @@ -379,25 +380,14 @@ public static function dataToPhpDocNode(): iterable new StringType(), new StringType(), new StringType(), - ], [3], [2, 3], TrinaryLogic::createMaybe()); - - yield [ - new IntersectionType([ - $constantArrayWithOptionalKeys, - new AccessoryArrayListType(), - ]), - 'list{0: string, 1: string, 2?: string, 3?: string}', - ]; + ], [3], [2, 3], TrinaryLogic::createYes()); yield [ - new IntersectionType([ - $constantArrayWithOptionalKeys, - new AccessoryArrayListType(), - ]), + $listWithOptionalKeys, 'list{0: string, 1: string, 2?: string, 3?: string}', ]; - $constantArrayWithAllOptionalKeys = new ConstantArrayType([ + $listArrayWithAllOptionalKeys = new ConstantArrayType([ new ConstantIntegerType(0), new ConstantIntegerType(1), new ConstantIntegerType(2), @@ -407,25 +397,33 @@ public static function dataToPhpDocNode(): iterable new StringType(), new StringType(), new StringType(), - ], [3], [0, 1, 2, 3], TrinaryLogic::createMaybe()); + ], [3], [0, 1, 2, 3], TrinaryLogic::createYes()); yield [ - new IntersectionType([ - $constantArrayWithAllOptionalKeys, - new AccessoryArrayListType(), - ]), + $listArrayWithAllOptionalKeys, 'list{0?: string, 1?: string, 2?: string, 3?: string}', ]; yield [ new IntersectionType([ - $constantArrayWithAllOptionalKeys, + $listArrayWithAllOptionalKeys, new NonEmptyArrayType(), - new AccessoryArrayListType(), ]), 'non-empty-list{0?: string, 1?: string, 2?: string, 3?: string}', ]; + $constantArrayWithAllOptionalKeys = new ConstantArrayType([ + new ConstantIntegerType(0), + new ConstantIntegerType(1), + new ConstantIntegerType(2), + new ConstantIntegerType(3), + ], [ + new StringType(), + new StringType(), + new StringType(), + new StringType(), + ], [3], [0, 1, 2, 3], TrinaryLogic::createMaybe()); + yield [ new IntersectionType([ $constantArrayWithAllOptionalKeys, From 8c56fba1f81bc69e5b7ca5d784f2ea94b328fb9b Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 27 Feb 2026 16:05:34 +0100 Subject: [PATCH 6/8] Fix --- src/Type/IntersectionType.php | 2 +- tests/PHPStan/Analyser/nsrt/array-column.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 6ce095420e..f68ccb5b64 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -55,9 +55,9 @@ use function is_int; use function ksort; use function sprintf; +use function str_starts_with; use function strcasecmp; use function strlen; -use function str_starts_with; use function substr; use function usort; diff --git a/tests/PHPStan/Analyser/nsrt/array-column.php b/tests/PHPStan/Analyser/nsrt/array-column.php index 4f830b96d9..7049a5130b 100644 --- a/tests/PHPStan/Analyser/nsrt/array-column.php +++ b/tests/PHPStan/Analyser/nsrt/array-column.php @@ -158,8 +158,8 @@ public function testConstantArray12(array $array): void /** @param array{0?: array{column: 'foo1', key: 'bar1'}, 1?: array{column: 'foo2', key: 'bar2'}} $array */ public function testConstantArray13(array $array): void { - assertType("array{0?: 'foo1'|'foo2', 1?: 'foo2'}", array_column($array, 'column')); - assertType("array{0?: 'foo1'|'foo2', 1?: 'foo2'}", array_column($array, 'column', null)); + assertType("list{0?: 'foo1'|'foo2', 1?: 'foo2'}", array_column($array, 'column')); + assertType("list{0?: 'foo1'|'foo2', 1?: 'foo2'}", array_column($array, 'column', null)); assertType("array{bar1?: 'foo1', bar2?: 'foo2'}", array_column($array, 'column', 'key')); } From 65797b96a96b5e15205b7603b348abfc2ef2fa74 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 27 Feb 2026 16:31:39 +0100 Subject: [PATCH 7/8] Correctly consider impossible list --- src/Type/Constant/ConstantArrayType.php | 22 +++++++++++++++++----- tests/PHPStan/Analyser/nsrt/bug-14177.php | 21 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 69a85922b4..e70c2d227b 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -748,8 +748,12 @@ public function unsetOffset(Type $offsetType, bool $preserveListCertainty = fals $newOptionalKeys, $this->isList, in_array($i, $this->optionalKeys, true), - $preserveListCertainty, ); + if (!$preserveListCertainty) { + $newIsList = $newIsList->and(TrinaryLogic::createMaybe()); + } elseif ($this->isList->yes() && $newIsList->no()) { + return new NeverType(); + } return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList); } @@ -791,8 +795,12 @@ public function unsetOffset(Type $offsetType, bool $preserveListCertainty = fals $optionalKeys, $this->isList, count($optionalKeys) === count($this->optionalKeys), - $preserveListCertainty, ); + if (!$preserveListCertainty) { + $newIsList = $newIsList->and(TrinaryLogic::createMaybe()); + } elseif ($this->isList->yes() && $newIsList->no()) { + return new NeverType(); + } return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList); } @@ -817,8 +825,12 @@ public function unsetOffset(Type $offsetType, bool $preserveListCertainty = fals $optionalKeys, $this->isList, count($optionalKeys) === count($this->optionalKeys), - $preserveListCertainty, ); + if (!$preserveListCertainty) { + $newIsList = $newIsList->and(TrinaryLogic::createMaybe()); + } elseif ($this->isList->yes() && $newIsList->no()) { + return new NeverType(); + } return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList); } @@ -830,7 +842,7 @@ public function unsetOffset(Type $offsetType, bool $preserveListCertainty = fals * @param list $newKeyTypes * @param int[] $newOptionalKeys */ - private static function isListAfterUnset(array $newKeyTypes, array $newOptionalKeys, TrinaryLogic $arrayIsList, bool $unsetOptionalKey, bool $preserveListCertainty): TrinaryLogic + private static function isListAfterUnset(array $newKeyTypes, array $newOptionalKeys, TrinaryLogic $arrayIsList, bool $unsetOptionalKey): TrinaryLogic { if (!$unsetOptionalKey || $arrayIsList->no()) { return TrinaryLogic::createNo(); @@ -854,7 +866,7 @@ private static function isListAfterUnset(array $newKeyTypes, array $newOptionalK } } - return $preserveListCertainty ? $arrayIsList : TrinaryLogic::createMaybe(); + return $arrayIsList; } public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type diff --git a/tests/PHPStan/Analyser/nsrt/bug-14177.php b/tests/PHPStan/Analyser/nsrt/bug-14177.php index 416b69aaa8..53261d163e 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14177.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14177.php @@ -200,4 +200,25 @@ public function testUnsetInt(array $a, array $b, array $c, int $int): void assertType('bool', array_is_list($a)); assertType('false', array_is_list($b)); } + + /** + * @param list{0?: string, 1?: string, 2?: string} $l + */ + public function testFoo($l): void + { + if (array_key_exists(2, $l, true)) { + assertType('true', array_is_list($l)); + assertType('list{0?: string, 1?: string, 2: string}', $l); + if (array_key_exists(1, $l, true)) { + assertType('true', array_is_list($l)); + assertType('list{0?: string, 1: string, 2: string}', $l); + } else { + assertType('true', array_is_list($l)); + assertType('*NEVER*', $l); + } + } else { + assertType('true', array_is_list($l)); + assertType('list{0?: string, 1?: string}', $l); + } + } } From c97b61d20f50fae2d325f5ba2694559d1dfd3fa0 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 27 Feb 2026 14:12:19 +0100 Subject: [PATCH 8/8] WIP --- src/Type/Constant/ConstantArrayType.php | 17 ++++++++++++-- tests/PHPStan/Analyser/nsrt/bug-14177.php | 2 +- .../PHPStan/Rules/Variables/IssetRuleTest.php | 16 ++++++++++++++ .../Variables/data/isset-constant-array.php | 22 +++++++++++++++++++ 4 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Rules/Variables/data/isset-constant-array.php diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index e70c2d227b..3ea57af380 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1840,18 +1840,31 @@ public function makeOffsetRequired(Type $offsetType): self { $offsetType = $offsetType->toArrayKey(); $optionalKeys = $this->optionalKeys; + $isList = $this->isList->yes(); foreach ($this->keyTypes as $i => $keyType) { if (!$keyType->equals($offsetType)) { continue; } + $keyValue = $keyType->getValue(); foreach ($optionalKeys as $j => $key) { - if ($i === $key) { + if ( + $i === $key + || ( + $isList + && is_int($keyValue) + && is_int($this->keyTypes[$key]->getValue()) + && $this->keyTypes[$key]->getValue() < $keyValue + ) + ) { unset($optionalKeys[$j]); - return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, array_values($optionalKeys), $this->isList); } } + if (count($this->optionalKeys) !== count($optionalKeys)) { + return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, array_values($optionalKeys), $this->isList); + } + break; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14177.php b/tests/PHPStan/Analyser/nsrt/bug-14177.php index 53261d163e..c586b65429 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14177.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14177.php @@ -12,7 +12,7 @@ class HelloWorld public function testList(array $b): void { if (array_key_exists(3, $b)) { - assertType('list{0: string, 1: string, 2?: string, 3: string}', $b); + assertType('array{string, string, string, string}', $b); } else { assertType('array{0: string, 1: string, 2?: string}', $b); } diff --git a/tests/PHPStan/Rules/Variables/IssetRuleTest.php b/tests/PHPStan/Rules/Variables/IssetRuleTest.php index fac3234530..20fdeb99d2 100644 --- a/tests/PHPStan/Rules/Variables/IssetRuleTest.php +++ b/tests/PHPStan/Rules/Variables/IssetRuleTest.php @@ -499,6 +499,22 @@ public function testPr4374(): void ]); } + public function testIssetConstantArray(): void + { + $this->treatPhpDocTypesAsCertain = true; + + $this->analyse([__DIR__ . '/data/isset-constant-array.php'], [ + [ + 'Offset 2 on array{0: string, 1: string, 2: string, 3: string, 4?: string} in isset() always exists and is not nullable.', + 13, + ], + [ + 'Offset 3 on array{string, string, string, string, string} in isset() always exists and is not nullable.', + 17, + ], + ]); + } + public function testBug10640(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Variables/data/isset-constant-array.php b/tests/PHPStan/Rules/Variables/data/isset-constant-array.php new file mode 100644 index 0000000000..44047da47a --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/isset-constant-array.php @@ -0,0 +1,22 @@ +