Skip to content

Commit b0bd984

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 0c3d777 commit b0bd984

5 files changed

Lines changed: 268 additions & 31 deletions

File tree

‎src/Type/Php/RangeFunctionReturnTypeExtension.php‎

Lines changed: 154 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,22 +24,21 @@
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;
3135
use function intdiv;
32-
use function is_array;
3336
use function is_finite;
37+
use function is_float;
3438
use function is_numeric;
3539
use function is_string;
3640
use function max;
3741
use function min;
38-
use function range;
3942
use const PHP_INT_MAX;
4043

4144
#[AutowiredService]
@@ -60,46 +63,77 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
6063
$endType = $scope->getType($args[1]->value);
6164
$stepType = count($args) >= 3 ? $scope->getType($args[2]->value) : new ConstantIntegerType(1);
6265

66+
$phpVersions = $scope->getPhpVersion();
67+
$hasStricterRange = $phpVersions->hasStricterRangeFunction();
68+
$throwsValueError = $phpVersions->throwsValueErrorForInternalFunctions();
69+
6370
$constantReturnTypes = [];
71+
$constantCombinations = 0;
72+
$throwingCombinations = 0;
73+
$hasSkippedCombination = false;
74+
$hasUnknownCombination = false;
6475

6576
$startConstants = $startType->getConstantScalarTypes();
6677
foreach ($startConstants as $startConstant) {
6778
if (!$startConstant instanceof ConstantIntegerType && !$startConstant instanceof ConstantFloatType && !$startConstant instanceof ConstantStringType) {
79+
$hasSkippedCombination = true;
6880
continue;
6981
}
7082

7183
$endConstants = $endType->getConstantScalarTypes();
7284
foreach ($endConstants as $endConstant) {
7385
if (!$endConstant instanceof ConstantIntegerType && !$endConstant instanceof ConstantFloatType && !$endConstant instanceof ConstantStringType) {
86+
$hasSkippedCombination = true;
7487
continue;
7588
}
7689

7790
$stepConstants = $stepType->getConstantScalarTypes();
7891
foreach ($stepConstants as $stepConstant) {
7992
if (!$stepConstant instanceof ConstantIntegerType && !$stepConstant instanceof ConstantFloatType) {
93+
$hasSkippedCombination = true;
8094
continue;
8195
}
8296

97+
$constantCombinations++;
98+
8399
// range() would allocate every item before the length could be checked
84100
$rangeLength = self::getRangeLength($startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue());
85101
if ($rangeLength !== null && $rangeLength > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
86-
return self::getLongRangeType($startConstant, $endConstant, $stepConstant, $stepType);
87-
}
88-
89-
try {
90-
$rangeValues = @range($startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue());
91-
} catch (ValueError) {
102+
// without calling range() nothing rejects a negative step on an increasing range, which PHP 8.3 does
103+
if (RangeFunctionArgumentsHelper::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+
$constantReturnTypes[] = self::getLongRangeType($hasStricterRange, $startConstant, $endConstant, $stepConstant, $stepType, null);
92115
continue;
93116
}
94117

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

100132
if (count($rangeValues) > self::RANGE_LENGTH_THRESHOLD) {
101-
return self::getLongRangeType($startConstant, $endConstant, $stepConstant, $stepType);
133+
$constantReturnTypes[] = self::getLongRangeType($hasStricterRange, $startConstant, $endConstant, $stepConstant, $stepType, $rangeValues);
134+
continue;
102135
}
136+
103137
$arrayBuilder = ConstantArrayTypeBuilder::createEmpty();
104138
foreach ($rangeValues as $value) {
105139
$arrayBuilder->setOffsetValueType(null, $scope->getTypeFromValue($value));
@@ -110,10 +144,29 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
110144
}
111145
}
112146

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

164+
// the general type covers the combinations that could not be decided, the rest keep their own types
165+
return TypeCombinator::union(self::getGeneralType($startType, $endType, $stepType), ...$constantReturnTypes);
166+
}
167+
168+
private static function getGeneralType(Type $startType, Type $endType, Type $stepType): Type
169+
{
117170
$argType = TypeCombinator::union($startType, $endType);
118171
$isInteger = $argType->isInteger()->yes();
119172
$isStepInteger = $stepType->isInteger()->yes();
@@ -176,39 +229,109 @@ private static function getRangeLength(int|float|string $start, int|float|string
176229
return floor($length) + 1;
177230
}
178231

232+
/**
233+
* @param non-empty-list<int|float|string>|null $rangeValues null when range() was not called
234+
*/
179235
private static function getLongRangeType(
236+
TrinaryLogic $hasStricterRange,
180237
ConstantIntegerType|ConstantFloatType|ConstantStringType $startConstant,
181238
ConstantIntegerType|ConstantFloatType|ConstantStringType $endConstant,
182239
ConstantIntegerType|ConstantFloatType $stepConstant,
183240
Type $stepType,
241+
?array $rangeValues,
184242
): Type
185243
{
244+
$type = self::getLongRangeItemsType($startConstant, $endConstant, $stepConstant, $stepType, $rangeValues);
186245
if (
187-
$startConstant instanceof ConstantIntegerType
188-
&& $endConstant instanceof ConstantIntegerType
189-
&& $stepConstant instanceof ConstantIntegerType
246+
$hasStricterRange->yes()
247+
|| (
248+
!$startConstant instanceof ConstantFloatType
249+
&& !$endConstant instanceof ConstantFloatType
250+
&& !$stepConstant instanceof ConstantFloatType
251+
)
190252
) {
191-
$low = min($startConstant->getValue(), $endConstant->getValue());
192-
$high = max($startConstant->getValue(), $endConstant->getValue());
253+
return $type;
254+
}
255+
256+
// before PHP 8.3 a float argument produced floats even when none of the arguments had a fractional
257+
// part, and even for two strings, of which only a numeric one kept its value
258+
$floatListType = self::getNonEmptyListOfType(new FloatType());
259+
if ($hasStricterRange->no()) {
260+
return $floatListType;
261+
}
262+
263+
return TypeCombinator::union($type, $floatListType);
264+
}
193265

266+
/**
267+
* The type of the items the runtime returned. Without them it follows PHP 8.3 for numeric
268+
* boundaries and generalizes the arguments for a string one.
269+
*
270+
* @param non-empty-list<int|float|string>|null $rangeValues
271+
*/
272+
private static function getLongRangeItemsType(
273+
ConstantIntegerType|ConstantFloatType|ConstantStringType $startConstant,
274+
ConstantIntegerType|ConstantFloatType|ConstantStringType $endConstant,
275+
ConstantIntegerType|ConstantFloatType $stepConstant,
276+
Type $stepType,
277+
?array $rangeValues,
278+
): Type
279+
{
280+
$floatListType = self::getNonEmptyListOfType(new FloatType());
281+
282+
if ($rangeValues !== null) {
283+
// range() only ever returns values of a single type
284+
$firstValue = $rangeValues[0];
285+
$lastValue = $rangeValues[count($rangeValues) - 1];
286+
287+
if (is_string($firstValue) || is_string($lastValue)) {
288+
// a character range consists of single bytes taken from constant boundaries
289+
return self::getNonEmptyListOfType(TypeCombinator::intersect(
290+
new StringType(),
291+
new AccessoryNonEmptyStringType(),
292+
new AccessoryLiteralStringType(),
293+
));
294+
}
295+
296+
if (is_float($firstValue) || is_float($lastValue)) {
297+
return $floatListType;
298+
}
299+
300+
$bounds = $startConstant instanceof ConstantIntegerType && $endConstant instanceof ConstantIntegerType
301+
? [$startConstant->getValue(), $endConstant->getValue()]
302+
: [$firstValue, $lastValue];
303+
} elseif ($startConstant instanceof ConstantFloatType || $endConstant instanceof ConstantFloatType) {
304+
return $floatListType;
305+
} elseif (!$startConstant instanceof ConstantIntegerType || !$endConstant instanceof ConstantIntegerType) {
194306
return self::getNonEmptyListOfType(
195-
IntegerRangeType::fromInterval($low, $high),
196-
// the keys stay unbounded when $high - $low overflows
197-
$low >= 0 || $high <= PHP_INT_MAX + $low ? intdiv($high - $low, abs($stepConstant->getValue())) : null,
307+
TypeCombinator::union(
308+
$startConstant->generalize(GeneralizePrecision::moreSpecific()),
309+
$endConstant->generalize(GeneralizePrecision::moreSpecific()),
310+
$stepType->generalize(GeneralizePrecision::moreSpecific()),
311+
),
198312
);
313+
} elseif (floor($stepConstant->getValue()) !== (float) $stepConstant->getValue()) {
314+
// a step with a fractional part produces floats
315+
return $floatListType;
316+
} else {
317+
$bounds = [$startConstant->getValue(), $endConstant->getValue()];
199318
}
200319

201-
if ($stepType->isFloat()->yes()) {
202-
return self::getNonEmptyListOfType(new FloatType());
320+
// the sequence is monotonic, so the first and the last value are its bounds
321+
$low = min($bounds);
322+
$high = max($bounds);
323+
$maxKey = null;
324+
if (
325+
$startConstant instanceof ConstantIntegerType
326+
&& $endConstant instanceof ConstantIntegerType
327+
&& $stepConstant instanceof ConstantIntegerType
328+
// the keys stay unbounded when $high - $low overflows
329+
&& ($low >= 0 || $high <= PHP_INT_MAX + $low)
330+
) {
331+
$maxKey = intdiv($high - $low, abs($stepConstant->getValue()));
203332
}
204333

205-
return self::getNonEmptyListOfType(
206-
TypeCombinator::union(
207-
$startConstant->generalize(GeneralizePrecision::moreSpecific()),
208-
$endConstant->generalize(GeneralizePrecision::moreSpecific()),
209-
$stepType->generalize(GeneralizePrecision::moreSpecific()),
210-
),
211-
);
334+
return self::getNonEmptyListOfType(IntegerRangeType::fromInterval($low, $high), $maxKey);
212335
}
213336

214337
private static function getNonEmptyListOfType(Type $type, ?int $maxKey = null): 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
@@ -35,3 +35,31 @@ function keys(): void
3535
assertType('int<0, max>', $key);
3636
}
3737
}
38+
39+
function doBar(): void
40+
{
41+
if (PHP_VERSION_ID >= 80300) {
42+
// a negative step on an increasing range throws a ValueError since PHP 8.3
43+
assertType('*NEVER*', range(1, 1000, -1));
44+
45+
// an integral float step produces ints since PHP 8.3
46+
assertType('non-empty-list<int<1, 1000>>', range(1, 1000, 1.0));
47+
} else {
48+
// the sign of the step used to be ignored
49+
assertType('non-empty-list<int<1, 1000>>', range(1, 1000, -1));
50+
51+
// a float argument used to produce floats even without a fractional part
52+
assertType('non-empty-list<float>', range(1, 1000, 1.0));
53+
}
54+
55+
assertType('non-empty-list<float>', range(1.0, 1000.0));
56+
assertType('non-empty-list<float>', range(0, 1000, 0.5));
57+
}
58+
59+
function doBaz(bool $flag): void
60+
{
61+
if (PHP_VERSION_ID >= 80000) {
62+
// PHP 8.2 accepts the first combination and PHP 8.3 rejects it
63+
assertType('non-empty-list<int>', range($flag ? 1 : 2000, 1000, -1));
64+
}
65+
}

0 commit comments

Comments
 (0)