diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 4f3e7f9c433..126f0579532 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -280,6 +280,7 @@ public function specifyTypesInCondition( ) { $argType = $scope->getType($expr->right->getArgs()[0]->value); + $sizeType = null; if ($leftType instanceof ConstantIntegerType) { if ($orEqual) { $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getValue()); @@ -287,14 +288,28 @@ public function specifyTypesInCondition( $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getValue()); } } elseif ($leftType instanceof IntegerRangeType) { - $sizeType = $leftType->shift($offset); + if ($context->falsey() && $leftType->getMax() !== null) { + if ($orEqual) { + $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMax()); + } else { + $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getMax()); + } + } elseif ($context->truthy() && $leftType->getMin() !== null) { + if ($orEqual) { + $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMin()); + } else { + $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getMin()); + } + } } else { $sizeType = $leftType; } - $specifiedTypes = $this->specifyTypesForCountFuncCall($expr->right, $argType, $sizeType, $context, $scope, $expr); - if ($specifiedTypes !== null) { - $result = $result->unionWith($specifiedTypes); + if ($sizeType !== null) { + $specifiedTypes = $this->specifyTypesForCountFuncCall($expr->right, $argType, $sizeType, $context, $scope, $expr); + if ($specifiedTypes !== null) { + $result = $result->unionWith($specifiedTypes); + } } if ( diff --git a/tests/PHPStan/Analyser/nsrt/bug-13705.php b/tests/PHPStan/Analyser/nsrt/bug-13705.php new file mode 100644 index 00000000000..905984b8163 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13705.php @@ -0,0 +1,158 @@ +', $codes); + $code = random_bytes(16); + if (!in_array($code, $codes, true)) { + $codes[] = $code; + } + } +} + +/** + * @param list $arr + * @param int<2, 5> $boundedRange + * @param int<2, max> $unboundedMaxRange + * @param int $unboundedMinRange + */ +function countLessThanRange(array $arr, int $boundedRange, int $unboundedMaxRange, int $unboundedMinRange): void +{ + // count($arr) < $range → inverted to NOT($range <= count($arr)) + // Inner: orEqual=true, false context → falsey + max !== null + orEqual (branch 1) + // Else: orEqual=true, true context → truthy + min !== null + orEqual (branch 3) + if (count($arr) < $boundedRange) { + assertType('list', $arr); + } else { + assertType('non-empty-list&hasOffsetValue(1, string)', $arr); + } + + // count($arr) < unbounded max range → falsey + max is null → fallback via min (branch 3/4) + if (count($arr) < $unboundedMaxRange) { + assertType('list', $arr); + } else { + assertType('non-empty-list&hasOffsetValue(1, string)', $arr); + } + + // count($arr) < unbounded min range → fallback branch (min is null) + if (count($arr) < $unboundedMinRange) { + assertType('list', $arr); + } else { + assertType('list', $arr); + } +} + +/** + * @param list $arr + * @param int<2, 5> $boundedRange + */ +function countLessThanOrEqualRange(array $arr, int $boundedRange): void +{ + // count($arr) <= $range → inverted to NOT($range < count($arr)) + // Inner: orEqual=false, false context → falsey + max !== null + !orEqual (branch 2) + // Else: orEqual=false, true context → truthy + min !== null + !orEqual (branch 4) + if (count($arr) <= $boundedRange) { + assertType('list', $arr); + } else { + assertType('non-empty-list&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $arr); + } +} + +/** + * @param list $arr + * @param int<2, 5> $boundedRange + */ +function rangeGreaterThanOrEqualCount(array $arr, int $boundedRange): void +{ + // $range >= count($arr) → same as count($arr) <= $range + if ($boundedRange >= count($arr)) { + assertType('list', $arr); + } else { + assertType('non-empty-list&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $arr); + } +} + +/** + * @param list $arr + * @param int<2, 5> $boundedRange + */ +function rangeLessThanOrEqualCount(array $arr, int $boundedRange): void +{ + // $range <= count($arr) → direct, orEqual=true + // True context: truthy + orEqual + min !== null (branch 3) + // False context: falsey + orEqual + max !== null (branch 1) + if ($boundedRange <= count($arr)) { + assertType('non-empty-list&hasOffsetValue(1, string)', $arr); + } else { + assertType('list', $arr); + } +} + +/** + * @param list $arr + * @param int<2, 5> $boundedRange + */ +function rangeLessThanCount(array $arr, int $boundedRange): void +{ + // $range < count($arr) → direct, orEqual=false + // True context: truthy + !orEqual + min !== null (branch 4) + // False context: falsey + !orEqual + max !== null (branch 2) + if ($boundedRange < count($arr)) { + assertType('non-empty-list&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $arr); + } else { + assertType('list', $arr); + } +} + +function whileLoopOriginal(int $length, int $quantity): void +{ + if ($length < 8) { + throw new \InvalidArgumentException(); + } + $codes = []; + while ($quantity >= 1 && count($codes) < $quantity) { + $code = ''; + for ($i = 0; $i < $length; $i++) { + $code .= 'x'; + } + if (!in_array($code, $codes, true)) { + $codes[] = $code; + } + } +} + +class HelloWorld +{ + private const MIN_LENGTH = 8; + + /** + * @return list + */ + public function generatePlainRecoveryCodes(int $length = 8, int $quantity = 8): array + { + if ($length < self::MIN_LENGTH) { + throw new \InvalidArgumentException( + $length . ' is not allowed as length for recovery codes. Must be at least ' . self::MIN_LENGTH, + 1613666803 + ); + } + $codes = []; + while ($quantity >= 1 && count($codes) < $quantity) { + $code = ''; + for ($i = 0; $i < $length; $i++) { + $code .= 'x'; + } + if (!in_array($code, $codes, true)) { + $codes[] = $code; + } + } + return $codes; + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-4700.php b/tests/PHPStan/Analyser/nsrt/bug-4700.php index 24a680e387f..49cea6c59dc 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-4700.php +++ b/tests/PHPStan/Analyser/nsrt/bug-4700.php @@ -21,8 +21,8 @@ function(array $array, int $count): void { assertType('int<1, 5>', count($a)); assertType('list{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a); } else { - assertType('0', count($a)); - assertType('array{}', $a); + assertType('int<0, 5>', count($a)); + assertType('array{}|list{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a); } }; diff --git a/tests/PHPStan/Analyser/nsrt/bug11480.php b/tests/PHPStan/Analyser/nsrt/bug11480.php index 17077d7bfc7..f80a7237ff2 100644 --- a/tests/PHPStan/Analyser/nsrt/bug11480.php +++ b/tests/PHPStan/Analyser/nsrt/bug11480.php @@ -106,7 +106,7 @@ public function intRangeCount($count): void if (count($x) >= $count) { assertType("array{'xy'}|array{0: 'ab', 1?: 'xy'}", $x); } else { - assertType("array{}", $x); + assertType("array{}|array{'xy'}|array{0: 'ab', 1?: 'xy'}", $x); } assertType("array{}|array{'xy'}|array{0: 'ab', 1?: 'xy'}", $x); } diff --git a/tests/PHPStan/Analyser/nsrt/list-count.php b/tests/PHPStan/Analyser/nsrt/list-count.php index 4a90322e538..24bfc6fa63f 100644 --- a/tests/PHPStan/Analyser/nsrt/list-count.php +++ b/tests/PHPStan/Analyser/nsrt/list-count.php @@ -352,7 +352,7 @@ protected function testOptionalKeysInUnionArray($row): void protected function testOptionalKeysInUnionListWithIntRange($row, $listRow, $twoOrThree, $twoOrMore, int $maxThree, $tenOrEleven, $threeOrMoreInRangeLimit, $threeOrMoreOverRangeLimit): void { if (count($row) >= $twoOrThree) { - assertType('array{0: int, 1: string|null, 2?: int|null}', $row); + assertType('list{0: int, 1: string|null, 2?: int|null, 3?: float|null}', $row); } else { assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); } @@ -376,25 +376,25 @@ protected function testOptionalKeysInUnionListWithIntRange($row, $listRow, $twoO } if (count($row) >= $threeOrMoreInRangeLimit) { - assertType('list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); + assertType('array{0: int, 1: string|null, 2: int|null, 3?: float|null}', $row); } else { assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); } if (count($listRow) >= $threeOrMoreInRangeLimit) { - assertType('list{0: string, 1: string, 2: string, 3?: string, 4?: string, 5?: string, 6?: string, 7?: string, 8?: string, 9?: string, 10?: string, 11?: string, 12?: string, 13?: string, 14?: string, 15?: string, 16?: string, 17?: string, 18?: string, 19?: string, 20?: string, 21?: string, 22?: string, 23?: string, 24?: string, 25?: string, 26?: string, 27?: string, 28?: string, 29?: string, 30?: string, 31?: string}', $listRow); + assertType('non-empty-list&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $listRow); } else { assertType('list', $listRow); } if (count($row) >= $threeOrMoreOverRangeLimit) { - assertType('list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); + assertType('array{0: int, 1: string|null, 2: int|null, 3?: float|null}', $row); } else { assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); } if (count($listRow) >= $threeOrMoreOverRangeLimit) { - assertType('non-empty-list', $listRow); + assertType('non-empty-list&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $listRow); } else { assertType('list', $listRow); } diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 5e61f488d05..1af4465cd9d 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1202,6 +1202,12 @@ public function testBug14429(): void $this->analyse([__DIR__ . '/data/bug-14429.php'], []); } + public function testBug13705(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-13705.php'], []); + } + public function testBug13799(): void { $this->treatPhpDocTypesAsCertain = true;