From 4fefbd22435533a759df7b958e9a06b0af66fe7d Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 23 Oct 2025 22:27:10 +0200 Subject: [PATCH 1/9] Infer non-empty-ness after count($a) == count($b) --- src/Analyser/TypeSpecifier.php | 24 +++++- tests/PHPStan/Analyser/nsrt/list-count2.php | 81 +++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/list-count2.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 6698a8a800..e917ad3a98 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2260,7 +2260,7 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty $rightType = $scope->getType($rightExpr); - // (count($a) === $b) + // (count($a) === $expr) if ( !$context->null() && $unwrappedLeftExpr instanceof FuncCall @@ -2269,6 +2269,28 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty && in_array(strtolower((string) $unwrappedLeftExpr->name), ['count', 'sizeof'], true) && $rightType->isInteger()->yes() ) { + // count($a) === count($b) + if ( + $context->true() + && $unwrappedRightExpr instanceof FuncCall + && $unwrappedRightExpr->name instanceof Name + && in_array($unwrappedRightExpr->name->toLowerString(), ['count', 'sizeof'], true) + && count($unwrappedRightExpr->getArgs()) >= 1 + ) { + $leftArrayType = $scope->getType($unwrappedLeftExpr->getArgs()[0]->value); + $rightArrayType = $scope->getType($unwrappedRightExpr->getArgs()[0]->value); + + if ( + $leftArrayType->isArray()->yes() && $rightArrayType->isArray()->yes() + && ($leftArrayType->isIterableAtLeastOnce()->yes() || $rightArrayType->isIterableAtLeastOnce()->yes()) + ) { + $arrayTypes = $this->create($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr); + return $arrayTypes->unionWith( + $this->create($unwrappedRightExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), + ); + } + } + if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($rightType)->yes()) { return $this->create($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope)->setRootExpr($expr); } diff --git a/tests/PHPStan/Analyser/nsrt/list-count2.php b/tests/PHPStan/Analyser/nsrt/list-count2.php new file mode 100644 index 0000000000..88422909af --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/list-count2.php @@ -0,0 +1,81 @@ + $listA + * @param list $listB + */ + public function sayIdenticalLists($listA, array $listB): void + { + if (count($listA) === count($listB)) { + assertType('non-empty-list', $listB); + } + assertType('list', $listB); + } + + /** + * @param non-empty-list $listA + */ + public function sayIdenticalList($listA, array $arrB): void + { + if (count($listA) === count($arrB)) { + assertType('non-empty-array', $arrB); + } + assertType('array', $arrB); + } + + /** + * @param non-empty-array $arrA + */ + public function sayEqualArray($arrA, array $arrB): void + { + if (count($arrA) == count($arrB)) { + assertType('non-empty-array', $arrB); + } + assertType('array', $arrB); + } + + /** + * @param non-empty-array $arrA + * @param array $arrB + */ + public function sayEqualIntArray($arrA, array $arrB): void + { + if (count($arrA) == count($arrB)) { + assertType('non-empty-array', $arrB); + } + assertType('array', $arrB); + } + + /** + * @param non-empty-array $arrA + * @param array $arrB + */ + public function sayEqualStringArray($arrA, array $arrB): void + { + if (count($arrA) == count($arrB)) { + assertType('non-empty-array', $arrB); + } + assertType('array', $arrB); + } + + /** + * @param array $arrA + * @param array $arrB + */ + public function sayUnknownArray($arrA, array $arrB): void + { + if (count($arrA) == count($arrB)) { + assertType('array', $arrA); + assertType('array', $arrB); + } + assertType('array', $arrB); + } +} From 258340d329510c698e9505d002f48e2fcbfc1164 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 24 Oct 2025 00:09:32 +0200 Subject: [PATCH 2/9] support array-size --- src/Analyser/TypeSpecifier.php | 25 ++++++++++++++------- tests/PHPStan/Analyser/nsrt/list-count2.php | 12 ++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index e917ad3a98..3c1905107d 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2280,14 +2280,23 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty $leftArrayType = $scope->getType($unwrappedLeftExpr->getArgs()[0]->value); $rightArrayType = $scope->getType($unwrappedRightExpr->getArgs()[0]->value); - if ( - $leftArrayType->isArray()->yes() && $rightArrayType->isArray()->yes() - && ($leftArrayType->isIterableAtLeastOnce()->yes() || $rightArrayType->isIterableAtLeastOnce()->yes()) - ) { - $arrayTypes = $this->create($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr); - return $arrayTypes->unionWith( - $this->create($unwrappedRightExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), - ); + if ($leftArrayType->isArray()->yes() && $rightArrayType->isArray()->yes()) { + $argType = $scope->getType($unwrappedRightExpr->getArgs()[0]->value); + $sizeType = $scope->getType($leftExpr); + + if ($sizeType instanceof IntegerRangeType || $sizeType->isConstantScalarValue()->yes()) { + $specifiedTypes = $this->specifyTypesForCountFuncCall($unwrappedRightExpr, $argType, $sizeType, $context, $scope, $expr); + if ($specifiedTypes !== null) { + return $specifiedTypes; + } + } + + if ($leftArrayType->isIterableAtLeastOnce()->yes() || $rightArrayType->isIterableAtLeastOnce()->yes()) { + $arrayTypes = $this->create($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr); + return $arrayTypes->unionWith( + $this->create($unwrappedRightExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), + ); + } } } diff --git a/tests/PHPStan/Analyser/nsrt/list-count2.php b/tests/PHPStan/Analyser/nsrt/list-count2.php index 88422909af..37dc759ad4 100644 --- a/tests/PHPStan/Analyser/nsrt/list-count2.php +++ b/tests/PHPStan/Analyser/nsrt/list-count2.php @@ -78,4 +78,16 @@ public function sayUnknownArray($arrA, array $arrB): void } assertType('array', $arrB); } + + /** + * @param array{int, int, int} $arrA + * @param list $arrB + */ + function sayEqualArrayShape($arrA, array $arrB): void + { + if (count($arrA) == count($arrB)) { + assertType('array{mixed, mixed, mixed}', $arrB); + } + assertType('list', $arrB); + } } From ea79290b3507451343c213767022b33bf8a5fbdd Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 24 Oct 2025 00:16:42 +0200 Subject: [PATCH 3/9] fix --- src/Analyser/TypeSpecifier.php | 12 +++++++----- tests/PHPStan/Analyser/nsrt/list-count2.php | 12 ++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 3c1905107d..180c7e09bd 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2291,11 +2291,13 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty } } - if ($leftArrayType->isIterableAtLeastOnce()->yes() || $rightArrayType->isIterableAtLeastOnce()->yes()) { - $arrayTypes = $this->create($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr); - return $arrayTypes->unionWith( - $this->create($unwrappedRightExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), - ); + if (!$rightType->isConstantScalarValue()->yes()) { + if ($leftArrayType->isIterableAtLeastOnce()->yes() || $rightArrayType->isIterableAtLeastOnce()->yes()) { + $arrayTypes = $this->create($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr); + return $arrayTypes->unionWith( + $this->create($unwrappedRightExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), + ); + } } } } diff --git a/tests/PHPStan/Analyser/nsrt/list-count2.php b/tests/PHPStan/Analyser/nsrt/list-count2.php index 37dc759ad4..c1eb52f809 100644 --- a/tests/PHPStan/Analyser/nsrt/list-count2.php +++ b/tests/PHPStan/Analyser/nsrt/list-count2.php @@ -90,4 +90,16 @@ function sayEqualArrayShape($arrA, array $arrB): void } assertType('list', $arrB); } + + /** + * @param list $arrA + * @param array{int, int, int} $arrB + */ + function sayEqualArrayShapeReversed($arrA, array $arrB): void + { + if (count($arrA) == count($arrB)) { + assertType('array{mixed, mixed, mixed}', $arrA); + } + assertType('list', $arrA); + } } From aa3ba6e5a2273e30f063e95c84623bed5c751a91 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 24 Oct 2025 00:20:36 +0200 Subject: [PATCH 4/9] Update list-count2.php --- tests/PHPStan/Analyser/nsrt/list-count2.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/list-count2.php b/tests/PHPStan/Analyser/nsrt/list-count2.php index c1eb52f809..8c0d7dfe27 100644 --- a/tests/PHPStan/Analyser/nsrt/list-count2.php +++ b/tests/PHPStan/Analyser/nsrt/list-count2.php @@ -70,7 +70,7 @@ public function sayEqualStringArray($arrA, array $arrB): void * @param array $arrA * @param array $arrB */ - public function sayUnknownArray($arrA, array $arrB): void + public function sayUnknownSizeArray($arrA, array $arrB): void { if (count($arrA) == count($arrB)) { assertType('array', $arrA); From 0af1c1210b9c75765951f2c566ef6bc6e62ce43b Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 24 Oct 2025 07:02:48 +0200 Subject: [PATCH 5/9] simplify --- src/Analyser/TypeSpecifier.php | 38 ++++++++++++++++------------------ 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 180c7e09bd..82e0e5d460 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2277,28 +2277,26 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty && in_array($unwrappedRightExpr->name->toLowerString(), ['count', 'sizeof'], true) && count($unwrappedRightExpr->getArgs()) >= 1 ) { - $leftArrayType = $scope->getType($unwrappedLeftExpr->getArgs()[0]->value); - $rightArrayType = $scope->getType($unwrappedRightExpr->getArgs()[0]->value); + $argType = $scope->getType($unwrappedRightExpr->getArgs()[0]->value); + $sizeType = $scope->getType($leftExpr); - if ($leftArrayType->isArray()->yes() && $rightArrayType->isArray()->yes()) { - $argType = $scope->getType($unwrappedRightExpr->getArgs()[0]->value); - $sizeType = $scope->getType($leftExpr); - - if ($sizeType instanceof IntegerRangeType || $sizeType->isConstantScalarValue()->yes()) { - $specifiedTypes = $this->specifyTypesForCountFuncCall($unwrappedRightExpr, $argType, $sizeType, $context, $scope, $expr); - if ($specifiedTypes !== null) { - return $specifiedTypes; - } - } + $specifiedTypes = $this->specifyTypesForCountFuncCall($unwrappedRightExpr, $argType, $sizeType, $context, $scope, $expr); + if ($specifiedTypes !== null) { + return $specifiedTypes; + } - if (!$rightType->isConstantScalarValue()->yes()) { - if ($leftArrayType->isIterableAtLeastOnce()->yes() || $rightArrayType->isIterableAtLeastOnce()->yes()) { - $arrayTypes = $this->create($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr); - return $arrayTypes->unionWith( - $this->create($unwrappedRightExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), - ); - } - } + $leftArrayType = $scope->getType($unwrappedLeftExpr->getArgs()[0]->value); + $rightArrayType = $scope->getType($unwrappedRightExpr->getArgs()[0]->value); + if ( + $leftArrayType->isArray()->yes() + && $rightArrayType->isArray()->yes() + && !$rightType->isConstantScalarValue()->yes() + && ($leftArrayType->isIterableAtLeastOnce()->yes() || $rightArrayType->isIterableAtLeastOnce()->yes()) + ) { + $arrayTypes = $this->create($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr); + return $arrayTypes->unionWith( + $this->create($unwrappedRightExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), + ); } } From cf2574dbecdbe3d1fdc3a9acaa75288d51af8224 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 24 Oct 2025 07:22:31 +0200 Subject: [PATCH 6/9] another test --- tests/PHPStan/Analyser/nsrt/list-count2.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/list-count2.php b/tests/PHPStan/Analyser/nsrt/list-count2.php index 8c0d7dfe27..0886e7100f 100644 --- a/tests/PHPStan/Analyser/nsrt/list-count2.php +++ b/tests/PHPStan/Analyser/nsrt/list-count2.php @@ -102,4 +102,21 @@ function sayEqualArrayShapeReversed($arrA, array $arrB): void } assertType('list', $arrA); } + + /** + * @param array{int, int, int} $arrA + * @param list $arrB + */ + function sayEqualArrayShapeAfterNarrowedCount($arrA, array $arrB): void + { + if (count($arrB) < 2) { + return; + } + + if (count($arrA) == count($arrB)) { + assertType('array{mixed, mixed, mixed}', $arrB); + } + assertType('non-empty-list', $arrB); + } + } From 03d47854021f08d251c51fccf75c58176d81142e Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 31 Oct 2025 07:14:48 +0100 Subject: [PATCH 7/9] dont narrow empty array --- tests/PHPStan/Analyser/nsrt/list-count2.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/list-count2.php b/tests/PHPStan/Analyser/nsrt/list-count2.php index 0886e7100f..fc72d46631 100644 --- a/tests/PHPStan/Analyser/nsrt/list-count2.php +++ b/tests/PHPStan/Analyser/nsrt/list-count2.php @@ -119,4 +119,25 @@ function sayEqualArrayShapeAfterNarrowedCount($arrA, array $arrB): void assertType('non-empty-list', $arrB); } + /** + * @param non-empty-array $arrB + */ + function dontNarrowEmpty(array $arrB): void + { + $arrA = []; + assertType('array{}', $arrA); + + if (count($arrA) == count($arrB)) { + assertType('*NEVER*', $arrA); + assertType('non-empty-array', $arrB); + } + assertType('array{}', $arrA); + + if (count($arrB) == count($arrA)) { + assertType('*NEVER*', $arrA); + assertType('non-empty-array', $arrB); + } + assertType('array{}', $arrA); + } + } From 6686d85efa615a1b9364dcc7395d6edeea037eb5 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 31 Oct 2025 07:21:10 +0100 Subject: [PATCH 8/9] Update list-count2.php --- tests/PHPStan/Analyser/nsrt/list-count2.php | 23 ++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/list-count2.php b/tests/PHPStan/Analyser/nsrt/list-count2.php index fc72d46631..f7d5cf9bf6 100644 --- a/tests/PHPStan/Analyser/nsrt/list-count2.php +++ b/tests/PHPStan/Analyser/nsrt/list-count2.php @@ -15,8 +15,10 @@ class HelloWorld public function sayIdenticalLists($listA, array $listB): void { if (count($listA) === count($listB)) { + assertType('non-empty-list', $listA); assertType('non-empty-list', $listB); } + assertType('non-empty-list', $listA); assertType('list', $listB); } @@ -26,8 +28,10 @@ public function sayIdenticalLists($listA, array $listB): void public function sayIdenticalList($listA, array $arrB): void { if (count($listA) === count($arrB)) { + assertType('non-empty-list', $listA); assertType('non-empty-array', $arrB); } + assertType('non-empty-list', $listA); assertType('array', $arrB); } @@ -37,8 +41,10 @@ public function sayIdenticalList($listA, array $arrB): void public function sayEqualArray($arrA, array $arrB): void { if (count($arrA) == count($arrB)) { + assertType('non-empty-array', $arrA); assertType('non-empty-array', $arrB); } + assertType('non-empty-array', $arrA); assertType('array', $arrB); } @@ -49,8 +55,10 @@ public function sayEqualArray($arrA, array $arrB): void public function sayEqualIntArray($arrA, array $arrB): void { if (count($arrA) == count($arrB)) { + assertType('non-empty-array', $arrA); assertType('non-empty-array', $arrB); } + assertType('non-empty-array', $arrA); assertType('array', $arrB); } @@ -61,8 +69,10 @@ public function sayEqualIntArray($arrA, array $arrB): void public function sayEqualStringArray($arrA, array $arrB): void { if (count($arrA) == count($arrB)) { + assertType('non-empty-array', $arrA); assertType('non-empty-array', $arrB); } + assertType('non-empty-array', $arrA); assertType('array', $arrB); } @@ -76,6 +86,7 @@ public function sayUnknownSizeArray($arrA, array $arrB): void assertType('array', $arrA); assertType('array', $arrB); } + assertType('array', $arrA); assertType('array', $arrB); } @@ -86,8 +97,10 @@ public function sayUnknownSizeArray($arrA, array $arrB): void function sayEqualArrayShape($arrA, array $arrB): void { if (count($arrA) == count($arrB)) { + assertType('array{int, int, int}', $arrA); assertType('array{mixed, mixed, mixed}', $arrB); } + assertType('array{int, int, int}', $arrA); assertType('list', $arrB); } @@ -99,8 +112,10 @@ function sayEqualArrayShapeReversed($arrA, array $arrB): void { if (count($arrA) == count($arrB)) { assertType('array{mixed, mixed, mixed}', $arrA); + assertType('array{int, int, int}', $arrB); } assertType('list', $arrA); + assertType('array{int, int, int}', $arrB); } /** @@ -114,8 +129,10 @@ function sayEqualArrayShapeAfterNarrowedCount($arrA, array $arrB): void } if (count($arrA) == count($arrB)) { + assertType('array{int, int, int}', $arrA); assertType('array{mixed, mixed, mixed}', $arrB); } + assertType('array{int, int, int}', $arrA); assertType('non-empty-list', $arrB); } @@ -126,16 +143,16 @@ function dontNarrowEmpty(array $arrB): void { $arrA = []; assertType('array{}', $arrA); - + if (count($arrA) == count($arrB)) { assertType('*NEVER*', $arrA); - assertType('non-empty-array', $arrB); + assertType('non-empty-array', $arrB); // could be '*NEVER*' } assertType('array{}', $arrA); if (count($arrB) == count($arrA)) { assertType('*NEVER*', $arrA); - assertType('non-empty-array', $arrB); + assertType('non-empty-array', $arrB); // could be '*NEVER*' } assertType('array{}', $arrA); } From 9142e1921427366dd26f26a5bac0b93b0b1a40e3 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 2 Nov 2025 10:02:17 +0100 Subject: [PATCH 9/9] test COUNT_RECURSIVE --- tests/PHPStan/Analyser/nsrt/list-count2.php | 64 +++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/list-count2.php b/tests/PHPStan/Analyser/nsrt/list-count2.php index f7d5cf9bf6..500705062f 100644 --- a/tests/PHPStan/Analyser/nsrt/list-count2.php +++ b/tests/PHPStan/Analyser/nsrt/list-count2.php @@ -157,4 +157,68 @@ function dontNarrowEmpty(array $arrB): void assertType('array{}', $arrA); } + /** + * @param non-empty-list $listA + * @param list $listB + */ + public function supportsNormalCount($listA, array $listB): void + { + if (count($listA, COUNT_NORMAL) === count($listB)) { + assertType('non-empty-list', $listA); + assertType('non-empty-list', $listB); + } + assertType('non-empty-list', $listA); + assertType('list', $listB); + } + + /** + * @param array{int, int, int} $arrA + * @param list $arrB + */ + function skipRecursiveLeftCount($arrA, array $arrB): void + { + if (count($arrB) < 2) { + return; + } + + if (count($arrA, COUNT_RECURSIVE) == count($arrB)) { + assertType('array{int, int, int}', $arrA); + assertType('non-empty-list', $arrB); + } + assertType('array{int, int, int}', $arrA); + assertType('non-empty-list', $arrB); + } + + /** + * @param array{int, int, int} $arrA + * @param list $arrB + */ + function skipRecursiveRightCount($arrA, array $arrB): void + { + if (count($arrB) < 2) { + return; + } + + if (count($arrA) == count($arrB, COUNT_RECURSIVE)) { + assertType('array{int, int, int}', $arrA); + assertType('non-empty-list', $arrB); + } + assertType('array{int, int, int}', $arrA); + assertType('non-empty-list', $arrB); + } + + /** + * @param non-empty-array $arrA + * @param array $arrB + */ + public function skipRecursiveCount($arrA, array $arrB): void + { + if (count($arrA, COUNT_RECURSIVE) == count($arrB)) { + assertType('non-empty-array', $arrA); + assertType('non-empty-array', $arrB); + } + assertType('non-empty-array', $arrA); + assertType('array', $arrB); + } + }