Skip to content

Commit 2fb052c

Browse files
zonuexeclaude
andcommitted
Improve range() return type for invalid steps and long ranges
An invalid step makes range() throw a ValueError on PHP 8 and return false with a warning on PHP 7, so a call where every combination of the constant arguments is rejected is now `*NEVER*` on PHP 8 and `false` on PHP 7. The extension folds the constant arguments by calling range() on the PHP version PHPStan runs on, so RangeFunctionArgumentsHelper decides whether the PHP versions of the scope reject the step as well. A combination it cannot decide adds the general type to the union, next to the types of the other combinations. Long ranges are generalized from the values the range consists of, so range('A', 'z') is a list of single byte strings and, since PHP 8.3, where an integral float step stopped producing floats, range(1, 200, 1.0) is a list of ints. Before PHP 8.3 a float argument still produces floats. A range longer than ARRAY_COUNT_LIMIT, for which the extension skips calling range(), gets the same rules from its argument types, including the PHP 8.3 rejection of a negative step on an increasing range. Ref phpstan/phpstan#10022 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent facaa63 commit 2fb052c

5 files changed

Lines changed: 257 additions & 31 deletions

File tree

‎src/Type/Php/RangeFunctionReturnTypeExtension.php‎

Lines changed: 143 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@
66
use PHPStan\Analyser\Scope;
77
use PHPStan\DependencyInjection\AutowiredService;
88
use PHPStan\Reflection\FunctionReflection;
9+
use PHPStan\TrinaryLogic;
910
use PHPStan\Type\Accessory\AccessoryArrayListType;
11+
use PHPStan\Type\Accessory\AccessoryLiteralStringType;
12+
use PHPStan\Type\Accessory\AccessoryNonEmptyStringType;
1013
use PHPStan\Type\Accessory\NonEmptyArrayType;
1114
use PHPStan\Type\ArrayType;
1215
use PHPStan\Type\BenevolentUnionType;
1316
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
17+
use PHPStan\Type\Constant\ConstantBooleanType;
1418
use PHPStan\Type\Constant\ConstantFloatType;
1519
use PHPStan\Type\Constant\ConstantIntegerType;
1620
use PHPStan\Type\Constant\ConstantStringType;
@@ -20,21 +24,20 @@
2024
use PHPStan\Type\IntegerRangeType;
2125
use PHPStan\Type\IntegerType;
2226
use PHPStan\Type\IntersectionType;
27+
use PHPStan\Type\NeverType;
2328
use PHPStan\Type\StringType;
2429
use PHPStan\Type\Type;
2530
use PHPStan\Type\TypeCombinator;
2631
use PHPStan\Type\UnionType;
27-
use ValueError;
2832
use function abs;
2933
use function count;
3034
use function floor;
31-
use function is_array;
3235
use function is_finite;
36+
use function is_float;
3337
use function is_numeric;
3438
use function is_string;
3539
use function max;
3640
use function min;
37-
use function range;
3841

3942
#[AutowiredService]
4043
final class RangeFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
@@ -58,46 +61,77 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
5861
$endType = $scope->getType($args[1]->value);
5962
$stepType = count($args) >= 3 ? $scope->getType($args[2]->value) : new ConstantIntegerType(1);
6063

64+
$phpVersions = $scope->getPhpVersion();
65+
$hasStricterRange = $phpVersions->hasStricterRangeFunction();
66+
$throwsValueError = $phpVersions->throwsValueErrorForInternalFunctions();
67+
6168
$constantReturnTypes = [];
69+
$constantCombinations = 0;
70+
$throwingCombinations = 0;
71+
$hasSkippedCombination = false;
72+
$hasUnknownCombination = false;
6273

6374
$startConstants = $startType->getConstantScalarTypes();
6475
foreach ($startConstants as $startConstant) {
6576
if (!$startConstant instanceof ConstantIntegerType && !$startConstant instanceof ConstantFloatType && !$startConstant instanceof ConstantStringType) {
77+
$hasSkippedCombination = true;
6678
continue;
6779
}
6880

6981
$endConstants = $endType->getConstantScalarTypes();
7082
foreach ($endConstants as $endConstant) {
7183
if (!$endConstant instanceof ConstantIntegerType && !$endConstant instanceof ConstantFloatType && !$endConstant instanceof ConstantStringType) {
84+
$hasSkippedCombination = true;
7285
continue;
7386
}
7487

7588
$stepConstants = $stepType->getConstantScalarTypes();
7689
foreach ($stepConstants as $stepConstant) {
7790
if (!$stepConstant instanceof ConstantIntegerType && !$stepConstant instanceof ConstantFloatType) {
91+
$hasSkippedCombination = true;
7892
continue;
7993
}
8094

95+
$constantCombinations++;
96+
8197
// range() would allocate every item before the length could be checked
8298
$rangeLength = self::getRangeLength($startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue());
8399
if ($rangeLength !== null && $rangeLength > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
84-
return self::getLongRangeType($startConstant, $endConstant, $stepConstant, $stepType);
85-
}
86-
87-
try {
88-
$rangeValues = @range($startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue());
89-
} catch (ValueError) {
100+
// without calling range() nothing rejects a negative step on an increasing range, which PHP 8.3 does
101+
if (RangeFunctionArgumentsHelper::isNegativeStepOnIncreasingRange($startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue())) {
102+
if ($hasStricterRange->yes()) {
103+
$throwingCombinations++;
104+
continue;
105+
}
106+
if ($hasStricterRange->maybe()) {
107+
$hasUnknownCombination = true;
108+
continue;
109+
}
110+
}
111+
112+
$constantReturnTypes[] = self::getLongRangeType($hasStricterRange, $startConstant, $endConstant, $stepConstant, $stepType, null);
90113
continue;
91114
}
92115

93-
// @phpstan-ignore function.alreadyNarrowedType
94-
if (!is_array($rangeValues)) {
116+
$rangeValues = RangeFunctionArgumentsHelper::callRange($startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue());
117+
if ($rangeValues === false) {
118+
$fails = RangeFunctionArgumentsHelper::rejects($hasStricterRange, $startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue(), true);
119+
if (!$fails->yes()) {
120+
// the analysed PHP version might accept the step, but only the runtime's verdict is known
121+
$hasUnknownCombination = true;
122+
} elseif ($throwsValueError->yes()) {
123+
$throwingCombinations++;
124+
} else {
125+
$constantReturnTypes[] = new ConstantBooleanType(false);
126+
}
95127
continue;
96128
}
97129

98130
if (count($rangeValues) > self::RANGE_LENGTH_THRESHOLD) {
99-
return self::getLongRangeType($startConstant, $endConstant, $stepConstant, $stepType);
131+
$constantReturnTypes[] = self::getLongRangeType($hasStricterRange, $startConstant, $endConstant, $stepConstant, $stepType, $rangeValues);
132+
continue;
100133
}
134+
101135
$arrayBuilder = ConstantArrayTypeBuilder::createEmpty();
102136
foreach ($rangeValues as $value) {
103137
$arrayBuilder->setOffsetValueType(null, $scope->getTypeFromValue($value));
@@ -108,10 +142,29 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
108142
}
109143
}
110144

111-
if (count($constantReturnTypes) > 0) {
112-
return TypeCombinator::union(...$constantReturnTypes);
145+
if (!$hasUnknownCombination && !$hasSkippedCombination) {
146+
if (count($constantReturnTypes) > 0) {
147+
return TypeCombinator::union(...$constantReturnTypes);
148+
}
149+
150+
// nothing is returned when every combination of the constant arguments throws
151+
if (
152+
$constantCombinations > 0
153+
&& $throwingCombinations === $constantCombinations
154+
&& $startType->isConstantScalarValue()->yes()
155+
&& $endType->isConstantScalarValue()->yes()
156+
&& $stepType->isConstantScalarValue()->yes()
157+
) {
158+
return new NeverType();
159+
}
113160
}
114161

162+
// the general type covers the combinations that could not be decided, the rest keep their own types
163+
return TypeCombinator::union(self::getGeneralType($startType, $endType, $stepType), ...$constantReturnTypes);
164+
}
165+
166+
private static function getGeneralType(Type $startType, Type $endType, Type $stepType): Type
167+
{
115168
$argType = TypeCombinator::union($startType, $endType);
116169
$isInteger = $argType->isInteger()->yes();
117170
$isStepInteger = $stepType->isInteger()->yes();
@@ -174,37 +227,96 @@ private static function getRangeLength(int|float|string $start, int|float|string
174227
return floor($length) + 1;
175228
}
176229

230+
/**
231+
* @param non-empty-list<int|float|string>|null $rangeValues null when range() was not called
232+
*/
177233
private static function getLongRangeType(
234+
TrinaryLogic $hasStricterRange,
178235
ConstantIntegerType|ConstantFloatType|ConstantStringType $startConstant,
179236
ConstantIntegerType|ConstantFloatType|ConstantStringType $endConstant,
180237
ConstantIntegerType|ConstantFloatType $stepConstant,
181238
Type $stepType,
239+
?array $rangeValues,
182240
): Type
183241
{
242+
$type = self::getLongRangeItemsType($startConstant, $endConstant, $stepConstant, $stepType, $rangeValues);
184243
if (
185-
$startConstant instanceof ConstantIntegerType
186-
&& $endConstant instanceof ConstantIntegerType
187-
&& $stepConstant instanceof ConstantIntegerType
244+
$hasStricterRange->yes()
245+
|| (
246+
!$startConstant instanceof ConstantFloatType
247+
&& !$endConstant instanceof ConstantFloatType
248+
&& !$stepConstant instanceof ConstantFloatType
249+
)
188250
) {
251+
return $type;
252+
}
253+
254+
// before PHP 8.3 a float argument produced floats even when none of the arguments had a fractional
255+
// part, and even for two strings, of which only a numeric one kept its value
256+
$floatListType = self::getNonEmptyListOfType(new FloatType());
257+
if ($hasStricterRange->no()) {
258+
return $floatListType;
259+
}
260+
261+
return TypeCombinator::union($type, $floatListType);
262+
}
263+
264+
/**
265+
* The type of the items the runtime returned. Without them it follows PHP 8.3 for numeric
266+
* boundaries and generalizes the arguments for a string one.
267+
*
268+
* @param non-empty-list<int|float|string>|null $rangeValues
269+
*/
270+
private static function getLongRangeItemsType(
271+
ConstantIntegerType|ConstantFloatType|ConstantStringType $startConstant,
272+
ConstantIntegerType|ConstantFloatType|ConstantStringType $endConstant,
273+
ConstantIntegerType|ConstantFloatType $stepConstant,
274+
Type $stepType,
275+
?array $rangeValues,
276+
): Type
277+
{
278+
$floatListType = self::getNonEmptyListOfType(new FloatType());
279+
280+
if ($rangeValues !== null) {
281+
// range() only ever returns values of a single type
282+
$firstValue = $rangeValues[0];
283+
$lastValue = $rangeValues[count($rangeValues) - 1];
284+
285+
if (is_string($firstValue) || is_string($lastValue)) {
286+
// a character range consists of single bytes taken from constant boundaries
287+
return self::getNonEmptyListOfType(TypeCombinator::intersect(
288+
new StringType(),
289+
new AccessoryNonEmptyStringType(),
290+
new AccessoryLiteralStringType(),
291+
));
292+
}
293+
294+
if (is_float($firstValue) || is_float($lastValue)) {
295+
return $floatListType;
296+
}
297+
298+
$bounds = $startConstant instanceof ConstantIntegerType && $endConstant instanceof ConstantIntegerType
299+
? [$startConstant->getValue(), $endConstant->getValue()]
300+
: [$firstValue, $lastValue];
301+
} elseif ($startConstant instanceof ConstantFloatType || $endConstant instanceof ConstantFloatType) {
302+
return $floatListType;
303+
} elseif (!$startConstant instanceof ConstantIntegerType || !$endConstant instanceof ConstantIntegerType) {
189304
return self::getNonEmptyListOfType(
190-
IntegerRangeType::fromInterval(
191-
min($startConstant->getValue(), $endConstant->getValue()),
192-
max($startConstant->getValue(), $endConstant->getValue()),
305+
TypeCombinator::union(
306+
$startConstant->generalize(GeneralizePrecision::moreSpecific()),
307+
$endConstant->generalize(GeneralizePrecision::moreSpecific()),
308+
$stepType->generalize(GeneralizePrecision::moreSpecific()),
193309
),
194310
);
311+
} elseif (floor($stepConstant->getValue()) !== (float) $stepConstant->getValue()) {
312+
// a step with a fractional part produces floats
313+
return $floatListType;
314+
} else {
315+
$bounds = [$startConstant->getValue(), $endConstant->getValue()];
195316
}
196317

197-
if ($stepType->isFloat()->yes()) {
198-
return self::getNonEmptyListOfType(new FloatType());
199-
}
200-
201-
return self::getNonEmptyListOfType(
202-
TypeCombinator::union(
203-
$startConstant->generalize(GeneralizePrecision::moreSpecific()),
204-
$endConstant->generalize(GeneralizePrecision::moreSpecific()),
205-
$stepType->generalize(GeneralizePrecision::moreSpecific()),
206-
),
207-
);
318+
// the sequence is monotonic, so the first and the last value are its bounds
319+
return self::getNonEmptyListOfType(IntegerRangeType::fromInterval(min($bounds), max($bounds)));
208320
}
209321

210322
private static function getNonEmptyListOfType(Type $type): IntersectionType
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php // lint < 8.3
2+
3+
namespace Bug10022Php82;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
// before PHP 8.3 a numeric string boundary was implicitly cast to int
8+
assertType('array{1, 0}', range('1', 'a'));
9+
10+
// the sign of the step used to be ignored
11+
assertType('array{2, 3, 4, 5}', range(2, 5, -1));
12+
13+
// a float step with no fractional part used to produce floats
14+
assertType('non-empty-list<float>', range(1, 200, 1.0));
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php // lint >= 8.3
2+
3+
namespace Bug10022;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
// https://github.com/phpstan/phpstan/issues/10022
8+
assertType('array{\'1\', \'2\', \'3\', \'4\', \'5\', \'6\', \'7\', \'8\', \'9\', \':\', \';\', \'<\', \'=\', \'>\', \'?\', \'@\', \'A\', \'B\', \'C\', \'D\', \'E\', \'F\', \'G\', \'H\', \'I\', \'J\', \'K\', \'L\', \'M\', \'N\', \'O\', \'P\', \'Q\', \'R\', \'S\', \'T\', \'U\', \'V\', \'W\', \'X\', \'Y\', \'Z\', \'[\', \'\\\\\', \']\', \'^\', \'_\', \'`\', \'a\'}', range('1', 'a'));
9+
10+
function doFoo(bool $flag): void
11+
{
12+
if (PHP_VERSION_ID >= 80300) {
13+
// a negative step on an increasing range and a non-finite step throw a ValueError since PHP 8.3
14+
assertType('*NEVER*', range(2, 5, -1));
15+
assertType('*NEVER*', range('a', 'z', -1));
16+
assertType('*NEVER*', range('a', 'z', 0));
17+
assertType('*NEVER*', range(1, 10, NAN));
18+
19+
// an integral float step produces ints since PHP 8.3
20+
assertType('non-empty-list<int<1, 200>>', range(1, 200, 1.0));
21+
assertType('non-empty-list<literal-string&non-empty-string>', range('A', 'z', 1.0));
22+
} elseif (PHP_VERSION_ID >= 80000) {
23+
// the sign of the step used to be ignored
24+
assertType('non-empty-list<int>', range(2, 5, -1));
25+
assertType('non-empty-list<string>', range('a', 'z', -1));
26+
27+
// a float argument used to produce floats even without a fractional part
28+
assertType('non-empty-list<float>', range(1, 200, 1.0));
29+
assertType('non-empty-list<float>', range('A', 'z', 1.0));
30+
} else {
31+
// PHP 7 returns false for an invalid step, next to what the other combinations return
32+
assertType('non-empty-list<int>|false', range(2, 5, $flag ? -1 : 0));
33+
assertType('non-empty-list<float|int>|false', range(2, 5, $flag ? 0 : true));
34+
}
35+
36+
// every combination of the constant arguments contributes to the type
37+
assertType('non-empty-list<0|1|(literal-string&non-empty-string)>', range($flag ? 'A' : 1, 'z'));
38+
assertType('non-empty-list<float|int<1, 100>>', range($flag ? 1.0 : 1, 100));
39+
assertType('non-empty-list<float|int<1, 100>>', range(1, 100, $flag ? 0.5 : 1));
40+
41+
// ranges longer than the threshold are generalized from the returned values
42+
assertType('non-empty-list<float>', range(1.0, 100.0));
43+
assertType('non-empty-list<literal-string&non-empty-string>', range('A', 'z'));
44+
45+
// a step that is neither an int nor a float is not folded at all, so nothing
46+
// can be said about the combination it belongs to
47+
assertType('non-empty-list<float|int>', range(2, 5, $flag ? 0 : true));
48+
}

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,31 @@ 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+
}
36+
37+
function doBaz(bool $flag): void
38+
{
39+
if (PHP_VERSION_ID >= 80000) {
40+
// PHP 8.2 accepts the first combination and PHP 8.3 rejects it
41+
assertType('non-empty-list<int>', range($flag ? 1 : 2000, 1000, -1));
42+
}
43+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
namespace RangeInvalidStep;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
function doFoo(bool $flag): void
8+
{
9+
if (PHP_VERSION_ID >= 80000) {
10+
// a step of 0 or one wider than the range throws a ValueError since PHP 8.0
11+
assertType('*NEVER*', range(2, 5, 0));
12+
assertType('*NEVER*', range(5, 6, 3));
13+
assertType('*NEVER*', range(1, 10, INF));
14+
assertType('array{6}', range($flag ? 5 : 6, 6, 3));
15+
assertType('non-empty-list<int<1, 300>>', range(1, $flag ? 2 : 300, 5));
16+
} else {
17+
// PHP 7 reports an invalid step with a warning and returns false instead
18+
assertType('false', range(2, 5, 0));
19+
assertType('false', range(5, 6, 3));
20+
assertType('false', range(1, 10, INF));
21+
assertType('array{6}|false', range($flag ? 5 : 6, 6, 3));
22+
assertType('non-empty-list<int<1, 300>>|false', range(1, $flag ? 2 : 300, 5));
23+
}
24+
}

0 commit comments

Comments
 (0)