diff --git a/src/Type/IntegerRangeType.php b/src/Type/IntegerRangeType.php index 4b9fb037c3d..b41da7851bb 100644 --- a/src/Type/IntegerRangeType.php +++ b/src/Type/IntegerRangeType.php @@ -33,6 +33,9 @@ class IntegerRangeType extends IntegerType implements CompoundType { + /** The first float above PHP_INT_MAX: 2^63 on 64-bit builds, 2^31 on 32-bit ones, exact on both. */ + private const FLOAT_ABOVE_INT_MAX = PHP_INT_MAX + 1.0; + private function __construct(private ?int $min, private ?int $max) { parent::__construct(); @@ -90,7 +93,11 @@ public static function createAllSmallerThan($value): Type return self::fromInterval(null, $value, -1); } - if ($value > PHP_INT_MAX) { + // decided on the ceil() about to be cast: comparing $value with PHP_INT_MAX + // converts PHP_INT_MAX to float, which rounds it up to 2^63 on 64-bit builds + // but keeps it exact on 32-bit ones, where 2147483647.0 is still in range + $ceil = ceil($value); + if ($ceil >= self::FLOAT_ABOVE_INT_MAX) { return new IntegerType(); } @@ -98,7 +105,7 @@ public static function createAllSmallerThan($value): Type return new NeverType(); } - return self::fromInterval(null, (int) ceil($value), -1); + return self::fromInterval(null, (int) $ceil, -1); } /** @@ -160,11 +167,13 @@ public static function createAllGreaterThanOrEqualTo($value): Type return new IntegerType(); } - if ($value > PHP_INT_MAX) { + // decided on the ceil() about to be cast, like createAllSmallerThan() + $ceil = ceil($value); + if ($ceil >= self::FLOAT_ABOVE_INT_MAX) { return new NeverType(); } - return self::fromInterval((int) ceil($value), null); + return self::fromInterval((int) $ceil, null); } public function getMin(): ?int diff --git a/tests/PHPStan/Analyser/nsrt/integer-range-float-two-to-the-63.php b/tests/PHPStan/Analyser/nsrt/integer-range-float-two-to-the-63.php new file mode 100644 index 00000000000..b914af2df34 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/integer-range-float-two-to-the-63.php @@ -0,0 +1,43 @@ += 9.2233720368547758E18) { + assertType('*NEVER*', $i); + } else { + assertType('int', $i); + } + assertType('int', $i); +} + +function smallerOrEqual(int $i): void +{ + if ($i <= 9.2233720368547758E18) { + assertType('int', $i); + } else { + assertType('*NEVER*', $i); + } +} + +function greater(int $i): void +{ + if ($i > 9.2233720368547758E18) { + assertType('*NEVER*', $i); + } else { + assertType('int', $i); + } +} diff --git a/tests/PHPStan/Type/IntegerRangeTypeTest.php b/tests/PHPStan/Type/IntegerRangeTypeTest.php new file mode 100644 index 00000000000..e00d2c5224f --- /dev/null +++ b/tests/PHPStan/Type/IntegerRangeTypeTest.php @@ -0,0 +1,136 @@ + [ + 9.2233720368547758E18, + 'int', + 'int', + '*NEVER*', + '*NEVER*', + ]; + + // the largest float smaller than 2**63 + yield '2**63 - 1024' => [ + 9.2233720368547748E18, + 'int', + 'int', + 'int<9223372036854774785, max>', + 'int<9223372036854774784, max>', + ]; + + // -2**63 is exactly PHP_INT_MIN + yield '-2**63' => [ + -9.2233720368547758E18, + '*NEVER*', + '-9223372036854775808', + 'int<-9223372036854775807, max>', + 'int', + ]; + + // the largest float smaller than -2**63 + yield '-2**63 - 2048' => [ + -9.2233720368547779E18, + '*NEVER*', + '*NEVER*', + 'int', + 'int', + ]; + } + + #[DataProvider('dataCreateFromFloat')] + public function testCreateFromFloat( + float $value, + string $expectedSmallerThan, + string $expectedSmallerThanOrEqualTo, + string $expectedGreaterThan, + string $expectedGreaterThanOrEqualTo, + ): void + { + if (PHP_INT_SIZE !== 8) { + $this->markTestSkipped('The bounds are those of a 64-bit int.'); + } + + $this->assertSame([ + 'createAllSmallerThan' => $expectedSmallerThan, + 'createAllSmallerThanOrEqualTo' => $expectedSmallerThanOrEqualTo, + 'createAllGreaterThan' => $expectedGreaterThan, + 'createAllGreaterThanOrEqualTo' => $expectedGreaterThanOrEqualTo, + ], [ + 'createAllSmallerThan' => IntegerRangeType::createAllSmallerThan($value)->describe(VerbosityLevel::precise()), + 'createAllSmallerThanOrEqualTo' => IntegerRangeType::createAllSmallerThanOrEqualTo($value)->describe(VerbosityLevel::precise()), + 'createAllGreaterThan' => IntegerRangeType::createAllGreaterThan($value)->describe(VerbosityLevel::precise()), + 'createAllGreaterThanOrEqualTo' => IntegerRangeType::createAllGreaterThanOrEqualTo($value)->describe(VerbosityLevel::precise()), + ]); + } + + public static function dataFloatBounds(): iterable + { + $smallerThan = static fn (float $value): Type => IntegerRangeType::createAllSmallerThan($value); + $smallerThanOrEqualTo = static fn (float $value): Type => IntegerRangeType::createAllSmallerThanOrEqualTo($value); + $greaterThan = static fn (float $value): Type => IntegerRangeType::createAllGreaterThan($value); + $greaterThanOrEqualTo = static fn (float $value): Type => IntegerRangeType::createAllGreaterThanOrEqualTo($value); + + // PHP_INT_MAX + 1.0 is the first float above PHP_INT_MAX on any int width, and + // (float) PHP_INT_MIN is exact on any int width + $aboveMax = PHP_INT_MAX + 1.0; + yield [$smallerThan, $aboveMax, 'int']; + yield [$smallerThanOrEqualTo, $aboveMax, 'int']; + yield [$greaterThan, $aboveMax, '*NEVER*']; + yield [$greaterThanOrEqualTo, $aboveMax, '*NEVER*']; + yield [$smallerThan, (float) PHP_INT_MIN, '*NEVER*']; + yield [$smallerThanOrEqualTo, (float) PHP_INT_MIN, (string) PHP_INT_MIN]; + yield [$greaterThan, (float) PHP_INT_MIN, 'int<' . (PHP_INT_MIN + 1) . ', max>']; + yield [$greaterThanOrEqualTo, (float) PHP_INT_MIN, 'int']; + + // (float) PHP_INT_MAX is 2^63 on 64-bit builds (above every int) and exact on 32-bit + // ones; so is PHP_INT_MAX - 0.5, which is PHP_INT_MAX - 1 + 0.5 on 32-bit builds + $maxIsExact = PHP_INT_SIZE < 8; + yield [$smallerThan, (float) PHP_INT_MAX, $maxIsExact ? 'int' : 'int']; + yield [$smallerThanOrEqualTo, (float) PHP_INT_MAX, 'int']; + yield [$greaterThan, (float) PHP_INT_MAX, '*NEVER*']; + yield [$greaterThanOrEqualTo, (float) PHP_INT_MAX, $maxIsExact ? (string) PHP_INT_MAX : '*NEVER*']; + yield [$smallerThan, PHP_INT_MAX - 0.5, $maxIsExact ? 'int' : 'int']; + yield [$greaterThanOrEqualTo, PHP_INT_MAX - 0.5, $maxIsExact ? (string) PHP_INT_MAX : '*NEVER*']; + + // between PHP_INT_MAX and the next int on 32-bit builds, 2^63 on 64-bit ones: its + // ceil() is past the int range either way + yield [$smallerThan, PHP_INT_MAX + 0.5, 'int']; + yield [$greaterThanOrEqualTo, PHP_INT_MAX + 0.5, '*NEVER*']; + + yield [$smallerThan, 2.5, 'int']; + yield [$greaterThan, 2.5, 'int<3, max>']; + } + + /** + * @param callable(float): Type $factory + */ + #[DataProvider('dataFloatBounds')] + public function testFloatBounds(callable $factory, float $value, string $expected): void + { + set_error_handler(static function (int $errno, string $errstr): bool { + throw new RuntimeException($errstr); + }); + try { + $this->assertSame($expected, $factory($value)->describe(VerbosityLevel::precise())); + } finally { + restore_error_handler(); + } + } + +}