From 625069a9df6455f34dded97ecd271dab2288f4db Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 31 Oct 2025 15:46:48 +0100 Subject: [PATCH 01/11] Improve `count` on `list` with greater/smaller-than --- tests/PHPStan/Analyser/nsrt/bug-11642.php | 1 + tests/PHPStan/Analyser/nsrt/bug-13747.php | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13747.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-11642.php b/tests/PHPStan/Analyser/nsrt/bug-11642.php index 520cf772bf..7c72706fe5 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11642.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11642.php @@ -34,6 +34,7 @@ function doFoo() { if (count($entries) !== count($payload->ids)) { exit(); } + assertType('int<1, max>', count($entries)); assertType('non-empty-list', $entries); if (count($entries) > 3) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-13747.php b/tests/PHPStan/Analyser/nsrt/bug-13747.php new file mode 100644 index 0000000000..49226871d6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13747.php @@ -0,0 +1,21 @@ + $list */ + public function sayHello($list): void + { + if (count($list) === 0) { + return; + } + + if (count($list) > 2) { + assertType('non-empty-list&hasOffsetValue(0, int)&hasOffsetValue(1, int)&hasOffsetValue(2, int)', $list); + } else { + assertType('non-empty-list', $list); + } + } +} From 0ea1387b2cbe9f445da0a840692e51fd90e1c073 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 31 Oct 2025 15:47:57 +0100 Subject: [PATCH 02/11] Update bug-13747.php --- tests/PHPStan/Analyser/nsrt/bug-13747.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13747.php b/tests/PHPStan/Analyser/nsrt/bug-13747.php index 49226871d6..258522938f 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13747.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13747.php @@ -13,7 +13,7 @@ public function sayHello($list): void } if (count($list) > 2) { - assertType('non-empty-list&hasOffsetValue(0, int)&hasOffsetValue(1, int)&hasOffsetValue(2, int)', $list); + assertType('non-empty-list&hasOffsetValue(1, int)&hasOffsetValue(2, int)', $list); } else { assertType('non-empty-list', $list); } From 9a77ee3e27a0d33c319d37290e4c89862cecb9e9 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 31 Oct 2025 15:51:46 +0100 Subject: [PATCH 03/11] Update bug-13747.php --- tests/PHPStan/Analyser/nsrt/bug-13747.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13747.php b/tests/PHPStan/Analyser/nsrt/bug-13747.php index 258522938f..cd86d5a6fc 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13747.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13747.php @@ -1,6 +1,7 @@ Date: Fri, 31 Oct 2025 15:55:07 +0100 Subject: [PATCH 04/11] Update bug-13747.php --- tests/PHPStan/Analyser/nsrt/bug-13747.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13747.php b/tests/PHPStan/Analyser/nsrt/bug-13747.php index cd86d5a6fc..c1b2227e78 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13747.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13747.php @@ -15,6 +15,7 @@ public function sayHello($list): void if (count($list) > 2) { assertType('non-empty-list&hasOffsetValue(1, int)&hasOffsetValue(2, int)', $list); + assertType('int<3, max>', count($list)); } else { assertType('non-empty-list', $list); } From 6d4ac7443e494c99747d29ddb2dcd728a67fd61e Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 31 Oct 2025 16:05:12 +0100 Subject: [PATCH 05/11] more tests --- tests/PHPStan/Analyser/nsrt/bug-13747.php | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13747.php b/tests/PHPStan/Analyser/nsrt/bug-13747.php index c1b2227e78..95aef79847 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13747.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13747.php @@ -20,4 +20,35 @@ public function sayHello($list): void assertType('non-empty-list', $list); } } + + /** @param list $list */ + public function doFoo($list): void + { + if (count($list) === 0) { + return; + } + + if (count($list) >= 2) { + assertType('non-empty-list&hasOffsetValue(1, int)', $list); + assertType('int<2, max>', count($list)); + } else { + assertType('non-empty-list', $list); + } + } + + /** @param list $list */ + public function doBar($list): void + { + if (count($list) === 0) { + return; + } + + if (2 <= count($list)) { + assertType('non-empty-list&hasOffsetValue(1, int)', $list); + assertType('int<2, max>', count($list)); + } else { + assertType('non-empty-list', $list); + assertType('1', count($list)); + } + } } From cb6e0ed666f67b9231acb6af39f97172feceb3d5 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 31 Oct 2025 21:16:24 +0100 Subject: [PATCH 06/11] test limits --- tests/PHPStan/Analyser/nsrt/bug-13747.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13747.php b/tests/PHPStan/Analyser/nsrt/bug-13747.php index 95aef79847..7b01ad7b42 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13747.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13747.php @@ -51,4 +51,27 @@ public function doBar($list): void assertType('1', count($list)); } } + + /** @param list $list */ + public function checkLimit($list): void + { + if (count($list) === 0) { + return; + } + + if (count($list) > 9) { + assertType('non-empty-list&hasOffsetValue(1, int)&hasOffsetValue(2, int)&hasOffsetValue(3, int)&hasOffsetValue(4, int)&hasOffsetValue(5, int)&hasOffsetValue(6, int)&hasOffsetValue(7, int)&hasOffsetValue(8, int)&hasOffsetValue(9, int)', $list); + assertType('int<10, max>', count($list)); + } else { + assertType('non-empty-list', $list); + } + + if (count($list) > 10) { + assertType('non-empty-list&hasOffsetValue(1, int)&hasOffsetValue(2, int)&hasOffsetValue(3, int)&hasOffsetValue(4, int)&hasOffsetValue(5, int)&hasOffsetValue(6, int)&hasOffsetValue(7, int)&hasOffsetValue(8, int)&hasOffsetValue(9, int)', $list); + assertType('int<11, max>', count($list)); + } else { + assertType('non-empty-list', $list); + } + + } } From e715e6a02bee40c468b4cd69b92920082782b10a Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 2 Nov 2025 11:43:45 +0100 Subject: [PATCH 07/11] Update bug-13747.php --- tests/PHPStan/Analyser/nsrt/bug-13747.php | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13747.php b/tests/PHPStan/Analyser/nsrt/bug-13747.php index 7b01ad7b42..67856d794c 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13747.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13747.php @@ -7,7 +7,7 @@ class HelloWorld { /** @param list $list */ - public function sayHello($list): void + public function count($list): void { if (count($list) === 0) { return; @@ -19,6 +19,22 @@ public function sayHello($list): void } else { assertType('non-empty-list', $list); } + assertType('non-empty-list', $list); + + if (count($list, COUNT_NORMAL) > 2) { + assertType('non-empty-list&hasOffsetValue(1, int)&hasOffsetValue(2, int)', $list); + assertType('int<3, max>', count($list)); + } else { + assertType('non-empty-list', $list); + } + + assertType('non-empty-list', $list); + if (count($list, COUNT_RECURSIVE) > 2) { + assertType('non-empty-list', $list); + assertType('int<1, max>', count($list)); + } else { + assertType('non-empty-list', $list); + } } /** @param list $list */ From c42265b239d38fddf5682745697e2c2f15ee8a48 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 2 Nov 2025 12:22:00 +0100 Subject: [PATCH 08/11] test different count() variants --- tests/PHPStan/Analyser/nsrt/bug-13747.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13747.php b/tests/PHPStan/Analyser/nsrt/bug-13747.php index 67856d794c..98ca6f6a91 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13747.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13747.php @@ -23,15 +23,15 @@ public function count($list): void if (count($list, COUNT_NORMAL) > 2) { assertType('non-empty-list&hasOffsetValue(1, int)&hasOffsetValue(2, int)', $list); - assertType('int<3, max>', count($list)); + assertType('int<3, max>', count($list, COUNT_NORMAL)); } else { assertType('non-empty-list', $list); } assertType('non-empty-list', $list); - if (count($list, COUNT_RECURSIVE) > 2) { - assertType('non-empty-list', $list); - assertType('int<1, max>', count($list)); + if (count($list, COUNT_RECURSIVE) > 2) { // COUNT_RECURSIVE on non-recursive array + assertType('non-empty-list&hasOffsetValue(1, int)&hasOffsetValue(2, int)', $list); + assertType('int<3, max>', count($list, COUNT_RECURSIVE)); } else { assertType('non-empty-list', $list); } From adedb125d9466c1da208ffe3e3db463bacf81b8e Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 6 Dec 2025 14:48:52 +0100 Subject: [PATCH 09/11] fix --- src/Analyser/LegacyTypeSpecifier.php | 25 ++++++++++++++++++- src/Type/IntersectionType.php | 25 +++++++++++++++++-- tests/PHPStan/Analyser/nsrt/bug-13747.php | 6 +++++ .../PHPStan/Analyser/nsrt/count-recursive.php | 12 ++++----- 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/Analyser/LegacyTypeSpecifier.php b/src/Analyser/LegacyTypeSpecifier.php index aadef4281e..c2cfa70c73 100644 --- a/src/Analyser/LegacyTypeSpecifier.php +++ b/src/Analyser/LegacyTypeSpecifier.php @@ -38,6 +38,7 @@ use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\Accessory\HasOffsetType; +use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\Accessory\HasPropertyType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; @@ -92,6 +93,8 @@ final class LegacyTypeSpecifier implements TypeSpecifier { + private const MAX_ACCESSORIES_LIMIT = 8; + /** @var MethodTypeSpecifyingExtension[][]|null */ private ?array $methodTypeSpecifyingExtensionsByClass = null; @@ -1189,7 +1192,27 @@ private function specifyTypesForCountFuncCall( $builderData[] = [$offsetType, $arrayType->getOffsetValueType($offsetType), !$hasOffset->yes()]; } } else { - $resultTypes[] = TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + $intersection = []; + $intersection[] = $arrayType; + $intersection[] = new NonEmptyArrayType(); + + $zero = new ConstantIntegerType(0); + $i = 0; + foreach ($builderData as [$offsetType, $valueType]) { + // non-empty-list already implies the offset 0 + if ($zero->isSuperTypeOf($offsetType)->yes()) { + continue; + } + + if ($i > self::MAX_ACCESSORIES_LIMIT) { + break; + } + + $intersection[] = new HasOffsetValueType($offsetType, $valueType); + $i++; + } + + $resultTypes[] = TypeCombinator::intersect(...$intersection); continue; } diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index cd66832682..ca88d3b842 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -691,6 +691,10 @@ public function getArraySize(): Type $knownOffsets[$type->getOffsetType()->getValue()] = true; } + if ($this->isIterableAtLeastOnce()->yes()) { + $knownOffsets[0] = true; + } + if ($knownOffsets !== []) { return TypeCombinator::intersect($arraySize, IntegerRangeType::fromInterval(count($knownOffsets), null)); } @@ -830,9 +834,26 @@ public function isOffsetAccessLegal(): TrinaryLogic public function hasOffsetValueType(Type $offsetType): TrinaryLogic { - if ($this->isList()->yes() && $this->isIterableAtLeastOnce()->yes()) { + if ($this->isList()->yes()) { $arrayKeyOffsetType = $offsetType->toArrayKey(); - if ((new ConstantIntegerType(0))->isSuperTypeOf($arrayKeyOffsetType)->yes()) { + + $negative = IntegerRangeType::fromInterval(null, -1); + if ($negative->isSuperTypeOf($arrayKeyOffsetType)->yes()) { + return TrinaryLogic::createNo(); + } + + $size = $this->getArraySize(); + if ($size instanceof IntegerRangeType && $size->getMin() !== null) { + $knownOffsets = IntegerRangeType::fromInterval(0, $size->getMin()); + } elseif ($size instanceof ConstantIntegerType) { + $knownOffsets = IntegerRangeType::fromInterval(0, $size->getValue()); + } elseif ($this->isIterableAtLeastOnce()->yes()) { + $knownOffsets = new ConstantIntegerType(0); + } else { + $knownOffsets = null; + } + + if ($knownOffsets !== null && $knownOffsets->isSuperTypeOf($arrayKeyOffsetType)->yes()) { return TrinaryLogic::createYes(); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-13747.php b/tests/PHPStan/Analyser/nsrt/bug-13747.php index 98ca6f6a91..e8a033f170 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13747.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13747.php @@ -14,6 +14,12 @@ public function count($list): void } if (count($list) > 2) { + assertType('false', array_key_exists(-1, $list)); + assertType('true', array_key_exists(0, $list)); + assertType('true', array_key_exists(1, $list)); + assertType('true', array_key_exists(2, $list)); + assertType('bool', array_key_exists(3, $list)); + assertType('non-empty-list&hasOffsetValue(1, int)&hasOffsetValue(2, int)', $list); assertType('int<3, max>', count($list)); } else { diff --git a/tests/PHPStan/Analyser/nsrt/count-recursive.php b/tests/PHPStan/Analyser/nsrt/count-recursive.php index 1725b929fd..12b46d9a59 100644 --- a/tests/PHPStan/Analyser/nsrt/count-recursive.php +++ b/tests/PHPStan/Analyser/nsrt/count-recursive.php @@ -84,8 +84,8 @@ public function countList($list): void { if (count($list) > 2) { assertType('int<3, max>', count($list)); - assertType('int<1, max>', count($list, COUNT_NORMAL)); - assertType('int<1, max>', count($list, COUNT_RECURSIVE)); + assertType('int<2, max>', count($list, COUNT_NORMAL)); + assertType('int<2, max>', count($list, COUNT_RECURSIVE)); } } @@ -93,9 +93,9 @@ public function countList($list): void public function countListNormal($list): void { if (count($list, COUNT_NORMAL) > 2) { - assertType('int<1, max>', count($list)); + assertType('int<2, max>', count($list)); assertType('int<3, max>', count($list, COUNT_NORMAL)); - assertType('int<1, max>', count($list, COUNT_RECURSIVE)); + assertType('int<2, max>', count($list, COUNT_RECURSIVE)); } } @@ -124,8 +124,8 @@ public function countMixed($arr, $mode): void public function countListRecursive($list): void { if (count($list, COUNT_RECURSIVE) > 2) { - assertType('int<1, max>', count($list)); - assertType('int<1, max>', count($list, COUNT_NORMAL)); + assertType('int<2, max>', count($list)); + assertType('int<2, max>', count($list, COUNT_NORMAL)); assertType('int<3, max>', count($list, COUNT_RECURSIVE)); } } From 96b6caf92bb5cf6a4272b7f01f2a5fef3ebeb422 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 6 Dec 2025 14:56:00 +0100 Subject: [PATCH 10/11] fix --- src/Type/IntersectionType.php | 4 ++-- tests/PHPStan/Analyser/nsrt/count-recursive.php | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index ca88d3b842..0e692dbae9 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -691,7 +691,7 @@ public function getArraySize(): Type $knownOffsets[$type->getOffsetType()->getValue()] = true; } - if ($this->isIterableAtLeastOnce()->yes()) { + if ($this->isList()->yes() && $this->isIterableAtLeastOnce()->yes()) { $knownOffsets[0] = true; } @@ -844,7 +844,7 @@ public function hasOffsetValueType(Type $offsetType): TrinaryLogic $size = $this->getArraySize(); if ($size instanceof IntegerRangeType && $size->getMin() !== null) { - $knownOffsets = IntegerRangeType::fromInterval(0, $size->getMin()); + $knownOffsets = IntegerRangeType::fromInterval(0, $size->getMin() - 1); } elseif ($size instanceof ConstantIntegerType) { $knownOffsets = IntegerRangeType::fromInterval(0, $size->getValue()); } elseif ($this->isIterableAtLeastOnce()->yes()) { diff --git a/tests/PHPStan/Analyser/nsrt/count-recursive.php b/tests/PHPStan/Analyser/nsrt/count-recursive.php index 12b46d9a59..b8539e54da 100644 --- a/tests/PHPStan/Analyser/nsrt/count-recursive.php +++ b/tests/PHPStan/Analyser/nsrt/count-recursive.php @@ -84,8 +84,8 @@ public function countList($list): void { if (count($list) > 2) { assertType('int<3, max>', count($list)); - assertType('int<2, max>', count($list, COUNT_NORMAL)); - assertType('int<2, max>', count($list, COUNT_RECURSIVE)); + assertType('int<3, max>', count($list, COUNT_NORMAL)); + assertType('int<3, max>', count($list, COUNT_RECURSIVE)); } } @@ -93,9 +93,9 @@ public function countList($list): void public function countListNormal($list): void { if (count($list, COUNT_NORMAL) > 2) { - assertType('int<2, max>', count($list)); + assertType('int<3, max>', count($list)); assertType('int<3, max>', count($list, COUNT_NORMAL)); - assertType('int<2, max>', count($list, COUNT_RECURSIVE)); + assertType('int<3, max>', count($list, COUNT_RECURSIVE)); } } @@ -124,8 +124,8 @@ public function countMixed($arr, $mode): void public function countListRecursive($list): void { if (count($list, COUNT_RECURSIVE) > 2) { - assertType('int<2, max>', count($list)); - assertType('int<2, max>', count($list, COUNT_NORMAL)); + assertType('int<3, max>', count($list)); + assertType('int<3, max>', count($list, COUNT_NORMAL)); assertType('int<3, max>', count($list, COUNT_RECURSIVE)); } } From 0e92d060325c1dd51359ffd941a51ac00ad34aa7 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 6 Dec 2025 14:58:31 +0100 Subject: [PATCH 11/11] Update IntersectionType.php --- src/Type/IntersectionType.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 0e692dbae9..3aa4a9a2d8 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -846,7 +846,7 @@ public function hasOffsetValueType(Type $offsetType): TrinaryLogic if ($size instanceof IntegerRangeType && $size->getMin() !== null) { $knownOffsets = IntegerRangeType::fromInterval(0, $size->getMin() - 1); } elseif ($size instanceof ConstantIntegerType) { - $knownOffsets = IntegerRangeType::fromInterval(0, $size->getValue()); + $knownOffsets = IntegerRangeType::fromInterval(0, $size->getValue() - 1); } elseif ($this->isIterableAtLeastOnce()->yes()) { $knownOffsets = new ConstantIntegerType(0); } else {