From 81bc94e9bd946961cd5417183ef5cbb004b48903 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Tue, 3 Nov 2026 15:19:27 +0000 Subject: [PATCH] Narrow intdiv() return type from integer ranges Amp-Thread-ID: https://ampcode.com/threads/T-01a1a1e7-5e72-7235-a4b2-ba1b689f7e1b --- .../Php/IntdivFunctionReturnTypeExtension.php | 136 ++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-15282.php | 21 +++ tests/PHPStan/Analyser/nsrt/bug-15347.php | 62 ++++++++ 3 files changed, 219 insertions(+) create mode 100644 src/Type/Php/IntdivFunctionReturnTypeExtension.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15282.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15347.php diff --git a/src/Type/Php/IntdivFunctionReturnTypeExtension.php b/src/Type/Php/IntdivFunctionReturnTypeExtension.php new file mode 100644 index 00000000000..6611418ff30 --- /dev/null +++ b/src/Type/Php/IntdivFunctionReturnTypeExtension.php @@ -0,0 +1,136 @@ +getName() === 'intdiv'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) < 2) { + return null; + } + + $numeratorIntervals = $this->getIntervals($scope->getType($args[0]->value)->toInteger()); + $divisorIntervals = $this->getIntervals($scope->getType($args[1]->value)->toInteger()); + if ($numeratorIntervals === null || $divisorIntervals === null) { + return null; + } + + $resultTypes = []; + foreach ($numeratorIntervals as [$numeratorMin, $numeratorMax]) { + foreach ($divisorIntervals as [$divisorMin, $divisorMax]) { + // division by zero throws, so only the negative and the positive part of the divisor produce a result + if ($divisorMin < 0) { + $resultTypes[] = $this->divideIntervals($numeratorMin, $numeratorMax, $divisorMin, min($divisorMax, -1)); + } + if ($divisorMax <= 0) { + continue; + } + + $resultTypes[] = $this->divideIntervals($numeratorMin, $numeratorMax, max($divisorMin, 1), $divisorMax); + } + } + + if (count($resultTypes) === 0) { + return null; + } + + return TypeCombinator::union(...$resultTypes); + } + + /** + * Unbounded ends are represented by -INF and INF. + * + * @return list|null + */ + private function getIntervals(Type $type): ?array + { + $intervals = []; + foreach ($type instanceof UnionType ? $type->getTypes() : [$type] as $innerType) { + if ($innerType instanceof ConstantIntegerType) { + $intervals[] = [$innerType->getValue(), $innerType->getValue()]; + } elseif ($innerType instanceof IntegerRangeType) { + $intervals[] = [$innerType->getMin() ?? -INF, $innerType->getMax() ?? INF]; + } elseif ($innerType->isInteger()->yes()) { + $intervals[] = [-INF, INF]; + } else { + return null; + } + } + + return $intervals; + } + + /** + * The divisor interval must not contain zero. The quotient is then monotonic in both operands + * and truncation towards zero is monotonic too, so the extremes are found in the corners. + */ + private function divideIntervals(int|float $numeratorMin, int|float $numeratorMax, int|float $divisorMin, int|float $divisorMax): Type + { + $results = []; + foreach ([$numeratorMin, $numeratorMax] as $numerator) { + foreach ([$divisorMin, $divisorMax] as $divisor) { + $results[] = $this->divideBounds($numerator, $divisor); + } + } + + $min = min($results); + $max = max($results); + + return IntegerRangeType::fromInterval( + is_infinite($min) ? null : (int) $min, + is_infinite($max) ? null : (int) $max, + ); + } + + private function divideBounds(int|float $numerator, int|float $divisor): int|float + { + if (is_infinite($numerator)) { + if (is_infinite($divisor)) { + // the range already contains 0 through a neighbouring corner + return 0; + } + + return ($numerator > 0) === ($divisor > 0) ? INF : -INF; + } + + if (is_infinite($divisor)) { + return 0; + } + + if ($numerator === PHP_INT_MIN && $divisor === -1) { + // throws ArithmeticError, the closest valid result is intdiv(PHP_INT_MIN + 1, -1) + return PHP_INT_MAX; + } + + return intdiv((int) $numerator, (int) $divisor); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-15282.php b/tests/PHPStan/Analyser/nsrt/bug-15282.php new file mode 100644 index 00000000000..34dc4988d93 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15282.php @@ -0,0 +1,21 @@ + + */ +function getPositiveNumber(): int +{ + /** @var non-negative-int */ + $zeroOrMore = 0; + /** @var positive-int */ + $oneOrMore = 1; + + assertType('int<0, max>', intdiv($zeroOrMore, $oneOrMore)); + assertType('int<1, max>', intdiv($zeroOrMore, $oneOrMore) + 1); + + return intdiv($zeroOrMore, $oneOrMore) + 1; +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-15347.php b/tests/PHPStan/Analyser/nsrt/bug-15347.php new file mode 100644 index 00000000000..a1778807b55 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15347.php @@ -0,0 +1,62 @@ + $firstResult + * @param int<1, max> $maxResults + * + * @return int<1, max> + */ +function computePageNumber(int $firstResult, int $maxResults): int +{ + assertType('int<1, max>', intdiv($firstResult, $maxResults) + 1); + + return intdiv($firstResult, $maxResults) + 1; +} + +/** + * @param int<1, 5> $oneToFive + * @param int<-7, -1> $minusSevenToMinusOne + * @param int<-10, 10> $minusTenToTen + * @param int<-3, 3> $minusThreeToThree + * @param int<10, 20> $tenToTwenty + * @param int<0, max> $nonNegative + * @param int $negative + * @param positive-int $positive + * @param 3|int<10, 12> $union + */ +function ranges( + int $int, + int $oneToFive, + int $minusSevenToMinusOne, + int $minusTenToTen, + int $minusThreeToThree, + int $tenToTwenty, + int $nonNegative, + int $negative, + int $positive, + int $union, + string $string, +): void +{ + assertType('3', intdiv(7, 2)); + assertType('-3', intdiv(-7, 2)); + assertType('-3', intdiv(7, -2)); + assertType('int<0, 2>', intdiv($oneToFive, 2)); + assertType('int<-3, 0>', intdiv($minusSevenToMinusOne, 2)); + assertType('int<0, 3>', intdiv($minusSevenToMinusOne, -2)); + assertType('int<-10, 10>', intdiv($minusTenToTen, $minusThreeToThree)); + assertType('int<2, 20>', intdiv($tenToTwenty, $oneToFive)); + assertType('int<0, 5>', intdiv(5, $positive)); + assertType('int', intdiv($nonNegative, $negative)); + assertType('int<0, max>', intdiv($negative, $negative)); + assertType('1|int<5, 6>', intdiv($union, 2)); + assertType('int', intdiv($int, $int)); + assertType('int', intdiv($int, 0)); + assertType('int', intdiv($string, 2)); +}