From f56d927f7f3ffd4a8cd3881132b5b85783c53948 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 a86478620d4ea1e88739e34968a962e7e474ec36 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 23 Sep 2026 05:59:20 +0900 Subject: [PATCH 2/2] Add range() throw type extension PhpStorm stubs declare `@throws \ValueError` on range(), so every call became an explicit throw point and a catch around a valid call was never reported as dead. The extension returns void on PHP 7, which does not throw, and on PHP 8 when the native argument types are constant numbers or strings, RangeFunctionArgumentsHelper finds that no analysed PHP version rejects them, and the range has fewer items than RANGE_SIZE_LIMIT, the HT_MAX_SIZE of a 32-bit PHP. A range whose number of items does not fit into a float keeps the throw point as well. Co-authored-by: Claude Opus 5.5 --- build/baseline-pre-8.0.neon | 12 ++ .../Php/RangeFunctionReturnTypeExtension.php | 2 +- .../Php/RangeFunctionThrowTypeExtension.php | 118 +++++++++++++++++ .../CatchWithUnthrownExceptionRuleTest.php | 110 ++++++++++++++++ .../Exceptions/data/range-throw-type.php | 121 ++++++++++++++++++ 5 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 src/Type/Php/RangeFunctionThrowTypeExtension.php create mode 100644 tests/PHPStan/Rules/Exceptions/data/range-throw-type.php diff --git a/build/baseline-pre-8.0.neon b/build/baseline-pre-8.0.neon index 42c4ae6b539..5059e54015b 100644 --- a/build/baseline-pre-8.0.neon +++ b/build/baseline-pre-8.0.neon @@ -157,3 +157,15 @@ parameters: identifier: property.internalClass count: 3 path: ../src/Parser/RichParser.php + + - + rawMessage: 'Dead catch - ValueError is never thrown in the try block.' + identifier: catch.neverThrown + count: 1 + path: ../src/Type/Php/RangeFunctionArgumentsHelper.php + + - + rawMessage: 'Method PHPStan\Type\Php\RangeFunctionArgumentsHelper::callRange() never returns false so it can be removed from the return type.' + identifier: return.unusedType + count: 1 + path: ../src/Type/Php/RangeFunctionArgumentsHelper.php diff --git a/src/Type/Php/RangeFunctionReturnTypeExtension.php b/src/Type/Php/RangeFunctionReturnTypeExtension.php index 0df7f55f616..2b937aa7746 100644 --- a/src/Type/Php/RangeFunctionReturnTypeExtension.php +++ b/src/Type/Php/RangeFunctionReturnTypeExtension.php @@ -152,7 +152,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, * only calling it tells - for a character range, which has at most 256 * items, and for a zero, infinite or NAN argument, for which it throws. */ - private static function getRangeLength(int|float|string $start, int|float|string $end, int|float $step): ?float + public static function getRangeLength(int|float|string $start, int|float|string $end, int|float $step): ?float { if (is_string($start) && is_string($end) && !is_numeric($start) && !is_numeric($end)) { return null; diff --git a/src/Type/Php/RangeFunctionThrowTypeExtension.php b/src/Type/Php/RangeFunctionThrowTypeExtension.php new file mode 100644 index 00000000000..2c003721f5c --- /dev/null +++ b/src/Type/Php/RangeFunctionThrowTypeExtension.php @@ -0,0 +1,118 @@ +getName() === 'range'; + } + + public function getThrowTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $funcCall, Scope $scope): ?Type + { + // PHP 7 reported these with a warning and a false return value + $phpVersions = $scope->getPhpVersion(); + if ($phpVersions->throwsValueErrorForInternalFunctions()->no()) { + return new VoidType(); + } + + $args = $funcCall->getArgs(); + foreach ($args as $arg) { + if ($arg->unpack || $arg->name !== null) { + return $functionReflection->getThrowType(); + } + } + + if (count($args) < 2) { + return $functionReflection->getThrowType(); + } + + $starts = self::getConstantValues($scope->getNativeType($args[0]->value), true); + $ends = self::getConstantValues($scope->getNativeType($args[1]->value), true); + $steps = count($args) >= 3 ? self::getConstantValues($scope->getNativeType($args[2]->value), false) : [1]; + if ($starts === null || $ends === null || $steps === null) { + return $functionReflection->getThrowType(); + } + + $hasStricterRange = $phpVersions->hasStricterRangeFunction(); + foreach ($starts as $start) { + foreach ($ends as $end) { + foreach ($steps as $step) { + if (self::mightThrow($hasStricterRange, $start, $end, $step)) { + return $functionReflection->getThrowType(); + } + } + } + } + + return new VoidType(); + } + + /** + * @return ($allowString is true ? list : list)|null + */ + private static function getConstantValues(Type $type, bool $allowString): ?array + { + if (!$type->isConstantScalarValue()->yes()) { + return null; + } + + $values = []; + foreach ($type->getConstantScalarValues() as $value) { + if (!is_int($value) && !is_float($value) && (!$allowString || !is_string($value))) { + return null; + } + + $values[] = $value; + } + + return $values; + } + + private static function mightThrow(TrinaryLogic $hasStricterRange, int|float|string $start, int|float|string $end, int|float $step): bool + { + $length = RangeFunctionReturnTypeExtension::getRangeLength($start, $end, $step); + if ($length === null) { + // only a character range has no number of items, any other one did not fit into a float + if (!is_string($start) || !is_string($end) || is_numeric($start) || is_numeric($end)) { + return true; + } + } elseif ($length >= self::RANGE_SIZE_LIMIT) { + return true; + } + + // calling range() tells about the step and the boundaries of a short range + $runtimeRejects = null; + if ($length === null || $length <= ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { + $runtimeRejects = RangeFunctionArgumentsHelper::callRange($start, $end, $step) === false; + } + + return !RangeFunctionArgumentsHelper::rejects($hasStricterRange, $start, $end, $step, $runtimeRejects)->no(); + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php index 43633d859c5..553008f42d0 100644 --- a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php @@ -7,6 +7,7 @@ use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\RequiresPhp; +use const PHP_VERSION_ID; /** * @extends RuleTestCase @@ -1500,4 +1501,113 @@ public function testUnserializeThrowTypeBeforePhp8(): void ]); } + #[RequiresPhp('>= 8.0.0')] + public function testRangeThrowType(): void + { + $errors = [ + [ + 'Dead catch - ValueError is never thrown in the try block.', + 14, + ], + [ + 'Dead catch - ValueError is never thrown in the try block.', + 23, + ], + [ + 'Dead catch - ValueError is never thrown in the try block.', + 29, + ], + [ + 'Dead catch - ValueError is never thrown in the try block.', + 35, + ], + ]; + + // the helper decides a character range by the PHP 8.3 rules only when it runs on PHP 8.3 or newer + if (PHP_VERSION_ID >= 80300) { + $errors[] = [ + 'Dead catch - ValueError is never thrown in the try block.', + 60, + ]; + } + + $errors[] = [ + 'Dead catch - ValueError is never thrown in the try block.', + 88, + ]; + + $this->analyse([__DIR__ . '/data/range-throw-type.php'], $errors); + } + + #[RequiresPhp('< 8.0.0')] + public function testRangeThrowTypeBeforePhp8(): void + { + $this->analyse([__DIR__ . '/data/range-throw-type.php'], [ + [ + 'Dead catch - ValueError is never thrown in the try block.', + 14, + ], + [ + 'Dead catch - ValueError is never thrown in the try block.', + 23, + ], + [ + 'Dead catch - ValueError is never thrown in the try block.', + 29, + ], + [ + 'Dead catch - ValueError is never thrown in the try block.', + 35, + ], + [ + 'Dead catch - ValueError is never thrown in the try block.', + 41, + ], + [ + 'Dead catch - ValueError is never thrown in the try block.', + 47, + ], + [ + 'Dead catch - ValueError is never thrown in the try block.', + 53, + ], + [ + 'Dead catch - ValueError is never thrown in the try block.', + 60, + ], + [ + 'Dead catch - ValueError is never thrown in the try block.', + 67, + ], + [ + 'Dead catch - ValueError is never thrown in the try block.', + 74, + ], + [ + 'Dead catch - ValueError is never thrown in the try block.', + 81, + ], + [ + 'Dead catch - ValueError is never thrown in the try block.', + 88, + ], + [ + 'Dead catch - ValueError is never thrown in the try block.', + 95, + ], + [ + 'Dead catch - ValueError is never thrown in the try block.', + 102, + ], + [ + 'Dead catch - ValueError is never thrown in the try block.', + 109, + ], + [ + 'Dead catch - ValueError is never thrown in the try block.', + 115, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Exceptions/data/range-throw-type.php b/tests/PHPStan/Rules/Exceptions/data/range-throw-type.php new file mode 100644 index 00000000000..e5e7b611fe6 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/range-throw-type.php @@ -0,0 +1,121 @@ += 80300) { + try { + $a = range('a', 'z'); + } catch (\ValueError $e) { + + } + + // PHP 8.3 rejects a negative step on an increasing range + try { + $a = range(1, 1000, -1); + } catch (\ValueError $e) { + + } + + // PHP 8.3 builds a character range, in which 2 exceeds the range from '9' to ':' + try { + $a = range('9', ':', 2); + } catch (\ValueError $e) { + + } + + // PHP 8.3 compares an integral float step exactly, and 2 ** 60 exceeds the range up to 2 ** 60 - 1 + try { + $a = range(0, 1152921504606846975, 1152921504606846976.0); + } catch (\ValueError $e) { + + } + } else { + // the sign of the step used to be ignored + try { + $a = range(1, 1000, -1); + } catch (\ValueError $e) { + + } + + // '1' used to be a number next to a non-numeric string, so the step exceeds the range 1..0 + try { + $a = range('1', 'a', 30); + } catch (\ValueError $e) { + + } + + // integers are compared exactly, and 2 ** 60 exceeds the range up to 2 ** 60 - 1 + try { + $a = range(0, 1152921504606846975, 1152921504606846976); + } catch (\ValueError $e) { + + } + + // the number of items does not fit into a float, which exceeds every array size + try { + $a = range(-1.0e308, 1.0e308); + } catch (\ValueError $e) { + + } + + try { + $a = range(0, 1, 1.0e-320); + } catch (\ValueError $e) { + + } + } + } + +}