From 2f6d3699ddfc87a5ae3e724fbf1a3236ac690058 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 31 Oct 2025 15:46:48 +0100 Subject: [PATCH 01/10] Improve `count` on `list` with greater/smaller-than --- src/Analyser/TypeSpecifier.php | 19 ++++++++++++++++++- tests/PHPStan/Analyser/nsrt/bug-11642.php | 1 + tests/PHPStan/Analyser/nsrt/bug-13747.php | 21 +++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13747.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 6698a8a800..1cbc551166 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.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; @@ -1197,7 +1198,23 @@ 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); + foreach ($builderData as [$offsetType, $valueType, $optional]) { + if ( + $optional + // list already is non-empty + || $zero->isSuperTypeOf($offsetType)->yes() + ) { + continue; + } + $intersection[] = new HasOffsetValueType($offsetType, $valueType); + } + + $resultTypes[] = TypeCombinator::intersect(...$intersection); continue; } 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 a34755e754d4592dec831e0831070585d32016ae Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 31 Oct 2025 15:47:57 +0100 Subject: [PATCH 02/10] 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 b2494e86a113840e49f35ad6d4f8b10ace4bdc38 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 31 Oct 2025 15:49:32 +0100 Subject: [PATCH 03/10] Update TypeSpecifier.php --- src/Analyser/TypeSpecifier.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 1cbc551166..8e714592dd 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1203,12 +1203,8 @@ private function specifyTypesForCountFuncCall( $intersection[] = new NonEmptyArrayType(); $zero = new ConstantIntegerType(0); - foreach ($builderData as [$offsetType, $valueType, $optional]) { - if ( - $optional - // list already is non-empty - || $zero->isSuperTypeOf($offsetType)->yes() - ) { + foreach ($builderData as [$offsetType, $valueType]) { + if ($zero->isSuperTypeOf($offsetType)->yes()) { continue; } $intersection[] = new HasOffsetValueType($offsetType, $valueType); From 3501ad109496a6556ce893657cf7f41aa60a654e Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 31 Oct 2025 15:51:46 +0100 Subject: [PATCH 04/10] 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 05/10] 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 4ade53d249d2b49bf8d1c3ca36004909ac8399da Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 31 Oct 2025 15:56:46 +0100 Subject: [PATCH 06/10] Update TypeSpecifier.php --- src/Analyser/TypeSpecifier.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 8e714592dd..de80826761 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1204,6 +1204,7 @@ private function specifyTypesForCountFuncCall( $zero = new ConstantIntegerType(0); foreach ($builderData as [$offsetType, $valueType]) { + // non-empty-list already implies the offset 0 if ($zero->isSuperTypeOf($offsetType)->yes()) { continue; } From b9ebfb85867ddc0e3b8339a26551950289c449e9 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 31 Oct 2025 16:05:12 +0100 Subject: [PATCH 07/10] 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 7666ba406a16a9fb6a262da5274dff50f285f407 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 31 Oct 2025 21:16:24 +0100 Subject: [PATCH 08/10] test limits --- src/Analyser/TypeSpecifier.php | 9 +++++++++ tests/PHPStan/Analyser/nsrt/bug-13747.php | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index de80826761..48d9494406 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -93,6 +93,8 @@ final class TypeSpecifier { + private const MAX_ACCESSORIES_LIMIT = 8; + /** @var MethodTypeSpecifyingExtension[][]|null */ private ?array $methodTypeSpecifyingExtensionsByClass = null; @@ -1203,12 +1205,19 @@ private function specifyTypesForCountFuncCall( $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); 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 335960e0a61b467d8e553f9fe3020c2dcebecbf7 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 2 Nov 2025 11:43:45 +0100 Subject: [PATCH 09/10] 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 fc8643eff1ac0f0bd8657404a81bed3b683d4798 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 2 Nov 2025 12:22:00 +0100 Subject: [PATCH 10/10] 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); }