Skip to content

Commit 3695000

Browse files
committed
Check the step and the PHP version for ranges that skip calling range()
A range longer than ARRAY_COUNT_LIMIT gets its type without calling range(), so nothing rejected a negative step on an increasing range, which throws a ValueError since PHP 8.3. Such a range is now generalized the same way as one range() was called for, including the PHP 8.3 change that an integral float step produces ints, and a float boundary produces a list of floats instead of `list<float|int>`.
1 parent d520dc7 commit 3695000

2 files changed

Lines changed: 108 additions & 85 deletions

File tree

‎src/Type/Php/RangeFunctionReturnTypeExtension.php‎

Lines changed: 88 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,19 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
9999
// range() would allocate every item before the length could be checked
100100
$rangeLength = self::getRangeLength($startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue());
101101
if ($rangeLength !== null && $rangeLength > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
102-
return self::getLongRangeType($startConstant, $endConstant, $stepConstant, $stepType);
102+
// without calling range() nothing rejects a negative step on an increasing range, which PHP 8.3 does
103+
if (self::isNegativeStepOnIncreasingRange($startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue())) {
104+
if ($hasStricterRange->yes()) {
105+
$throwingCombinations++;
106+
continue;
107+
}
108+
if ($hasStricterRange->maybe()) {
109+
$hasUnknownCombination = true;
110+
continue;
111+
}
112+
}
113+
114+
return self::getLongRangeType($hasStricterRange, $startConstant, $endConstant, $stepConstant, $stepType, null);
103115
}
104116

105117
try {
@@ -123,31 +135,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
123135
}
124136

125137
if (count($rangeValues) > self::RANGE_LENGTH_THRESHOLD) {
126-
if (
127-
$startConstant instanceof ConstantIntegerType
128-
&& $endConstant instanceof ConstantIntegerType
129-
&& $stepConstant instanceof ConstantIntegerType
130-
) {
131-
if ($startConstant->getValue() > $endConstant->getValue()) {
132-
$tmp = $startConstant;
133-
$startConstant = $endConstant;
134-
$endConstant = $tmp;
135-
}
136-
return self::getNonEmptyListOfType(
137-
IntegerRangeType::fromInterval(
138-
$startConstant->getValue(),
139-
$endConstant->getValue(),
140-
),
141-
);
142-
}
143-
144-
return self::getLargeRangeType(
145-
$hasStricterRange,
146-
$rangeValues,
147-
$startConstant->getValue(),
148-
$endConstant->getValue(),
149-
$stepConstant->getValue(),
150-
);
138+
return self::getLongRangeType($hasStricterRange, $startConstant, $endConstant, $stepConstant, $stepType, $rangeValues);
151139
}
152140
$arrayBuilder = ConstantArrayTypeBuilder::createEmpty();
153141
foreach ($rangeValues as $value) {
@@ -256,48 +244,6 @@ private static function failsBeforePhp83(int|float|string $start, int|float|stri
256244
return $step === 0.0 || abs($end - $start) < $step;
257245
}
258246

259-
/**
260-
* @param non-empty-list<int|float|string> $rangeValues
261-
*/
262-
private static function getLargeRangeType(TrinaryLogic $hasStricterRange, array $rangeValues, int|float|string $start, int|float|string $end, int|float $step): Type
263-
{
264-
// range() only ever returns values of a single type
265-
$firstValue = $rangeValues[0];
266-
$lastValue = $rangeValues[count($rangeValues) - 1];
267-
268-
if (is_string($firstValue) || is_string($lastValue)) {
269-
// a character range consists of single bytes taken from constant boundaries
270-
return self::getNonEmptyListOfType(TypeCombinator::intersect(
271-
new StringType(),
272-
new AccessoryNonEmptyStringType(),
273-
new AccessoryLiteralStringType(),
274-
));
275-
}
276-
277-
$floatListType = self::getNonEmptyListOfType(new FloatType());
278-
if (is_float($firstValue) || is_float($lastValue)) {
279-
return $floatListType;
280-
}
281-
282-
// the sequence is monotonic, so the first and the last value are its bounds
283-
if ($firstValue > $lastValue) {
284-
[$firstValue, $lastValue] = [$lastValue, $firstValue];
285-
}
286-
287-
$intListType = self::getNonEmptyListOfType(IntegerRangeType::fromInterval($firstValue, $lastValue));
288-
if ($hasStricterRange->yes() || (!is_float($start) && !is_float($end) && !is_float($step))) {
289-
return $intListType;
290-
}
291-
292-
// before PHP 8.3 a float argument produced floats even when none of
293-
// the arguments had a fractional part
294-
if ($hasStricterRange->no()) {
295-
return $floatListType;
296-
}
297-
298-
return TypeCombinator::union($intListType, $floatListType);
299-
}
300-
301247
/**
302248
* The number of items range() creates for numeric arguments, or null when
303249
* only calling it tells - for a character range, which has at most 256
@@ -325,37 +271,94 @@ private static function getRangeLength(int|float|string $start, int|float|string
325271
return floor($length) + 1;
326272
}
327273

274+
private static function isNegativeStepOnIncreasingRange(int|float|string $start, int|float|string $end, int|float $step): bool
275+
{
276+
// a non-numeric string next to a number is 0
277+
$startNumber = is_numeric($start) ? (float) $start : 0.0;
278+
$endNumber = is_numeric($end) ? (float) $end : 0.0;
279+
280+
return $step < 0 && $startNumber < $endNumber;
281+
}
282+
283+
/**
284+
* @param non-empty-list<int|float|string>|null $rangeValues null when range() was not called
285+
*/
328286
private static function getLongRangeType(
287+
TrinaryLogic $hasStricterRange,
329288
ConstantIntegerType|ConstantFloatType|ConstantStringType $startConstant,
330289
ConstantIntegerType|ConstantFloatType|ConstantStringType $endConstant,
331290
ConstantIntegerType|ConstantFloatType $stepConstant,
332291
Type $stepType,
292+
?array $rangeValues,
333293
): Type
334294
{
335-
if (
336-
$startConstant instanceof ConstantIntegerType
337-
&& $endConstant instanceof ConstantIntegerType
338-
&& $stepConstant instanceof ConstantIntegerType
339-
) {
295+
$floatListType = self::getNonEmptyListOfType(new FloatType());
296+
297+
if ($rangeValues !== null) {
298+
// range() only ever returns values of a single type
299+
$firstValue = $rangeValues[0];
300+
$lastValue = $rangeValues[count($rangeValues) - 1];
301+
302+
if (is_string($firstValue) || is_string($lastValue)) {
303+
// a character range consists of single bytes taken from constant boundaries
304+
return self::getNonEmptyListOfType(TypeCombinator::intersect(
305+
new StringType(),
306+
new AccessoryNonEmptyStringType(),
307+
new AccessoryLiteralStringType(),
308+
));
309+
}
310+
311+
if (is_float($firstValue) || is_float($lastValue)) {
312+
return $floatListType;
313+
}
314+
} elseif ($startConstant instanceof ConstantFloatType || $endConstant instanceof ConstantFloatType) {
315+
return $floatListType;
316+
} elseif (!$startConstant instanceof ConstantIntegerType || !$endConstant instanceof ConstantIntegerType) {
340317
return self::getNonEmptyListOfType(
341-
IntegerRangeType::fromInterval(
342-
min($startConstant->getValue(), $endConstant->getValue()),
343-
max($startConstant->getValue(), $endConstant->getValue()),
318+
TypeCombinator::union(
319+
$startConstant->generalize(GeneralizePrecision::moreSpecific()),
320+
$endConstant->generalize(GeneralizePrecision::moreSpecific()),
321+
$stepType->generalize(GeneralizePrecision::moreSpecific()),
344322
),
345323
);
324+
} elseif (floor($stepConstant->getValue()) !== (float) $stepConstant->getValue()) {
325+
// a step with a fractional part produces floats
326+
return $floatListType;
327+
} else {
328+
$firstValue = $startConstant->getValue();
329+
$lastValue = $endConstant->getValue();
346330
}
347331

348-
if ($stepType->isFloat()->yes()) {
349-
return self::getNonEmptyListOfType(new FloatType());
332+
if ($startConstant instanceof ConstantIntegerType && $endConstant instanceof ConstantIntegerType) {
333+
$firstValue = $startConstant->getValue();
334+
$lastValue = $endConstant->getValue();
350335
}
351336

352-
return self::getNonEmptyListOfType(
353-
TypeCombinator::union(
354-
$startConstant->generalize(GeneralizePrecision::moreSpecific()),
355-
$endConstant->generalize(GeneralizePrecision::moreSpecific()),
356-
$stepType->generalize(GeneralizePrecision::moreSpecific()),
357-
),
358-
);
337+
// the sequence is monotonic, so the first and the last value are its bounds
338+
$intListType = self::getNonEmptyListOfType(IntegerRangeType::fromInterval(
339+
min($firstValue, $lastValue),
340+
max($firstValue, $lastValue),
341+
));
342+
343+
if (
344+
!$startConstant instanceof ConstantFloatType
345+
&& !$endConstant instanceof ConstantFloatType
346+
&& !$stepConstant instanceof ConstantFloatType
347+
) {
348+
return $intListType;
349+
}
350+
351+
// before PHP 8.3 a float argument produced floats even when none of
352+
// the arguments had a fractional part
353+
if ($hasStricterRange->yes()) {
354+
return $intListType;
355+
}
356+
357+
if ($hasStricterRange->no()) {
358+
return $floatListType;
359+
}
360+
361+
return TypeCombinator::union($intListType, $floatListType);
359362
}
360363

361364
private static function getNonEmptyListOfType(Type $type): IntersectionType

‎tests/PHPStan/Analyser/nsrt/range-array-count-limit.php‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,23 @@ function doFoo(): void
1313
assertType('non-empty-list<float>', range(0, 100000000, 0.5));
1414
assertType('non-empty-list<int<0, 300>>', range(0, 300));
1515
}
16+
17+
function doBar(): void
18+
{
19+
if (PHP_VERSION_ID >= 80300) {
20+
// a negative step on an increasing range throws a ValueError since PHP 8.3
21+
assertType('*NEVER*', range(1, 1000, -1));
22+
23+
// an integral float step produces ints since PHP 8.3
24+
assertType('non-empty-list<int<1, 1000>>', range(1, 1000, 1.0));
25+
} else {
26+
// the sign of the step used to be ignored
27+
assertType('non-empty-list<int<1, 1000>>', range(1, 1000, -1));
28+
29+
// a float argument used to produce floats even without a fractional part
30+
assertType('non-empty-list<float>', range(1, 1000, 1.0));
31+
}
32+
33+
assertType('non-empty-list<float>', range(1.0, 1000.0));
34+
assertType('non-empty-list<float>', range(0, 1000, 0.5));
35+
}

0 commit comments

Comments
 (0)