From 64edf286988e3ee4b71c497242ba91a3107dffce Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 23 Sep 2026 05:59:19 +0900 Subject: [PATCH 1/2] 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 --- src/Php/PhpVersions.php | 9 + src/Type/Php/RangeFunctionArgumentsHelper.php | 194 ++++++++++++++++++ .../Php/RangeFunctionArgumentsHelperTest.php | 77 +++++++ 3 files changed, 280 insertions(+) create mode 100644 src/Type/Php/RangeFunctionArgumentsHelper.php create mode 100644 tests/PHPStan/Type/Php/RangeFunctionArgumentsHelperTest.php diff --git a/src/Php/PhpVersions.php b/src/Php/PhpVersions.php index 426572ca849..720d3232bc3 100644 --- a/src/Php/PhpVersions.php +++ b/src/Php/PhpVersions.php @@ -71,4 +71,13 @@ public function supportsMaxMemoryLimit(): TrinaryLogic return IntegerRangeType::fromInterval(80500, null)->isSuperTypeOf($this->phpVersions)->result; } + /** + * PHP 8.3 rejects a negative step on an increasing range and a NAN step, builds a character + * range from two single bytes and no longer turns an integral float step into floats. + */ + public function hasStricterRangeFunction(): TrinaryLogic + { + return IntegerRangeType::fromInterval(80300, null)->isSuperTypeOf($this->phpVersions)->result; + } + } diff --git a/src/Type/Php/RangeFunctionArgumentsHelper.php b/src/Type/Php/RangeFunctionArgumentsHelper.php new file mode 100644 index 00000000000..a5e9c73bb60 --- /dev/null +++ b/src/Type/Php/RangeFunctionArgumentsHelper.php @@ -0,0 +1,194 @@ +|false false when range() rejects the arguments + */ + public static function callRange(int|float|string $start, int|float|string $end, int|float $step): array|false + { + try { + return @range($start, $end, $step); + } catch (ValueError) { + return false; + } + } + + /** + * @param TrinaryLogic $hasStricterRange whether the analysed PHP versions are 8.3 or newer + * @param bool|null $runtimeRejects what calling range() on the runtime told, null when it was not called + */ + public static function rejects( + TrinaryLogic $hasStricterRange, + int|float|string $start, + int|float|string $end, + int|float $step, + ?bool $runtimeRejects, + ): TrinaryLogic + { + $results = []; + if (!$hasStricterRange->no()) { + $results[] = self::rejectsSincePhp83($start, $end, $step, $runtimeRejects); + } + if (!$hasStricterRange->yes()) { + $results[] = self::rejectsBeforePhp83($start, $end, $step); + } + + return TrinaryLogic::extremeIdentity(...$results); + } + + private static function rejectsSincePhp83(int|float|string $start, int|float|string $end, int|float $step, ?bool $runtimeRejects): TrinaryLogic + { + // PHP 8.3 checks the step on its own before looking at the boundaries + if (!is_finite((float) $step) || (float) $step === 0.0 || $step === PHP_INT_MIN) { + return TrinaryLogic::createYes(); + } + + // calling range() only tells about PHP 8.3 when PHPStan itself runs on PHP 8.3 or newer + if (PHP_VERSION_ID < 80300) { + $runtimeRejects = null; + } + + if (self::isNegativeStepOnIncreasingRange($start, $end, $step)) { + return TrinaryLogic::createYes(); + } + + if ($runtimeRejects !== null) { + return TrinaryLogic::createFromBoolean($runtimeRejects); + } + + // the older rules stand in, but they compare floats where PHP 8.3 compares an integral float step exactly + if (self::isImprecise($start) || self::isImprecise($end) || self::isImprecise($step)) { + return TrinaryLogic::createMaybe(); + } + + return self::rejectsBeforePhp83($start, $end, $step); + } + + /** + * For numeric boundaries PHP 7 and 8.0-8.2 ignore the sign of the step and reject + * one that is 0 or wider than the range itself. + */ + private static function rejectsBeforePhp83(int|float|string $start, int|float|string $end, int|float $step): TrinaryLogic + { + if (is_string($start) || is_string($end)) { + // how a string boundary was coerced before PHP 8.3 is not modelled here + return TrinaryLogic::createMaybe(); + } + + if ( + is_int($start) && is_int($end) && is_int($step) + && (abs($start) > self::PRECISE_INTEGER_LIMIT || abs($end) > self::PRECISE_INTEGER_LIMIT || abs($step) > self::PRECISE_INTEGER_LIMIT) + ) { + // range() compares integers exactly, which the floats below cannot do anymore + return TrinaryLogic::createMaybe(); + } + + $start = (float) $start; + $end = (float) $end; + $step = abs((float) $step); + if (!is_finite($start) || !is_finite($end) || is_nan($step)) { + return TrinaryLogic::createMaybe(); + } + + if ($start === $end) { + // a step of 0 was only rejected for integer boundaries in this case + return TrinaryLogic::createMaybe(); + } + + return TrinaryLogic::createFromBoolean($step === 0.0 || abs($end - $start) < $step); + } + + /** + * PHP 8.3 rejects a negative step on an increasing range, while earlier versions ignored its sign. + */ + public static function isNegativeStepOnIncreasingRange(int|float|string $start, int|float|string $end, int|float $step): bool + { + if ($step >= 0) { + return false; + } + + // with a float step PHP 8.3 compares numbers, in which only a digit keeps its value + if (!self::isFloatStep($step) && self::isCharacter($start) && self::isCharacter($end)) { + return ord($start[0]) < ord($end[0]); + } + + // an empty string or a character next to a number counts as 0 + return self::toNumber($start) < self::toNumber($end); + } + + /** + * PHP 8.3 keeps a step with a fractional part or beyond the integer range as a float. + */ + private static function isFloatStep(int|float $step): bool + { + if (!is_float($step)) { + return false; + } + + return floor($step) !== $step || abs($step) >= self::INTEGER_STEP_LIMIT; + } + + /** + * PHP 8.3 builds a character range from two single bytes, which includes a digit, + * and takes the first byte of a longer non-numeric string. + * + * @phpstan-assert-if-true non-empty-string $value + */ + private static function isCharacter(int|float|string $value): bool + { + return is_string($value) && $value !== '' && (strlen($value) === 1 || !self::isNumeric($value)); + } + + /** + * PHP 8 accepts whitespace after a numeric string, which is_numeric() on PHP 7.4 does not. + */ + private static function isNumeric(int|float|string $value): bool + { + return is_numeric($value) || is_numeric(rtrim($value, " \t\n\r\v\f")); + } + + private static function isImprecise(int|float|string $value): bool + { + return !is_string($value) && abs($value) > self::PRECISE_INTEGER_LIMIT; + } + + private static function toNumber(int|float|string $value): float + { + return self::isNumeric($value) ? (float) $value : 0.0; + } + +} diff --git a/tests/PHPStan/Type/Php/RangeFunctionArgumentsHelperTest.php b/tests/PHPStan/Type/Php/RangeFunctionArgumentsHelperTest.php new file mode 100644 index 00000000000..e113aac2ceb --- /dev/null +++ b/tests/PHPStan/Type/Php/RangeFunctionArgumentsHelperTest.php @@ -0,0 +1,77 @@ + + */ + public static function dataIsNegativeStepOnIncreasingRange(): iterable + { + yield [1, 5, -1, true]; + yield [5, 1, -1, false]; + yield [5, 5, -1, false]; + yield [1, 5, 1, false]; + + // two single bytes form a character range + yield ['a', 'z', -1, true]; + yield ['z', 'a', -1, false]; + yield ['a', '5', -1, false]; + yield ['5', 'a', -1, true]; + yield ['ab', 'z', -1, true]; + yield ['a', 'z', -1.0, true]; + + // PHP 8.3 keeps a step with a fractional part or beyond the integer range as a float, + // which turns a character into 0 unless it is a digit + yield ['a', 'z', -0.5, false]; + yield ['0', '9', -0.0001, true]; + yield ['a', '9', -0.0001, true]; + yield ['9', 'a', -0.0001, false]; + yield ['a', 'z', -1e20, false]; + yield ['a', '9', -1e20, true]; + + // a numeric string with whitespace around it is a number, also on PHP 7.4 + yield ['5 ', 3, -1, false]; + yield [' 5', 3, -1, false]; + + // an empty string or a character next to a number is 0 + yield ['', 'a', -1, false]; + yield ['a', '12', -1, true]; + yield ['a', 5, -1, true]; + } + + #[DataProvider('dataIsNegativeStepOnIncreasingRange')] + public function testIsNegativeStepOnIncreasingRange(int|float|string $start, int|float|string $end, int|float $step, bool $expected): void + { + $this->assertSame($expected, RangeFunctionArgumentsHelper::isNegativeStepOnIncreasingRange($start, $end, $step)); + } + + /** + * @return iterable + */ + public static function dataRejectsSincePhp83WithoutCallingRange(): iterable + { + yield [1, 10, 2, TrinaryLogic::createNo()]; + yield [1, 10, 20, TrinaryLogic::createYes()]; + + // PHP 8.3 compares an integral float step exactly, which a float cannot do above 2 ** 53 + yield [0, 1152921504606846975, 1152921504606846976.0, TrinaryLogic::createMaybe()]; + yield [1, 9007199254740993, 9007199254740992.0, TrinaryLogic::createMaybe()]; + } + + #[DataProvider('dataRejectsSincePhp83WithoutCallingRange')] + public function testRejectsSincePhp83WithoutCallingRange(int|float|string $start, int|float|string $end, int|float $step, TrinaryLogic $expected): void + { + $this->assertSame( + $expected->describe(), + RangeFunctionArgumentsHelper::rejects(TrinaryLogic::createYes(), $start, $end, $step, null)->describe(), + ); + } + +} From fc6b43c1ff1f698265d3bb2a440146bb66faf89e Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 23 Sep 2026 06:20:30 +0900 Subject: [PATCH 2/2] 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 https://github.com/phpstan/phpstan/issues/10022 Co-authored-by: Claude Opus 5.5 Co-authored-by: Claude Opus 5 --- .../Php/RangeFunctionReturnTypeExtension.php | 174 ++++++++++++++---- .../PHPStan/Analyser/nsrt/bug-10022-php82.php | 14 ++ tests/PHPStan/Analyser/nsrt/bug-10022.php | 48 +++++ .../Analyser/nsrt/range-array-count-limit.php | 28 +++ .../Analyser/nsrt/range-invalid-step.php | 24 +++ 5 files changed, 257 insertions(+), 31 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-10022-php82.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-10022.php create mode 100644 tests/PHPStan/Analyser/nsrt/range-invalid-step.php diff --git a/src/Type/Php/RangeFunctionReturnTypeExtension.php b/src/Type/Php/RangeFunctionReturnTypeExtension.php index 0df7f55f616..f1c9a75cde2 100644 --- a/src/Type/Php/RangeFunctionReturnTypeExtension.php +++ b/src/Type/Php/RangeFunctionReturnTypeExtension.php @@ -6,11 +6,15 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; +use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\AccessoryLiteralStringType; +use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantFloatType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; @@ -20,21 +24,20 @@ use PHPStan\Type\IntegerRangeType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; +use PHPStan\Type\NeverType; use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; -use ValueError; use function abs; use function count; use function floor; -use function is_array; use function is_finite; +use function is_float; use function is_numeric; use function is_string; use function max; use function min; -use function range; #[AutowiredService] final class RangeFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension @@ -58,46 +61,77 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $endType = $scope->getType($args[1]->value); $stepType = count($args) >= 3 ? $scope->getType($args[2]->value) : new ConstantIntegerType(1); + $phpVersions = $scope->getPhpVersion(); + $hasStricterRange = $phpVersions->hasStricterRangeFunction(); + $throwsValueError = $phpVersions->throwsValueErrorForInternalFunctions(); + $constantReturnTypes = []; + $constantCombinations = 0; + $throwingCombinations = 0; + $hasSkippedCombination = false; + $hasUnknownCombination = false; $startConstants = $startType->getConstantScalarTypes(); foreach ($startConstants as $startConstant) { if (!$startConstant instanceof ConstantIntegerType && !$startConstant instanceof ConstantFloatType && !$startConstant instanceof ConstantStringType) { + $hasSkippedCombination = true; continue; } $endConstants = $endType->getConstantScalarTypes(); foreach ($endConstants as $endConstant) { if (!$endConstant instanceof ConstantIntegerType && !$endConstant instanceof ConstantFloatType && !$endConstant instanceof ConstantStringType) { + $hasSkippedCombination = true; continue; } $stepConstants = $stepType->getConstantScalarTypes(); foreach ($stepConstants as $stepConstant) { if (!$stepConstant instanceof ConstantIntegerType && !$stepConstant instanceof ConstantFloatType) { + $hasSkippedCombination = true; continue; } + $constantCombinations++; + // range() would allocate every item before the length could be checked $rangeLength = self::getRangeLength($startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue()); if ($rangeLength !== null && $rangeLength > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { - return self::getLongRangeType($startConstant, $endConstant, $stepConstant, $stepType); - } - - try { - $rangeValues = @range($startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue()); - } catch (ValueError) { + // without calling range() nothing rejects a negative step on an increasing range, which PHP 8.3 does + if (RangeFunctionArgumentsHelper::isNegativeStepOnIncreasingRange($startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue())) { + if ($hasStricterRange->yes()) { + $throwingCombinations++; + continue; + } + if ($hasStricterRange->maybe()) { + $hasUnknownCombination = true; + continue; + } + } + + $constantReturnTypes[] = self::getLongRangeType($hasStricterRange, $startConstant, $endConstant, $stepConstant, $stepType, null); continue; } - // @phpstan-ignore function.alreadyNarrowedType - if (!is_array($rangeValues)) { + $rangeValues = RangeFunctionArgumentsHelper::callRange($startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue()); + if ($rangeValues === false) { + $fails = RangeFunctionArgumentsHelper::rejects($hasStricterRange, $startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue(), true); + if (!$fails->yes()) { + // the analysed PHP version might accept the step, but only the runtime's verdict is known + $hasUnknownCombination = true; + } elseif ($throwsValueError->yes()) { + $throwingCombinations++; + } else { + $constantReturnTypes[] = new ConstantBooleanType(false); + } continue; } if (count($rangeValues) > self::RANGE_LENGTH_THRESHOLD) { - return self::getLongRangeType($startConstant, $endConstant, $stepConstant, $stepType); + $constantReturnTypes[] = self::getLongRangeType($hasStricterRange, $startConstant, $endConstant, $stepConstant, $stepType, $rangeValues); + continue; } + $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); foreach ($rangeValues as $value) { $arrayBuilder->setOffsetValueType(null, $scope->getTypeFromValue($value)); @@ -108,10 +142,29 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } } - if (count($constantReturnTypes) > 0) { - return TypeCombinator::union(...$constantReturnTypes); + if (!$hasUnknownCombination && !$hasSkippedCombination) { + if (count($constantReturnTypes) > 0) { + return TypeCombinator::union(...$constantReturnTypes); + } + + // nothing is returned when every combination of the constant arguments throws + if ( + $constantCombinations > 0 + && $throwingCombinations === $constantCombinations + && $startType->isConstantScalarValue()->yes() + && $endType->isConstantScalarValue()->yes() + && $stepType->isConstantScalarValue()->yes() + ) { + return new NeverType(); + } } + // the general type covers the combinations that could not be decided, the rest keep their own types + return TypeCombinator::union(self::getGeneralType($startType, $endType, $stepType), ...$constantReturnTypes); + } + + private static function getGeneralType(Type $startType, Type $endType, Type $stepType): Type + { $argType = TypeCombinator::union($startType, $endType); $isInteger = $argType->isInteger()->yes(); $isStepInteger = $stepType->isInteger()->yes(); @@ -174,37 +227,96 @@ private static function getRangeLength(int|float|string $start, int|float|string return floor($length) + 1; } + /** + * @param non-empty-list|null $rangeValues null when range() was not called + */ private static function getLongRangeType( + TrinaryLogic $hasStricterRange, ConstantIntegerType|ConstantFloatType|ConstantStringType $startConstant, ConstantIntegerType|ConstantFloatType|ConstantStringType $endConstant, ConstantIntegerType|ConstantFloatType $stepConstant, Type $stepType, + ?array $rangeValues, ): Type { + $type = self::getLongRangeItemsType($startConstant, $endConstant, $stepConstant, $stepType, $rangeValues); if ( - $startConstant instanceof ConstantIntegerType - && $endConstant instanceof ConstantIntegerType - && $stepConstant instanceof ConstantIntegerType + $hasStricterRange->yes() + || ( + !$startConstant instanceof ConstantFloatType + && !$endConstant instanceof ConstantFloatType + && !$stepConstant instanceof ConstantFloatType + ) ) { + return $type; + } + + // before PHP 8.3 a float argument produced floats even when none of the arguments had a fractional + // part, and even for two strings, of which only a numeric one kept its value + $floatListType = self::getNonEmptyListOfType(new FloatType()); + if ($hasStricterRange->no()) { + return $floatListType; + } + + return TypeCombinator::union($type, $floatListType); + } + + /** + * The type of the items the runtime returned. Without them it follows PHP 8.3 for numeric + * boundaries and generalizes the arguments for a string one. + * + * @param non-empty-list|null $rangeValues + */ + private static function getLongRangeItemsType( + ConstantIntegerType|ConstantFloatType|ConstantStringType $startConstant, + ConstantIntegerType|ConstantFloatType|ConstantStringType $endConstant, + ConstantIntegerType|ConstantFloatType $stepConstant, + Type $stepType, + ?array $rangeValues, + ): Type + { + $floatListType = self::getNonEmptyListOfType(new FloatType()); + + if ($rangeValues !== null) { + // range() only ever returns values of a single type + $firstValue = $rangeValues[0]; + $lastValue = $rangeValues[count($rangeValues) - 1]; + + if (is_string($firstValue) || is_string($lastValue)) { + // a character range consists of single bytes taken from constant boundaries + return self::getNonEmptyListOfType(TypeCombinator::intersect( + new StringType(), + new AccessoryNonEmptyStringType(), + new AccessoryLiteralStringType(), + )); + } + + if (is_float($firstValue) || is_float($lastValue)) { + return $floatListType; + } + + $bounds = $startConstant instanceof ConstantIntegerType && $endConstant instanceof ConstantIntegerType + ? [$startConstant->getValue(), $endConstant->getValue()] + : [$firstValue, $lastValue]; + } elseif ($startConstant instanceof ConstantFloatType || $endConstant instanceof ConstantFloatType) { + return $floatListType; + } elseif (!$startConstant instanceof ConstantIntegerType || !$endConstant instanceof ConstantIntegerType) { return self::getNonEmptyListOfType( - IntegerRangeType::fromInterval( - min($startConstant->getValue(), $endConstant->getValue()), - max($startConstant->getValue(), $endConstant->getValue()), + TypeCombinator::union( + $startConstant->generalize(GeneralizePrecision::moreSpecific()), + $endConstant->generalize(GeneralizePrecision::moreSpecific()), + $stepType->generalize(GeneralizePrecision::moreSpecific()), ), ); + } elseif (floor($stepConstant->getValue()) !== (float) $stepConstant->getValue()) { + // a step with a fractional part produces floats + return $floatListType; + } else { + $bounds = [$startConstant->getValue(), $endConstant->getValue()]; } - if ($stepType->isFloat()->yes()) { - return self::getNonEmptyListOfType(new FloatType()); - } - - return self::getNonEmptyListOfType( - TypeCombinator::union( - $startConstant->generalize(GeneralizePrecision::moreSpecific()), - $endConstant->generalize(GeneralizePrecision::moreSpecific()), - $stepType->generalize(GeneralizePrecision::moreSpecific()), - ), - ); + // the sequence is monotonic, so the first and the last value are its bounds + return self::getNonEmptyListOfType(IntegerRangeType::fromInterval(min($bounds), max($bounds))); } private static function getNonEmptyListOfType(Type $type): IntersectionType diff --git a/tests/PHPStan/Analyser/nsrt/bug-10022-php82.php b/tests/PHPStan/Analyser/nsrt/bug-10022-php82.php new file mode 100644 index 00000000000..ba3f436e41f --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10022-php82.php @@ -0,0 +1,14 @@ +', range(1, 200, 1.0)); diff --git a/tests/PHPStan/Analyser/nsrt/bug-10022.php b/tests/PHPStan/Analyser/nsrt/bug-10022.php new file mode 100644 index 00000000000..1bfb21b35dd --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10022.php @@ -0,0 +1,48 @@ += 8.3 + +namespace Bug10022; + +use function PHPStan\Testing\assertType; + +// https://github.com/phpstan/phpstan/issues/10022 +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')); + +function doFoo(bool $flag): void +{ + if (PHP_VERSION_ID >= 80300) { + // a negative step on an increasing range and a non-finite step throw a ValueError since PHP 8.3 + assertType('*NEVER*', range(2, 5, -1)); + assertType('*NEVER*', range('a', 'z', -1)); + assertType('*NEVER*', range('a', 'z', 0)); + assertType('*NEVER*', range(1, 10, NAN)); + + // an integral float step produces ints since PHP 8.3 + assertType('non-empty-list>', range(1, 200, 1.0)); + assertType('non-empty-list', range('A', 'z', 1.0)); + } elseif (PHP_VERSION_ID >= 80000) { + // the sign of the step used to be ignored + assertType('non-empty-list', range(2, 5, -1)); + assertType('non-empty-list', range('a', 'z', -1)); + + // a float argument used to produce floats even without a fractional part + assertType('non-empty-list', range(1, 200, 1.0)); + assertType('non-empty-list', range('A', 'z', 1.0)); + } else { + // PHP 7 returns false for an invalid step, next to what the other combinations return + assertType('non-empty-list|false', range(2, 5, $flag ? -1 : 0)); + assertType('non-empty-list|false', range(2, 5, $flag ? 0 : true)); + } + + // every combination of the constant arguments contributes to the type + assertType('non-empty-list<0|1|(literal-string&non-empty-string)>', range($flag ? 'A' : 1, 'z')); + assertType('non-empty-list>', range($flag ? 1.0 : 1, 100)); + assertType('non-empty-list>', range(1, 100, $flag ? 0.5 : 1)); + + // ranges longer than the threshold are generalized from the returned values + assertType('non-empty-list', range(1.0, 100.0)); + assertType('non-empty-list', range('A', 'z')); + + // a step that is neither an int nor a float is not folded at all, so nothing + // can be said about the combination it belongs to + assertType('non-empty-list', range(2, 5, $flag ? 0 : true)); +} diff --git a/tests/PHPStan/Analyser/nsrt/range-array-count-limit.php b/tests/PHPStan/Analyser/nsrt/range-array-count-limit.php index ca7161f4bb3..9e75671d7ab 100644 --- a/tests/PHPStan/Analyser/nsrt/range-array-count-limit.php +++ b/tests/PHPStan/Analyser/nsrt/range-array-count-limit.php @@ -13,3 +13,31 @@ function doFoo(): void assertType('non-empty-list', range(0, 100000000, 0.5)); assertType('non-empty-list>', range(0, 300)); } + +function doBar(): void +{ + if (PHP_VERSION_ID >= 80300) { + // a negative step on an increasing range throws a ValueError since PHP 8.3 + assertType('*NEVER*', range(1, 1000, -1)); + + // an integral float step produces ints since PHP 8.3 + assertType('non-empty-list>', range(1, 1000, 1.0)); + } else { + // the sign of the step used to be ignored + assertType('non-empty-list>', range(1, 1000, -1)); + + // a float argument used to produce floats even without a fractional part + assertType('non-empty-list', range(1, 1000, 1.0)); + } + + assertType('non-empty-list', range(1.0, 1000.0)); + assertType('non-empty-list', range(0, 1000, 0.5)); +} + +function doBaz(bool $flag): void +{ + if (PHP_VERSION_ID >= 80000) { + // PHP 8.2 accepts the first combination and PHP 8.3 rejects it + assertType('non-empty-list', range($flag ? 1 : 2000, 1000, -1)); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/range-invalid-step.php b/tests/PHPStan/Analyser/nsrt/range-invalid-step.php new file mode 100644 index 00000000000..f2a2e1ab3d1 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/range-invalid-step.php @@ -0,0 +1,24 @@ += 80000) { + // a step of 0 or one wider than the range throws a ValueError since PHP 8.0 + assertType('*NEVER*', range(2, 5, 0)); + assertType('*NEVER*', range(5, 6, 3)); + assertType('*NEVER*', range(1, 10, INF)); + assertType('array{6}', range($flag ? 5 : 6, 6, 3)); + assertType('non-empty-list>', range(1, $flag ? 2 : 300, 5)); + } else { + // PHP 7 reports an invalid step with a warning and returns false instead + assertType('false', range(2, 5, 0)); + assertType('false', range(5, 6, 3)); + assertType('false', range(1, 10, INF)); + assertType('array{6}|false', range($flag ? 5 : 6, 6, 3)); + assertType('non-empty-list>|false', range(1, $flag ? 2 : 300, 5)); + } +}