Skip to content

Commit 596668a

Browse files
committed
Use the PHP versions of the scope in the range() return type extension
`Scope::getPhpVersion()` follows conditions like `PHP_VERSION_ID < 80000`, so the tests move into `nsrt/` and no longer need separate test classes with a fixed `phpVersion`. When a combination fails at runtime but the analysed PHP versions might accept its step, the other constant combinations are not returned on their own anymore.
1 parent 4f4f737 commit 596668a

10 files changed

Lines changed: 107 additions & 172 deletions

‎src/Php/PhpVersion.php‎

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -531,13 +531,4 @@ public function throwsOnStringCast(): bool
531531
return $this->versionId >= 70400;
532532
}
533533

534-
/**
535-
* PHP 8.3 rejects a negative or non-finite step, always builds a character range
536-
* from two string boundaries and no longer turns an integral float step into floats.
537-
*/
538-
public function hasStricterRangeFunction(): bool
539-
{
540-
return $this->versionId >= 80300;
541-
}
542-
543534
}

‎src/Php/PhpVersions.php‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,18 @@ public function supportsMaxMemoryLimit(): TrinaryLogic
6161
return IntegerRangeType::fromInterval(80500, null)->isSuperTypeOf($this->phpVersions)->result;
6262
}
6363

64+
public function throwsValueErrorForInternalFunctions(): TrinaryLogic
65+
{
66+
return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result;
67+
}
68+
69+
/**
70+
* PHP 8.3 rejects a negative or non-finite step, always builds a character range
71+
* from two string boundaries and no longer turns an integral float step into floats.
72+
*/
73+
public function hasStricterRangeFunction(): TrinaryLogic
74+
{
75+
return IntegerRangeType::fromInterval(80300, null)->isSuperTypeOf($this->phpVersions)->result;
76+
}
77+
6478
}

‎src/Type/Php/RangeFunctionReturnTypeExtension.php‎

Lines changed: 48 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
use PhpParser\Node\Expr\FuncCall;
66
use PHPStan\Analyser\Scope;
77
use PHPStan\DependencyInjection\AutowiredService;
8-
use PHPStan\Php\PhpVersion;
98
use PHPStan\Reflection\FunctionReflection;
9+
use PHPStan\TrinaryLogic;
1010
use PHPStan\Type\Accessory\AccessoryArrayListType;
1111
use PHPStan\Type\Accessory\AccessoryLiteralStringType;
1212
use PHPStan\Type\Accessory\AccessoryNonEmptyStringType;
@@ -42,10 +42,6 @@ final class RangeFunctionReturnTypeExtension implements DynamicFunctionReturnTyp
4242

4343
private const RANGE_LENGTH_THRESHOLD = 50;
4444

45-
public function __construct(private PhpVersion $phpVersion)
46-
{
47-
}
48-
4945
public function isFunctionSupported(FunctionReflection $functionReflection): bool
5046
{
5147
return $functionReflection->getName() === 'range';
@@ -62,10 +58,15 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
6258
$endType = $scope->getType($args[1]->value);
6359
$stepType = count($args) >= 3 ? $scope->getType($args[2]->value) : new ConstantIntegerType(1);
6460

61+
$phpVersions = $scope->getPhpVersion();
62+
$hasStricterRange = $phpVersions->hasStricterRangeFunction();
63+
$throwsValueError = $phpVersions->throwsValueErrorForInternalFunctions();
64+
6565
$constantReturnTypes = [];
6666
$constantCombinations = 0;
6767
$throwingCombinations = 0;
6868
$hasSkippedCombination = false;
69+
$hasUnknownCombination = false;
6970

7071
$startConstants = $startType->getConstantScalarTypes();
7172
foreach ($startConstants as $startConstant) {
@@ -98,12 +99,14 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
9899

99100
// PHP 7 reports an invalid step by returning false instead of throwing a ValueError
100101
if ($rangeValues === false) {
101-
if ($this->failsOnAnalysedVersion($startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue())) {
102-
if ($this->phpVersion->throwsValueErrorForInternalFunctions()) {
103-
$throwingCombinations++;
104-
} else {
105-
$constantReturnTypes[] = new ConstantBooleanType(false);
106-
}
102+
$fails = self::failsOnAnalysedVersion($hasStricterRange, $startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue());
103+
if (!$fails->yes()) {
104+
// the analysed PHP version might accept the step, but only the runtime's verdict is known
105+
$hasUnknownCombination = true;
106+
} elseif ($throwsValueError->yes()) {
107+
$throwingCombinations++;
108+
} else {
109+
$constantReturnTypes[] = new ConstantBooleanType(false);
107110
}
108111
continue;
109112
}
@@ -127,7 +130,8 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
127130
);
128131
}
129132

130-
return $this->getLargeRangeType(
133+
return self::getLargeRangeType(
134+
$hasStricterRange,
131135
$rangeValues,
132136
$startConstant->getValue(),
133137
$endConstant->getValue(),
@@ -144,6 +148,10 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
144148
}
145149
}
146150

151+
if ($hasUnknownCombination) {
152+
$constantReturnTypes = [];
153+
}
154+
147155
if (count($constantReturnTypes) > 0) {
148156
return TypeCombinator::union(...$constantReturnTypes);
149157
}
@@ -198,14 +206,22 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
198206
/**
199207
* The constant arguments are folded by calling range() on the PHP version PHPStan
200208
* itself runs on, so a failure only says that the runtime rejects the step.
201-
* This checks whether the analysed PHP version rejects it as well.
209+
* This checks whether the analysed PHP versions reject it as well.
202210
*/
203-
private function failsOnAnalysedVersion(int|float|string $start, int|float|string $end, int|float $step): bool
211+
private static function failsOnAnalysedVersion(TrinaryLogic $hasStricterRange, int|float|string $start, int|float|string $end, int|float $step): TrinaryLogic
204212
{
205-
if ($this->phpVersion->hasStricterRangeFunction()) {
206-
return true;
213+
if (self::failsBeforePhp83($start, $end, $step)) {
214+
return TrinaryLogic::createYes();
207215
}
208216

217+
return $hasStricterRange;
218+
}
219+
220+
/**
221+
* PHP 7.x reported these steps with a warning and false, PHP 8.0-8.2 with a ValueError.
222+
*/
223+
private static function failsBeforePhp83(int|float|string $start, int|float|string $end, int|float $step): bool
224+
{
209225
if (is_string($start) || is_string($end)) {
210226
// how a string boundary was coerced before PHP 8.3 is not modelled here
211227
return false;
@@ -232,7 +248,7 @@ private function failsOnAnalysedVersion(int|float|string $start, int|float|strin
232248
/**
233249
* @param non-empty-list<int|float|string> $rangeValues
234250
*/
235-
private function getLargeRangeType(array $rangeValues, int|float|string $start, int|float|string $end, int|float $step): IntersectionType
251+
private static function getLargeRangeType(TrinaryLogic $hasStricterRange, array $rangeValues, int|float|string $start, int|float|string $end, int|float $step): Type
236252
{
237253
// range() only ever returns values of a single type
238254
$firstValue = $rangeValues[0];
@@ -247,25 +263,28 @@ private function getLargeRangeType(array $rangeValues, int|float|string $start,
247263
));
248264
}
249265

250-
if (
251-
is_float($firstValue)
252-
|| is_float($lastValue)
253-
// before PHP 8.3 a float argument produced floats even when none of
254-
// the arguments had a fractional part
255-
|| (
256-
!$this->phpVersion->hasStricterRangeFunction()
257-
&& (is_float($start) || is_float($end) || is_float($step))
258-
)
259-
) {
260-
return self::getNonEmptyListOfType(new FloatType());
266+
$floatListType = self::getNonEmptyListOfType(new FloatType());
267+
if (is_float($firstValue) || is_float($lastValue)) {
268+
return $floatListType;
261269
}
262270

263271
// the sequence is monotonic, so the first and the last value are its bounds
264272
if ($firstValue > $lastValue) {
265273
[$firstValue, $lastValue] = [$lastValue, $firstValue];
266274
}
267275

268-
return self::getNonEmptyListOfType(IntegerRangeType::fromInterval($firstValue, $lastValue));
276+
$intListType = self::getNonEmptyListOfType(IntegerRangeType::fromInterval($firstValue, $lastValue));
277+
if ($hasStricterRange->yes() || (!is_float($start) && !is_float($end) && !is_float($step))) {
278+
return $intListType;
279+
}
280+
281+
// before PHP 8.3 a float argument produced floats even when none of
282+
// the arguments had a fractional part
283+
if ($hasStricterRange->no()) {
284+
return $floatListType;
285+
}
286+
287+
return TypeCombinator::union($intListType, $floatListType);
269288
}
270289

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

‎tests/PHPStan/Analyser/RangePhp74Test.php‎

Lines changed: 0 additions & 36 deletions
This file was deleted.

‎tests/PHPStan/Analyser/RangePhp82Test.php‎

Lines changed: 0 additions & 39 deletions
This file was deleted.

‎tests/PHPStan/Analyser/data/range-php74.php‎

Lines changed: 0 additions & 15 deletions
This file was deleted.

‎tests/PHPStan/Analyser/data/range-php82.php‎

Lines changed: 0 additions & 25 deletions
This file was deleted.

‎tests/PHPStan/Analyser/nodeScopeResolverPhp82.neon‎

Lines changed: 0 additions & 2 deletions
This file was deleted.

‎tests/PHPStan/Analyser/nsrt/bug-10022.php‎

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,32 @@
77
// https://github.com/phpstan/phpstan/issues/10022
88
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'));
99

10-
// an invalid step is reported as a ValueError since PHP 8.0
11-
assertType('*NEVER*', range(2, 5, 0));
12-
assertType('*NEVER*', range('a', 'z', 0));
13-
assertType('*NEVER*', range(5, 6, 3));
14-
assertType('*NEVER*', range(2, 5, -1));
15-
assertType('*NEVER*', range('a', 'z', -1));
16-
assertType('*NEVER*', range(1, 10, INF));
17-
assertType('*NEVER*', range(1, 10, NAN));
18-
19-
function maybeInvalidStep(bool $flag): void
10+
function doFoo(bool $flag): void
2011
{
21-
// only one of the constant combinations throws, the other one returns a range
22-
assertType('array{6}', range($flag ? 5 : 6, 6, 3));
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+
} elseif (PHP_VERSION_ID >= 80000) {
22+
// the sign of the step used to be ignored
23+
assertType('non-empty-list<int>', range(2, 5, -1));
24+
assertType('non-empty-list<string>', range('a', 'z', -1));
25+
assertType('non-empty-list<float|int>', range(1, 10, NAN));
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+
}
30+
31+
// ranges longer than the threshold are generalized from the returned values
32+
assertType('non-empty-list<float>', range(1.0, 100.0));
33+
assertType('non-empty-list<literal-string&non-empty-string>', range('A', 'z'));
2334

2435
// a step that is neither an int nor a float is not folded at all, so nothing
2536
// can be said about the combination it belongs to
2637
assertType('non-empty-list<float|int>', range(2, 5, $flag ? 0 : true));
2738
}
28-
29-
// ranges longer than the threshold are generalized from the returned values
30-
assertType('non-empty-list<int<1, 200>>', range(1, 200, 1.0));
31-
assertType('non-empty-list<float>', range(1.0, 100.0));
32-
assertType('non-empty-list<literal-string&non-empty-string>', range('A', 'z'));
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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+
} else {
16+
// PHP 7 reports an invalid step with a warning and returns false instead
17+
assertType('false', range(2, 5, 0));
18+
assertType('false', range(5, 6, 3));
19+
assertType('false', range(1, 10, INF));
20+
assertType('array{6}|false', range($flag ? 5 : 6, 6, 3));
21+
}
22+
}

0 commit comments

Comments
 (0)