Skip to content

Commit 59c0233

Browse files
committed
Narrow intdiv() return type from integer ranges
Amp-Thread-ID: https://ampcode.com/threads/T-01a1a1e7-5e72-7235-a4b2-ba1b689f7e1b
1 parent 95d9e8b commit 59c0233

3 files changed

Lines changed: 219 additions & 0 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Php;
4+
5+
use PhpParser\Node\Expr\FuncCall;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\DependencyInjection\AutowiredService;
8+
use PHPStan\Reflection\FunctionReflection;
9+
use PHPStan\Type\Constant\ConstantIntegerType;
10+
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
11+
use PHPStan\Type\IntegerRangeType;
12+
use PHPStan\Type\Type;
13+
use PHPStan\Type\TypeCombinator;
14+
use PHPStan\Type\UnionType;
15+
use function count;
16+
use function intdiv;
17+
use function is_infinite;
18+
use function max;
19+
use function min;
20+
use const INF;
21+
use const PHP_INT_MAX;
22+
use const PHP_INT_MIN;
23+
24+
#[AutowiredService]
25+
final class IntdivFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
26+
{
27+
28+
public function isFunctionSupported(FunctionReflection $functionReflection): bool
29+
{
30+
return $functionReflection->getName() === 'intdiv';
31+
}
32+
33+
public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type
34+
{
35+
$args = $functionCall->getArgs();
36+
if (count($args) < 2) {
37+
return null;
38+
}
39+
40+
$numeratorIntervals = $this->getIntervals($scope->getType($args[0]->value)->toInteger());
41+
$divisorIntervals = $this->getIntervals($scope->getType($args[1]->value)->toInteger());
42+
if ($numeratorIntervals === null || $divisorIntervals === null) {
43+
return null;
44+
}
45+
46+
$resultTypes = [];
47+
foreach ($numeratorIntervals as [$numeratorMin, $numeratorMax]) {
48+
foreach ($divisorIntervals as [$divisorMin, $divisorMax]) {
49+
// division by zero throws, so only the negative and the positive part of the divisor produce a result
50+
if ($divisorMin < 0) {
51+
$resultTypes[] = $this->divideIntervals($numeratorMin, $numeratorMax, $divisorMin, min($divisorMax, -1));
52+
}
53+
if ($divisorMax <= 0) {
54+
continue;
55+
}
56+
57+
$resultTypes[] = $this->divideIntervals($numeratorMin, $numeratorMax, max($divisorMin, 1), $divisorMax);
58+
}
59+
}
60+
61+
if (count($resultTypes) === 0) {
62+
return null;
63+
}
64+
65+
return TypeCombinator::union(...$resultTypes);
66+
}
67+
68+
/**
69+
* Unbounded ends are represented by -INF and INF.
70+
*
71+
* @return list<array{int|float, int|float}>|null
72+
*/
73+
private function getIntervals(Type $type): ?array
74+
{
75+
$intervals = [];
76+
foreach ($type instanceof UnionType ? $type->getTypes() : [$type] as $innerType) {
77+
if ($innerType instanceof ConstantIntegerType) {
78+
$intervals[] = [$innerType->getValue(), $innerType->getValue()];
79+
} elseif ($innerType instanceof IntegerRangeType) {
80+
$intervals[] = [$innerType->getMin() ?? -INF, $innerType->getMax() ?? INF];
81+
} elseif ($innerType->isInteger()->yes()) {
82+
$intervals[] = [-INF, INF];
83+
} else {
84+
return null;
85+
}
86+
}
87+
88+
return $intervals;
89+
}
90+
91+
/**
92+
* The divisor interval must not contain zero. The quotient is then monotonic in both operands
93+
* and truncation towards zero is monotonic too, so the extremes are found in the corners.
94+
*/
95+
private function divideIntervals(int|float $numeratorMin, int|float $numeratorMax, int|float $divisorMin, int|float $divisorMax): Type
96+
{
97+
$results = [];
98+
foreach ([$numeratorMin, $numeratorMax] as $numerator) {
99+
foreach ([$divisorMin, $divisorMax] as $divisor) {
100+
$results[] = $this->divideBounds($numerator, $divisor);
101+
}
102+
}
103+
104+
$min = min($results);
105+
$max = max($results);
106+
107+
return IntegerRangeType::fromInterval(
108+
is_infinite($min) ? null : (int) $min,
109+
is_infinite($max) ? null : (int) $max,
110+
);
111+
}
112+
113+
private function divideBounds(int|float $numerator, int|float $divisor): int|float
114+
{
115+
if (is_infinite($numerator)) {
116+
if (is_infinite($divisor)) {
117+
// the range already contains 0 through a neighbouring corner
118+
return 0;
119+
}
120+
121+
return ($numerator > 0) === ($divisor > 0) ? INF : -INF;
122+
}
123+
124+
if (is_infinite($divisor)) {
125+
return 0;
126+
}
127+
128+
if ($numerator === PHP_INT_MIN && $divisor === -1) {
129+
// throws ArithmeticError, the closest valid result is intdiv(PHP_INT_MIN + 1, -1)
130+
return PHP_INT_MAX;
131+
}
132+
133+
return intdiv((int) $numerator, (int) $divisor);
134+
}
135+
136+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug15282;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/**
8+
* @return int<1, max>
9+
*/
10+
function getPositiveNumber(): int
11+
{
12+
/** @var non-negative-int */
13+
$zeroOrMore = 0;
14+
/** @var positive-int */
15+
$oneOrMore = 1;
16+
17+
assertType('int<0, max>', intdiv($zeroOrMore, $oneOrMore));
18+
assertType('int<1, max>', intdiv($zeroOrMore, $oneOrMore) + 1);
19+
20+
return intdiv($zeroOrMore, $oneOrMore) + 1;
21+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug15347;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/**
8+
* Computes the 1-based page number containing the first result.
9+
*
10+
* @param int<0, max> $firstResult
11+
* @param int<1, max> $maxResults
12+
*
13+
* @return int<1, max>
14+
*/
15+
function computePageNumber(int $firstResult, int $maxResults): int
16+
{
17+
assertType('int<1, max>', intdiv($firstResult, $maxResults) + 1);
18+
19+
return intdiv($firstResult, $maxResults) + 1;
20+
}
21+
22+
/**
23+
* @param int<1, 5> $oneToFive
24+
* @param int<-7, -1> $minusSevenToMinusOne
25+
* @param int<-10, 10> $minusTenToTen
26+
* @param int<-3, 3> $minusThreeToThree
27+
* @param int<10, 20> $tenToTwenty
28+
* @param int<0, max> $nonNegative
29+
* @param int<min, -1> $negative
30+
* @param positive-int $positive
31+
* @param 3|int<10, 12> $union
32+
*/
33+
function ranges(
34+
int $int,
35+
int $oneToFive,
36+
int $minusSevenToMinusOne,
37+
int $minusTenToTen,
38+
int $minusThreeToThree,
39+
int $tenToTwenty,
40+
int $nonNegative,
41+
int $negative,
42+
int $positive,
43+
int $union,
44+
string $string,
45+
): void
46+
{
47+
assertType('3', intdiv(7, 2));
48+
assertType('-3', intdiv(-7, 2));
49+
assertType('-3', intdiv(7, -2));
50+
assertType('int<0, 2>', intdiv($oneToFive, 2));
51+
assertType('int<-3, 0>', intdiv($minusSevenToMinusOne, 2));
52+
assertType('int<0, 3>', intdiv($minusSevenToMinusOne, -2));
53+
assertType('int<-10, 10>', intdiv($minusTenToTen, $minusThreeToThree));
54+
assertType('int<2, 20>', intdiv($tenToTwenty, $oneToFive));
55+
assertType('int<0, 5>', intdiv(5, $positive));
56+
assertType('int<min, 0>', intdiv($nonNegative, $negative));
57+
assertType('int<0, max>', intdiv($negative, $negative));
58+
assertType('1|int<5, 6>', intdiv($union, 2));
59+
assertType('int', intdiv($int, $int));
60+
assertType('int', intdiv($int, 0));
61+
assertType('int', intdiv($string, 2));
62+
}

0 commit comments

Comments
 (0)