From 0c3d777770774c62e28e9021c8596cf46500cfb5 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 23 Sep 2026 05:59:19 +0900 Subject: [PATCH 1/5] 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 84bd2c3f5f2..22c404acc99 100644 --- a/src/Php/PhpVersions.php +++ b/src/Php/PhpVersions.php @@ -284,4 +284,13 @@ public function throwsValueErrorForInvalidRoundingMode(): TrinaryLogic return IntegerRangeType::fromInterval(80400, 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 b0bd9844f8c50b4027d4d8f8730c2fe3dc5e8ccd Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 23 Sep 2026 06:20:30 +0900 Subject: [PATCH 2/5] 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 | 185 +++++++++++++++--- .../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, 268 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 b9edc9de93e..09936a88d51 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,22 +24,21 @@ 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 intdiv; -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; use const PHP_INT_MAX; #[AutowiredService] @@ -60,46 +63,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)); @@ -110,10 +144,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(); @@ -176,39 +229,109 @@ 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 + ) ) { - $low = min($startConstant->getValue(), $endConstant->getValue()); - $high = max($startConstant->getValue(), $endConstant->getValue()); + 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($low, $high), - // the keys stay unbounded when $high - $low overflows - $low >= 0 || $high <= PHP_INT_MAX + $low ? intdiv($high - $low, abs($stepConstant->getValue())) : null, + 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()); + // the sequence is monotonic, so the first and the last value are its bounds + $low = min($bounds); + $high = max($bounds); + $maxKey = null; + if ( + $startConstant instanceof ConstantIntegerType + && $endConstant instanceof ConstantIntegerType + && $stepConstant instanceof ConstantIntegerType + // the keys stay unbounded when $high - $low overflows + && ($low >= 0 || $high <= PHP_INT_MAX + $low) + ) { + $maxKey = intdiv($high - $low, abs($stepConstant->getValue())); } - return self::getNonEmptyListOfType( - TypeCombinator::union( - $startConstant->generalize(GeneralizePrecision::moreSpecific()), - $endConstant->generalize(GeneralizePrecision::moreSpecific()), - $stepType->generalize(GeneralizePrecision::moreSpecific()), - ), - ); + return self::getNonEmptyListOfType(IntegerRangeType::fromInterval($low, $high), $maxKey); } private static function getNonEmptyListOfType(Type $type, ?int $maxKey = null): 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 b87a4f78807..8910a88aca4 100644 --- a/tests/PHPStan/Analyser/nsrt/range-array-count-limit.php +++ b/tests/PHPStan/Analyser/nsrt/range-array-count-limit.php @@ -35,3 +35,31 @@ function keys(): void assertType('int<0, max>', $key); } } + +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)); + } +} From d43536383ec3faf141f534e7915d7275dc9a9b2e Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 3 Nov 2026 02:16:48 +0900 Subject: [PATCH 3/5] Test RangeFunctionArgumentsHelper for PHP versions on both sides of 8.3 When the analysed PHP versions only maybe include 8.3, rejects() combines the PHP 8.3 rules with the older ones. range(1, 10, -1) tells them apart: PHP 8.3 rejects the negative step on an increasing range, while earlier versions ignore its sign. --- .../Php/RangeFunctionArgumentsHelperTest.php | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/PHPStan/Type/Php/RangeFunctionArgumentsHelperTest.php b/tests/PHPStan/Type/Php/RangeFunctionArgumentsHelperTest.php index e113aac2ceb..7d790c50090 100644 --- a/tests/PHPStan/Type/Php/RangeFunctionArgumentsHelperTest.php +++ b/tests/PHPStan/Type/Php/RangeFunctionArgumentsHelperTest.php @@ -74,4 +74,28 @@ public function testRejectsSincePhp83WithoutCallingRange(int|float|string $start ); } + /** + * @return iterable + */ + public static function dataRejectsAcrossPhpVersions(): iterable + { + // PHP 8.3 rejects a negative step on an increasing range, earlier versions ignore its sign + yield [TrinaryLogic::createYes(), 1, 10, -1, TrinaryLogic::createYes()]; + yield [TrinaryLogic::createNo(), 1, 10, -1, TrinaryLogic::createNo()]; + yield [TrinaryLogic::createMaybe(), 1, 10, -1, TrinaryLogic::createMaybe()]; + + // every PHP version agrees on these + yield [TrinaryLogic::createMaybe(), 1, 10, 20, TrinaryLogic::createYes()]; + yield [TrinaryLogic::createMaybe(), 1, 10, 2, TrinaryLogic::createNo()]; + } + + #[DataProvider('dataRejectsAcrossPhpVersions')] + public function testRejectsAcrossPhpVersions(TrinaryLogic $hasStricterRange, int|float|string $start, int|float|string $end, int|float $step, TrinaryLogic $expected): void + { + $this->assertSame( + $expected->describe(), + RangeFunctionArgumentsHelper::rejects($hasStricterRange, $start, $end, $step, null)->describe(), + ); + } + } From fefa3a9253951b17224f25b3d5c2db0f8d52a4b7 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 3 Nov 2026 02:45:01 +0900 Subject: [PATCH 4/5] Test range() return type for undecided PHP versions and arguments On PHP versions on both sides of 8.0 an invalid step returns false on PHP 7 and throws on PHP 8, so the type is false rather than never. An argument that is only partly a constant might still make a valid range, so it keeps the general type. --- .../PHPStan/Analyser/nsrt/range-invalid-step.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/range-invalid-step.php b/tests/PHPStan/Analyser/nsrt/range-invalid-step.php index f2a2e1ab3d1..ce4402f9380 100644 --- a/tests/PHPStan/Analyser/nsrt/range-invalid-step.php +++ b/tests/PHPStan/Analyser/nsrt/range-invalid-step.php @@ -22,3 +22,19 @@ function doFoo(bool $flag): void assertType('non-empty-list>|false', range(1, $flag ? 2 : 300, 5)); } } + +function doBar(bool $flag, string $s): void +{ + // a guard with an upper bound only would leave the analysed PHP version at 8.0 + if (PHP_VERSION_ID >= 70400 && PHP_VERSION_ID < 80100) { + // PHP 7 returns false, PHP 8.0 throws a ValueError + assertType('false', range(2, 5, 0)); + } + + if (PHP_VERSION_ID >= 80000) { + // only a part of an argument is a constant, the rest might make a valid range + assertType('non-empty-list<(float|int|string)>', range($flag ? 5 : $s, 6, 3)); + assertType('non-empty-list<(float|int|string)>', range(5, $flag ? 6 : $s, 3)); + assertType('non-empty-list', range(2, 5, $flag ? 0 : $s)); + } +} From 4fbfd672cb1252d14dc456939a257b4402e2dfbe Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 3 Nov 2026 03:42:42 +0900 Subject: [PATCH 5/5] Drop the constant checks that the combination count already covers getConstantScalarTypes() returns nothing for a type that is only partly a constant, so such an argument leaves no combination, and a non-zero number of combinations already means that every argument is a constant. The isConstantScalarValue() checks before returning never were therefore always true. --- src/Type/Php/RangeFunctionReturnTypeExtension.php | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/Type/Php/RangeFunctionReturnTypeExtension.php b/src/Type/Php/RangeFunctionReturnTypeExtension.php index 09936a88d51..b82faa8dbc9 100644 --- a/src/Type/Php/RangeFunctionReturnTypeExtension.php +++ b/src/Type/Php/RangeFunctionReturnTypeExtension.php @@ -149,14 +149,9 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, 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() - ) { + // nothing is returned when every combination of the constant arguments throws; an argument + // that is only partly a constant has no constant scalar types, so it leaves no combination + if ($constantCombinations > 0 && $throwingCombinations === $constantCombinations) { return new NeverType(); } }