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..3aa4a9a2d8 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->isList()->yes() && $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() - 1); + } elseif ($size instanceof ConstantIntegerType) { + $knownOffsets = IntegerRangeType::fromInterval(0, $size->getValue() - 1); + } 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-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..e8a033f170 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13747.php @@ -0,0 +1,99 @@ + $list */ + public function count($list): void + { + if (count($list) === 0) { + return; + } + + 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 { + 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, COUNT_NORMAL)); + } else { + assertType('non-empty-list', $list); + } + + assertType('non-empty-list', $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); + } + } + + /** @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)); + } + } + + /** @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); + } + + } +} diff --git a/tests/PHPStan/Analyser/nsrt/count-recursive.php b/tests/PHPStan/Analyser/nsrt/count-recursive.php index 1725b929fd..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<1, max>', count($list, COUNT_NORMAL)); - assertType('int<1, 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<1, max>', count($list)); + assertType('int<3, max>', count($list)); assertType('int<3, max>', count($list, COUNT_NORMAL)); - assertType('int<1, 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<1, max>', count($list)); - assertType('int<1, 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)); } }