From f014ded2f8ca0056f5710879f1cdc95b7dc98ba5 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 2 Nov 2025 18:24:17 +0100 Subject: [PATCH 01/10] `count(non-empty-array, COUNT_RECURSIVE)` is `int<1, max>` --- .../Php/CountFunctionReturnTypeExtension.php | 22 +++++-- .../PHPStan/Analyser/nsrt/count-recursive.php | 66 +++++++++++++++++++ 2 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/count-recursive.php diff --git a/src/Type/Php/CountFunctionReturnTypeExtension.php b/src/Type/Php/CountFunctionReturnTypeExtension.php index b87d34c466..4b257a00f7 100644 --- a/src/Type/Php/CountFunctionReturnTypeExtension.php +++ b/src/Type/Php/CountFunctionReturnTypeExtension.php @@ -6,8 +6,10 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; +use PHPStan\TrinaryLogic; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; +use PHPStan\Type\IntegerRangeType; use PHPStan\Type\Type; use function count; use function in_array; @@ -32,14 +34,26 @@ public function getTypeFromFunctionCall( return null; } - if (count($functionCall->getArgs()) > 1) { - $mode = $scope->getType($functionCall->getArgs()[1]->value); - if ($mode->isSuperTypeOf(new ConstantIntegerType(COUNT_RECURSIVE))->yes()) { - return null; + $arrayType = $scope->getType($functionCall->getArgs()[0]->value); + if (!$this->isNormalCount($functionCall, $arrayType, $scope)->yes()) { + if ($arrayType->isIterableAtLeastOnce()->yes()) { + return IntegerRangeType::fromInterval(1, null); } + return null; } return $scope->getType($functionCall->getArgs()[0]->value)->getArraySize(); } + private function isNormalCount(FuncCall $countFuncCall, Type $countedType, Scope $scope,): TrinaryLogic + { + if (count($countFuncCall->getArgs()) === 1) { + $isNormalCount = TrinaryLogic::createYes(); + } else { + $mode = $scope->getType($countFuncCall->getArgs()[1]->value); + $isNormalCount = (new ConstantIntegerType(COUNT_NORMAL))->isSuperTypeOf($mode)->result->or($countedType->getIterableValueType()->isArray()->negate()); + } + return $isNormalCount; + } + } diff --git a/tests/PHPStan/Analyser/nsrt/count-recursive.php b/tests/PHPStan/Analyser/nsrt/count-recursive.php new file mode 100644 index 0000000000..283fbbff9c --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/count-recursive.php @@ -0,0 +1,66 @@ + 2) { + assertType('non-empty-array', $arr); + assertType('int<3, max>', count($arr)); + assertType('int<1, max>', count($arr, COUNT_NORMAL)); // could be int<3, max> + assertType('int<1, max>', count($arr, COUNT_RECURSIVE)); + } + } + + public function countArrayNormal(array $arr): void + { + if (count($arr, COUNT_NORMAL) > 2) { + assertType('non-empty-array', $arr); + assertType('int<1, max>', count($arr)); // could be int<3, max> + assertType('int<3, max>', count($arr, COUNT_NORMAL)); + assertType('int<1, max>', count($arr, COUNT_RECURSIVE)); + } + } + + public function countArrayRecursive(array $arr): void + { + if (count($arr, COUNT_RECURSIVE) > 2) { + assertType('non-empty-array', $arr); + assertType('int<1, max>', count($arr)); + assertType('int<1, max>', count($arr, COUNT_NORMAL)); + assertType('int<3, max>', count($arr, COUNT_RECURSIVE)); + } + } + + /** @param list $list */ + 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)); + } + } + + /** @param list $list */ + public function countListNormal($list): void + { + if (count($list, COUNT_NORMAL) > 2) { + assertType('int<1, max>', count($list)); + assertType('int<3, max>', count($list, COUNT_NORMAL)); + assertType('int<1, max>', count($list, COUNT_RECURSIVE)); + } + } + + /** @param list $list */ + 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<3, max>', count($list, COUNT_RECURSIVE)); + } + } +} From bef663fd8a8ba53d5ed34e2ba64540e4c689cf2c Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 2 Nov 2025 18:28:02 +0100 Subject: [PATCH 02/10] Update count-recursive.php --- tests/PHPStan/Analyser/nsrt/count-recursive.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/count-recursive.php b/tests/PHPStan/Analyser/nsrt/count-recursive.php index 283fbbff9c..9795bdecc2 100644 --- a/tests/PHPStan/Analyser/nsrt/count-recursive.php +++ b/tests/PHPStan/Analyser/nsrt/count-recursive.php @@ -1,5 +1,7 @@ Date: Sun, 2 Nov 2025 18:34:07 +0100 Subject: [PATCH 03/10] cs --- src/Type/Php/CountFunctionReturnTypeExtension.php | 11 ++++++----- .../PHPStan/Analyser/LegacyNodeScopeResolverTest.php | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Type/Php/CountFunctionReturnTypeExtension.php b/src/Type/Php/CountFunctionReturnTypeExtension.php index 4b257a00f7..7b3a5e2e30 100644 --- a/src/Type/Php/CountFunctionReturnTypeExtension.php +++ b/src/Type/Php/CountFunctionReturnTypeExtension.php @@ -13,7 +13,7 @@ use PHPStan\Type\Type; use function count; use function in_array; -use const COUNT_RECURSIVE; +use const COUNT_NORMAL; #[AutowiredService] final class CountFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension @@ -30,11 +30,12 @@ public function getTypeFromFunctionCall( Scope $scope, ): ?Type { - if (count($functionCall->getArgs()) < 1) { + $args = $functionCall->getArgs(); + if (count($args) < 1) { return null; } - $arrayType = $scope->getType($functionCall->getArgs()[0]->value); + $arrayType = $scope->getType($args[0]->value); if (!$this->isNormalCount($functionCall, $arrayType, $scope)->yes()) { if ($arrayType->isIterableAtLeastOnce()->yes()) { return IntegerRangeType::fromInterval(1, null); @@ -42,10 +43,10 @@ public function getTypeFromFunctionCall( return null; } - return $scope->getType($functionCall->getArgs()[0]->value)->getArraySize(); + return $scope->getType($args[0]->value)->getArraySize(); } - private function isNormalCount(FuncCall $countFuncCall, Type $countedType, Scope $scope,): TrinaryLogic + private function isNormalCount(FuncCall $countFuncCall, Type $countedType, Scope $scope): TrinaryLogic { if (count($countFuncCall->getArgs()) === 1) { $isNormalCount = TrinaryLogic::createYes(); diff --git a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php index c9fe4f942a..843786a0e5 100644 --- a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php @@ -2464,7 +2464,7 @@ public static function dataBinaryOperations(): array 'count($arrayOfIntegers)', ], [ - 'int<0, max>', + '3', 'count($arrayOfIntegers, \COUNT_RECURSIVE)', ], [ From 6a8122e3270287f44d3c14bf677c425c6d6c8ebf Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 3 Nov 2025 10:56:07 +0100 Subject: [PATCH 04/10] test union --- tests/PHPStan/Analyser/nsrt/count-recursive.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/count-recursive.php b/tests/PHPStan/Analyser/nsrt/count-recursive.php index 9795bdecc2..0e7891dc9d 100644 --- a/tests/PHPStan/Analyser/nsrt/count-recursive.php +++ b/tests/PHPStan/Analyser/nsrt/count-recursive.php @@ -36,6 +36,17 @@ public function countArrayRecursive(array $arr): void } } + public function countArrayUnionMode(array $arr): void + { + $mode = rand(0,1) ? COUNT_NORMAL : COUNT_RECURSIVE; + if (count($arr, $mode) > 2) { + assertType('non-empty-array', $arr); + assertType('int<3, max>', count($arr, $mode)); + assertType('int<1, max>', count($arr, COUNT_NORMAL)); + assertType('int<1, max>', count($arr, COUNT_RECURSIVE)); + } + } + /** @param list $list */ public function countList($list): void { From 5e74532cab6e098cccba690f809f229b07a59fc0 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 7 Nov 2025 14:04:39 +0100 Subject: [PATCH 05/10] add tests --- .../PHPStan/Analyser/nsrt/count-recursive.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/count-recursive.php b/tests/PHPStan/Analyser/nsrt/count-recursive.php index 0e7891dc9d..27ac21fe51 100644 --- a/tests/PHPStan/Analyser/nsrt/count-recursive.php +++ b/tests/PHPStan/Analyser/nsrt/count-recursive.php @@ -6,6 +6,24 @@ class HelloWorld { + public function countUnknownArray(array $arr): void + { + assertType('array', $arr); + assertType('int<0, max>', count($arr)); + assertType('int<0, max>', count($arr, COUNT_NORMAL)); + assertType('int<0, max>', count($arr, COUNT_RECURSIVE)); + } + + public function countEmptyArray(array $arr): void + { + if (count($arr) == 0) { + assertType('array{}', $arr); + assertType('0', count($arr)); + assertType('0', count($arr, COUNT_NORMAL)); + assertType('0', count($arr, COUNT_RECURSIVE)); + } + } + public function countArray(array $arr): void { if (count($arr) > 2) { From 14a5eea6a924c0cdd7bc8021345ee6b4ab507773 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 7 Nov 2025 16:24:28 +0100 Subject: [PATCH 06/10] more tests --- tests/PHPStan/Analyser/nsrt/count-recursive.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/count-recursive.php b/tests/PHPStan/Analyser/nsrt/count-recursive.php index 27ac21fe51..20bb578e59 100644 --- a/tests/PHPStan/Analyser/nsrt/count-recursive.php +++ b/tests/PHPStan/Analyser/nsrt/count-recursive.php @@ -6,6 +6,20 @@ class HelloWorld { + /** + * @param array> $muliDimArr + * @return void + */ + public function countMultiDim(array $muliDimArr, $mixed): void + { + if (count($muliDimArr, $mixed) > 2) { + assertType('int<1, max>', count($muliDimArr)); + assertType('int<3, max>', count($muliDimArr, $mixed)); + assertType('int<1, max>', count($muliDimArr, COUNT_NORMAL)); + assertType('int<1, max>', count($muliDimArr, COUNT_RECURSIVE)); + } + } + public function countUnknownArray(array $arr): void { assertType('array', $arr); From edcadc08b4ab87bb18b68aebeca00d39a58107fc Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 8 Nov 2025 11:39:57 +0100 Subject: [PATCH 07/10] more tests --- .../PHPStan/Analyser/nsrt/count-recursive.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/count-recursive.php b/tests/PHPStan/Analyser/nsrt/count-recursive.php index 20bb578e59..2d2f22886f 100644 --- a/tests/PHPStan/Analyser/nsrt/count-recursive.php +++ b/tests/PHPStan/Analyser/nsrt/count-recursive.php @@ -108,4 +108,22 @@ public function countListRecursive($list): void assertType('int<3, max>', count($list, COUNT_RECURSIVE)); } } + + public function countConstantArray(array $anotherArray): void { + $arr = [1, 2, 3, [4, 5]]; + assertType('4', count($arr)); + assertType('4', count($arr, COUNT_NORMAL)); + assertType('int<1, max>', count($arr, COUNT_RECURSIVE)); + + $arr = [1, 2, 3, $anotherArray]; + assertType('4', count($arr)); + assertType('4', count($arr, COUNT_NORMAL)); + assertType('int<1, max>', count($arr, COUNT_RECURSIVE)); // could be int<4, max> + + $arr = [1, 2, 3] + $anotherArray; + assertType('non-empty-array&hasOffsetValue(0, 1)&hasOffsetValue(1, 2)&hasOffsetValue(2, 3)', $arr); + assertType('int<1, max>', count($arr)); // could be int<3, max> + assertType('int<1, max>', count($arr, COUNT_NORMAL)); // could be int<3, max> + assertType('int<1, max>', count($arr, COUNT_RECURSIVE)); + } } From 5ef5fa7123c0b30784558bc10604a86e3091bf08 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 8 Nov 2025 12:04:20 +0100 Subject: [PATCH 08/10] take HasOffset*Type into account --- src/Type/IntersectionType.php | 21 +++++++++++++- .../PHPStan/Analyser/nsrt/count-recursive.php | 28 +++++++++++++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 16cb33d2dd..5c5f754528 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -53,6 +53,7 @@ use function in_array; use function is_int; use function ksort; +use function max; use function md5; use function sprintf; use function strcasecmp; @@ -680,7 +681,25 @@ public function isIterableAtLeastOnce(): TrinaryLogic public function getArraySize(): Type { - return $this->intersectTypes(static fn (Type $type): Type => $type->getArraySize()); + $arraySize = $this->intersectTypes(static fn (Type $type): Type => $type->getArraySize()); + + if ($arraySize instanceof IntegerRangeType) { + $knownOffsets = []; + foreach ($this->types as $type) { + if ($type instanceof HasOffsetValueType) { + $knownOffsets[$type->getOffsetType()->getValue()] = true; + } + if (!($type instanceof HasOffsetType)) { + continue; + } + + $knownOffsets[$type->getOffsetType()->getValue()] = true; + } + + return IntegerRangeType::fromInterval(max(count($knownOffsets), $arraySize->getMin()), $arraySize->getMax()); + } + + return $arraySize; } public function getIterableKeyType(): Type diff --git a/tests/PHPStan/Analyser/nsrt/count-recursive.php b/tests/PHPStan/Analyser/nsrt/count-recursive.php index 2d2f22886f..996be7f7a5 100644 --- a/tests/PHPStan/Analyser/nsrt/count-recursive.php +++ b/tests/PHPStan/Analyser/nsrt/count-recursive.php @@ -116,14 +116,36 @@ public function countConstantArray(array $anotherArray): void { assertType('int<1, max>', count($arr, COUNT_RECURSIVE)); $arr = [1, 2, 3, $anotherArray]; + assertType('array{1, 2, 3, array}', $arr); assertType('4', count($arr)); assertType('4', count($arr, COUNT_NORMAL)); assertType('int<1, max>', count($arr, COUNT_RECURSIVE)); // could be int<4, max> $arr = [1, 2, 3] + $anotherArray; assertType('non-empty-array&hasOffsetValue(0, 1)&hasOffsetValue(1, 2)&hasOffsetValue(2, 3)', $arr); - assertType('int<1, max>', count($arr)); // could be int<3, max> - assertType('int<1, max>', count($arr, COUNT_NORMAL)); // could be int<3, max> - assertType('int<1, max>', count($arr, COUNT_RECURSIVE)); + assertType('int<3, max>', count($arr)); + assertType('int<3, max>', count($arr, COUNT_NORMAL)); + assertType('int<1, max>', count($arr, COUNT_RECURSIVE)); // could be int<3, max> + } + + public function countAfterKeyExists(array $array, int $i): void { + if (array_key_exists(5, $array)) { + assertType('non-empty-array&hasOffset(5)', $array); + assertType('int<1, max>', count($array)); + } + + if ($array !== []) { + assertType('non-empty-array', $array); + assertType('int<1, max>', count($array)); + if (array_key_exists(5, $array)) { + assertType('non-empty-array&hasOffset(5)', $array); + assertType('int<1, max>', count($array)); + + if (array_key_exists(15, $array)) { + assertType('non-empty-array&hasOffset(15)&hasOffset(5)', $array); + assertType('int<2, max>', count($array)); + } + } + } } } From 6d2574ebd328f20ac773982f3b19f5156ab4c5f6 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 8 Nov 2025 12:05:59 +0100 Subject: [PATCH 09/10] cs --- src/Type/IntersectionType.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 5c5f754528..ce66ee1c7b 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -686,10 +686,7 @@ public function getArraySize(): Type if ($arraySize instanceof IntegerRangeType) { $knownOffsets = []; foreach ($this->types as $type) { - if ($type instanceof HasOffsetValueType) { - $knownOffsets[$type->getOffsetType()->getValue()] = true; - } - if (!($type instanceof HasOffsetType)) { + if (!($type instanceof HasOffsetValueType) && !($type instanceof HasOffsetType)) { continue; } From d52ed955dd2586878a55c12625981fe27df058b1 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 8 Nov 2025 12:09:48 +0100 Subject: [PATCH 10/10] test optional keys --- tests/PHPStan/Analyser/nsrt/count-recursive.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/count-recursive.php b/tests/PHPStan/Analyser/nsrt/count-recursive.php index 996be7f7a5..e64d14c4a2 100644 --- a/tests/PHPStan/Analyser/nsrt/count-recursive.php +++ b/tests/PHPStan/Analyser/nsrt/count-recursive.php @@ -121,6 +121,14 @@ public function countConstantArray(array $anotherArray): void { assertType('4', count($arr, COUNT_NORMAL)); assertType('int<1, max>', count($arr, COUNT_RECURSIVE)); // could be int<4, max> + if (rand(0,1)) { + $arr[] = 10; + } + assertType('array{0: 1, 1: 2, 2: 3, 3: array, 4?: 10}', $arr); + assertType('int<4, 5>', count($arr)); + assertType('int<4, 5>', count($arr, COUNT_NORMAL)); + assertType('int<1, max>', count($arr, COUNT_RECURSIVE)); // could be int<4, max> + $arr = [1, 2, 3] + $anotherArray; assertType('non-empty-array&hasOffsetValue(0, 1)&hasOffsetValue(1, 2)&hasOffsetValue(2, 3)', $arr); assertType('int<3, max>', count($arr));