Skip to content

Commit 64edf28

Browse files
zonuexeclaude
andcommitted
Add a helper that tells whether range() rejects its arguments
PHP 8.0-8.2 ignore the sign of the step and, for numeric boundaries, reject one that is 0 or wider than the range; PHP 7 does the same with a warning and false instead of a ValueError. PHP 8.3 checks a step of 0, a NAN step and a step of PHP_INT_MIN before the boundaries, rejects a negative step on an increasing range, and builds a character range from two single bytes, in which a float step turns every character but a digit into 0. RangeFunctionArgumentsHelper::rejects() answers for the PHP versions of the scope, with PhpVersions::hasStricterRangeFunction() telling them apart. It uses the verdict of calling range() only when PHPStan itself runs on PHP 8.3 or newer, and leaves undecided what it cannot tell without running an older PHP: string boundaries before PHP 8.3, and numbers beyond 2 ** 53, which PHP compares exactly as integers. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 31470ab commit 64edf28

3 files changed

Lines changed: 280 additions & 0 deletions

File tree

‎src/Php/PhpVersions.php‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,13 @@ public function supportsMaxMemoryLimit(): TrinaryLogic
7171
return IntegerRangeType::fromInterval(80500, null)->isSuperTypeOf($this->phpVersions)->result;
7272
}
7373

74+
/**
75+
* PHP 8.3 rejects a negative step on an increasing range and a NAN step, builds a character
76+
* range from two single bytes and no longer turns an integral float step into floats.
77+
*/
78+
public function hasStricterRangeFunction(): TrinaryLogic
79+
{
80+
return IntegerRangeType::fromInterval(80300, null)->isSuperTypeOf($this->phpVersions)->result;
81+
}
82+
7483
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Php;
4+
5+
use PHPStan\TrinaryLogic;
6+
use ValueError;
7+
use function abs;
8+
use function floor;
9+
use function is_finite;
10+
use function is_float;
11+
use function is_int;
12+
use function is_nan;
13+
use function is_numeric;
14+
use function is_string;
15+
use function ord;
16+
use function range;
17+
use function rtrim;
18+
use function strlen;
19+
use const PHP_INT_MIN;
20+
use const PHP_VERSION_ID;
21+
22+
/**
23+
* Tells whether range() rejects its arguments, which PHP 8 reports with a ValueError
24+
* and PHP 7 with a warning and false. A range with more items than an array can hold
25+
* is not covered here.
26+
*/
27+
final class RangeFunctionArgumentsHelper
28+
{
29+
30+
/** 2 ** 53, above which a float no longer holds every integer */
31+
private const PRECISE_INTEGER_LIMIT = 9007199254740992;
32+
33+
/** 2 ** 63, from which PHP 8.3 keeps an integral float step as a float */
34+
private const INTEGER_STEP_LIMIT = 9.2233720368547758E18;
35+
36+
/**
37+
* Calls range() on the PHP version PHPStan itself runs on.
38+
*
39+
* @return list<int|float|string>|false false when range() rejects the arguments
40+
*/
41+
public static function callRange(int|float|string $start, int|float|string $end, int|float $step): array|false
42+
{
43+
try {
44+
return @range($start, $end, $step);
45+
} catch (ValueError) {
46+
return false;
47+
}
48+
}
49+
50+
/**
51+
* @param TrinaryLogic $hasStricterRange whether the analysed PHP versions are 8.3 or newer
52+
* @param bool|null $runtimeRejects what calling range() on the runtime told, null when it was not called
53+
*/
54+
public static function rejects(
55+
TrinaryLogic $hasStricterRange,
56+
int|float|string $start,
57+
int|float|string $end,
58+
int|float $step,
59+
?bool $runtimeRejects,
60+
): TrinaryLogic
61+
{
62+
$results = [];
63+
if (!$hasStricterRange->no()) {
64+
$results[] = self::rejectsSincePhp83($start, $end, $step, $runtimeRejects);
65+
}
66+
if (!$hasStricterRange->yes()) {
67+
$results[] = self::rejectsBeforePhp83($start, $end, $step);
68+
}
69+
70+
return TrinaryLogic::extremeIdentity(...$results);
71+
}
72+
73+
private static function rejectsSincePhp83(int|float|string $start, int|float|string $end, int|float $step, ?bool $runtimeRejects): TrinaryLogic
74+
{
75+
// PHP 8.3 checks the step on its own before looking at the boundaries
76+
if (!is_finite((float) $step) || (float) $step === 0.0 || $step === PHP_INT_MIN) {
77+
return TrinaryLogic::createYes();
78+
}
79+
80+
// calling range() only tells about PHP 8.3 when PHPStan itself runs on PHP 8.3 or newer
81+
if (PHP_VERSION_ID < 80300) {
82+
$runtimeRejects = null;
83+
}
84+
85+
if (self::isNegativeStepOnIncreasingRange($start, $end, $step)) {
86+
return TrinaryLogic::createYes();
87+
}
88+
89+
if ($runtimeRejects !== null) {
90+
return TrinaryLogic::createFromBoolean($runtimeRejects);
91+
}
92+
93+
// the older rules stand in, but they compare floats where PHP 8.3 compares an integral float step exactly
94+
if (self::isImprecise($start) || self::isImprecise($end) || self::isImprecise($step)) {
95+
return TrinaryLogic::createMaybe();
96+
}
97+
98+
return self::rejectsBeforePhp83($start, $end, $step);
99+
}
100+
101+
/**
102+
* For numeric boundaries PHP 7 and 8.0-8.2 ignore the sign of the step and reject
103+
* one that is 0 or wider than the range itself.
104+
*/
105+
private static function rejectsBeforePhp83(int|float|string $start, int|float|string $end, int|float $step): TrinaryLogic
106+
{
107+
if (is_string($start) || is_string($end)) {
108+
// how a string boundary was coerced before PHP 8.3 is not modelled here
109+
return TrinaryLogic::createMaybe();
110+
}
111+
112+
if (
113+
is_int($start) && is_int($end) && is_int($step)
114+
&& (abs($start) > self::PRECISE_INTEGER_LIMIT || abs($end) > self::PRECISE_INTEGER_LIMIT || abs($step) > self::PRECISE_INTEGER_LIMIT)
115+
) {
116+
// range() compares integers exactly, which the floats below cannot do anymore
117+
return TrinaryLogic::createMaybe();
118+
}
119+
120+
$start = (float) $start;
121+
$end = (float) $end;
122+
$step = abs((float) $step);
123+
if (!is_finite($start) || !is_finite($end) || is_nan($step)) {
124+
return TrinaryLogic::createMaybe();
125+
}
126+
127+
if ($start === $end) {
128+
// a step of 0 was only rejected for integer boundaries in this case
129+
return TrinaryLogic::createMaybe();
130+
}
131+
132+
return TrinaryLogic::createFromBoolean($step === 0.0 || abs($end - $start) < $step);
133+
}
134+
135+
/**
136+
* PHP 8.3 rejects a negative step on an increasing range, while earlier versions ignored its sign.
137+
*/
138+
public static function isNegativeStepOnIncreasingRange(int|float|string $start, int|float|string $end, int|float $step): bool
139+
{
140+
if ($step >= 0) {
141+
return false;
142+
}
143+
144+
// with a float step PHP 8.3 compares numbers, in which only a digit keeps its value
145+
if (!self::isFloatStep($step) && self::isCharacter($start) && self::isCharacter($end)) {
146+
return ord($start[0]) < ord($end[0]);
147+
}
148+
149+
// an empty string or a character next to a number counts as 0
150+
return self::toNumber($start) < self::toNumber($end);
151+
}
152+
153+
/**
154+
* PHP 8.3 keeps a step with a fractional part or beyond the integer range as a float.
155+
*/
156+
private static function isFloatStep(int|float $step): bool
157+
{
158+
if (!is_float($step)) {
159+
return false;
160+
}
161+
162+
return floor($step) !== $step || abs($step) >= self::INTEGER_STEP_LIMIT;
163+
}
164+
165+
/**
166+
* PHP 8.3 builds a character range from two single bytes, which includes a digit,
167+
* and takes the first byte of a longer non-numeric string.
168+
*
169+
* @phpstan-assert-if-true non-empty-string $value
170+
*/
171+
private static function isCharacter(int|float|string $value): bool
172+
{
173+
return is_string($value) && $value !== '' && (strlen($value) === 1 || !self::isNumeric($value));
174+
}
175+
176+
/**
177+
* PHP 8 accepts whitespace after a numeric string, which is_numeric() on PHP 7.4 does not.
178+
*/
179+
private static function isNumeric(int|float|string $value): bool
180+
{
181+
return is_numeric($value) || is_numeric(rtrim($value, " \t\n\r\v\f"));
182+
}
183+
184+
private static function isImprecise(int|float|string $value): bool
185+
{
186+
return !is_string($value) && abs($value) > self::PRECISE_INTEGER_LIMIT;
187+
}
188+
189+
private static function toNumber(int|float|string $value): float
190+
{
191+
return self::isNumeric($value) ? (float) $value : 0.0;
192+
}
193+
194+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Php;
4+
5+
use PHPStan\TrinaryLogic;
6+
use PHPUnit\Framework\Attributes\DataProvider;
7+
use PHPUnit\Framework\TestCase;
8+
9+
class RangeFunctionArgumentsHelperTest extends TestCase
10+
{
11+
12+
/**
13+
* @return iterable<array{int|float|string, int|float|string, int|float, bool}>
14+
*/
15+
public static function dataIsNegativeStepOnIncreasingRange(): iterable
16+
{
17+
yield [1, 5, -1, true];
18+
yield [5, 1, -1, false];
19+
yield [5, 5, -1, false];
20+
yield [1, 5, 1, false];
21+
22+
// two single bytes form a character range
23+
yield ['a', 'z', -1, true];
24+
yield ['z', 'a', -1, false];
25+
yield ['a', '5', -1, false];
26+
yield ['5', 'a', -1, true];
27+
yield ['ab', 'z', -1, true];
28+
yield ['a', 'z', -1.0, true];
29+
30+
// PHP 8.3 keeps a step with a fractional part or beyond the integer range as a float,
31+
// which turns a character into 0 unless it is a digit
32+
yield ['a', 'z', -0.5, false];
33+
yield ['0', '9', -0.0001, true];
34+
yield ['a', '9', -0.0001, true];
35+
yield ['9', 'a', -0.0001, false];
36+
yield ['a', 'z', -1e20, false];
37+
yield ['a', '9', -1e20, true];
38+
39+
// a numeric string with whitespace around it is a number, also on PHP 7.4
40+
yield ['5 ', 3, -1, false];
41+
yield [' 5', 3, -1, false];
42+
43+
// an empty string or a character next to a number is 0
44+
yield ['', 'a', -1, false];
45+
yield ['a', '12', -1, true];
46+
yield ['a', 5, -1, true];
47+
}
48+
49+
#[DataProvider('dataIsNegativeStepOnIncreasingRange')]
50+
public function testIsNegativeStepOnIncreasingRange(int|float|string $start, int|float|string $end, int|float $step, bool $expected): void
51+
{
52+
$this->assertSame($expected, RangeFunctionArgumentsHelper::isNegativeStepOnIncreasingRange($start, $end, $step));
53+
}
54+
55+
/**
56+
* @return iterable<array{int|float|string, int|float|string, int|float, TrinaryLogic}>
57+
*/
58+
public static function dataRejectsSincePhp83WithoutCallingRange(): iterable
59+
{
60+
yield [1, 10, 2, TrinaryLogic::createNo()];
61+
yield [1, 10, 20, TrinaryLogic::createYes()];
62+
63+
// PHP 8.3 compares an integral float step exactly, which a float cannot do above 2 ** 53
64+
yield [0, 1152921504606846975, 1152921504606846976.0, TrinaryLogic::createMaybe()];
65+
yield [1, 9007199254740993, 9007199254740992.0, TrinaryLogic::createMaybe()];
66+
}
67+
68+
#[DataProvider('dataRejectsSincePhp83WithoutCallingRange')]
69+
public function testRejectsSincePhp83WithoutCallingRange(int|float|string $start, int|float|string $end, int|float $step, TrinaryLogic $expected): void
70+
{
71+
$this->assertSame(
72+
$expected->describe(),
73+
RangeFunctionArgumentsHelper::rejects(TrinaryLogic::createYes(), $start, $end, $step, null)->describe(),
74+
);
75+
}
76+
77+
}

0 commit comments

Comments
 (0)