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/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/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) { + + } + } + } + +} 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(), + ); + } + +}