diff --git a/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index 2b54632598c..f71a3ee0fdb 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '63cd54c'; + public const EXPECTED_EXTENSION_VERSION = '22b361a'; private static bool $active = false; diff --git a/src/Type/IntegerRangeType.php b/src/Type/IntegerRangeType.php index 4dd6249a6c1..518d5d00993 100644 --- a/src/Type/IntegerRangeType.php +++ b/src/Type/IntegerRangeType.php @@ -35,6 +35,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(); @@ -92,9 +95,11 @@ public static function createAllSmallerThan($value): Type return self::fromInterval(null, $value, -1); } - // a float never holds PHP_INT_MAX itself, so the first float past the - // int range is (float) PHP_INT_MAX: every int is smaller than that - 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(); } @@ -102,7 +107,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); } /** @@ -164,13 +169,13 @@ public static function createAllGreaterThanOrEqualTo($value): Type return new IntegerType(); } - // (float) PHP_INT_MAX is already past the int range, so no int - // reaches it - 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/Type/IntegerRangeTypeTest.php b/tests/PHPStan/Type/IntegerRangeTypeTest.php index 099d945b843..02bc556a3f7 100644 --- a/tests/PHPStan/Type/IntegerRangeTypeTest.php +++ b/tests/PHPStan/Type/IntegerRangeTypeTest.php @@ -3,21 +3,69 @@ namespace PHPStan\Type; use PHPStan\Testing\PHPStanTestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use RuntimeException; +use function restore_error_handler; +use function set_error_handler; use const PHP_INT_MAX; +use const PHP_INT_MIN; +use const PHP_INT_SIZE; class IntegerRangeTypeTest extends PHPStanTestCase { - public function testCreateFromFloatPastTheIntRange(): void + public static function dataFloatBounds(): iterable { - // PHP_INT_MAX is not representable as a float: (float) PHP_INT_MAX is - // the first float past the int range - $pastMax = (float) PHP_INT_MAX; - - $this->assertSame('int', IntegerRangeType::createAllSmallerThan($pastMax)->describe(VerbosityLevel::precise())); - $this->assertSame('int', IntegerRangeType::createAllSmallerThanOrEqualTo($pastMax)->describe(VerbosityLevel::precise())); - $this->assertSame('*NEVER*', IntegerRangeType::createAllGreaterThan($pastMax)->describe(VerbosityLevel::precise())); - $this->assertSame('*NEVER*', IntegerRangeType::createAllGreaterThanOrEqualTo($pastMax)->describe(VerbosityLevel::precise())); + $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(); + } } } diff --git a/turbo-ext/src/IntegerRangeType.cpp b/turbo-ext/src/IntegerRangeType.cpp index 1afa62f7565..5bd8042413e 100644 --- a/turbo-ext/src/IntegerRangeType.cpp +++ b/turbo-ext/src/IntegerRangeType.cpp @@ -27,6 +27,10 @@ namespace sigs = ptdecl::IntegerRangeType::sig; zend_class_entry *pt_ce_integer_range_type = nullptr; +/* the twin's private const FLOAT_ABOVE_INT_MAX = PHP_INT_MAX + 1.0: the first + * double above ZEND_LONG_MAX, exact on 64-bit (2^63) and 32-bit (2^31) builds */ +static constexpr double PT_IRT_FLOAT_ABOVE_INT_MAX = (double) ZEND_LONG_MAX + 1.0; + /* nothing is memoized per request since InitializerExprTypeResolver's * CALCULATE_SCALARS_LIMIT became a native constant */ void pt_integer_range_type_rinit() @@ -171,26 +175,26 @@ class IntegerRangeType /* the createAll*() family: the range of integers beyond an int|float * $value. An int goes straight to fromInterval(); anything else is - * first held against the int limits — a float the way the VM compares - * int with float (both as doubles), any other value loosely — and what - * passes is then rounded by ceil()/floor() and (int)-cast, which is - * where a non-number fails under strict_types. */ + * held against the int limits — a float the way the VM compares int + * with float (both as doubles), any other value loosely — and rounded + * by ceil()/floor() and (int)-cast, which is where a non-number fails + * under strict_types. Where the ceil() is held against the max instead + * of $value (createAllSmallerThan(), createAllGreaterThanOrEqualTo()), + * it is taken first, in the twin's order. */ /* fromInterval(null, $value, -1) */ static zv::Val createAllSmallerThan(zval *value) { if (Z_TYPE_P(value) == IS_LONG) return fromInterval(NullableLong::null(), NullableLong::of(Z_LVAL_P(value)), -1); - int againstMax, againstMin; - if (UNEXPECTED(!compareToLimits(value, againstMax, againstMin))) return zv::Val(); - if (againstMax >= 0) { /* $value >= PHP_INT_MAX */ + double ceiled; + if (UNEXPECTED(!roundToDouble(value, true, ceiled))) return zv::Val(); + if (ceiled >= PT_IRT_FLOAT_ABOVE_INT_MAX) { /* $ceil >= self::FLOAT_ABOVE_INT_MAX */ return integer(); } - if (againstMin <= 0) { /* $value <= PHP_INT_MIN */ + if (Z_DVAL_P(value) <= (double) ZEND_LONG_MIN) { /* $value <= PHP_INT_MIN */ return never(); } - zend_long rounded; - if (UNEXPECTED(!roundToLong(value, true, rounded))) return zv::Val(); - return fromInterval(NullableLong::null(), NullableLong::of(rounded), -1); + return fromInterval(NullableLong::null(), NullableLong::of(zend_dval_to_lval(ceiled)), -1); } /* fromInterval(null, $value) */ @@ -231,17 +235,17 @@ class IntegerRangeType static zv::Val createAllGreaterThanOrEqualTo(zval *value) { if (Z_TYPE_P(value) == IS_LONG) return fromInterval(NullableLong::of(Z_LVAL_P(value)), NullableLong::null(), 0); - int againstMax, againstMin; - if (UNEXPECTED(!compareToLimits(value, againstMax, againstMin))) return zv::Val(); + int againstMin; + if (UNEXPECTED(!compareToMin(value, againstMin))) return zv::Val(); if (againstMin <= 0) { /* $value <= PHP_INT_MIN */ return integer(); } - if (againstMax >= 0) { /* $value >= PHP_INT_MAX */ + double ceiled; + if (UNEXPECTED(!roundToDouble(value, true, ceiled))) return zv::Val(); + if (ceiled >= PT_IRT_FLOAT_ABOVE_INT_MAX) { /* $ceil >= self::FLOAT_ABOVE_INT_MAX */ return never(); } - zend_long rounded; - if (UNEXPECTED(!roundToLong(value, true, rounded))) return zv::Val(); - return fromInterval(NullableLong::of(rounded), NullableLong::null(), 0); + return fromInterval(NullableLong::of(zend_dval_to_lval(ceiled)), NullableLong::null(), 0); } /* $this->min / $this->max; false with an Error pending when the @@ -1127,34 +1131,58 @@ class IntegerRangeType * every one of the four tests false); anything else compares loosely. * false = pending exception */ [[nodiscard]] static bool compareToLimits(zval *value, int &againstMax, int &againstMin) + { + return compareToMax(value, againstMax) && compareToMin(value, againstMin); + } + + [[nodiscard]] static bool compareToMax(zval *value, int &againstMax) { if (EXPECTED(Z_TYPE_P(value) == IS_DOUBLE)) { double d = Z_DVAL_P(value); double max = (double) ZEND_LONG_MAX; - double min = (double) ZEND_LONG_MIN; againstMax = d > max ? 1 : (d < max ? -1 : (d == max ? 0 : -1)); - againstMin = d < min ? -1 : (d > min ? 1 : (d == min ? 0 : 1)); return true; } zval limit; ZVAL_LONG(&limit, ZEND_LONG_MAX); againstMax = zend_compare(value, &limit); - if (UNEXPECTED(EG(exception))) return false; + return !EG(exception); + } + + [[nodiscard]] static bool compareToMin(zval *value, int &againstMin) + { + if (EXPECTED(Z_TYPE_P(value) == IS_DOUBLE)) { + double d = Z_DVAL_P(value); + double min = (double) ZEND_LONG_MIN; + againstMin = d < min ? -1 : (d > min ? 1 : (d == min ? 0 : 1)); + return true; + } + zval limit; ZVAL_LONG(&limit, ZEND_LONG_MIN); againstMin = zend_compare(value, &limit); return !EG(exception); } - /* (int) ceil($value) / (int) floor($value) for a non-int $value: a - * float rounds and casts as the engine casts; anything else is what - * ceil()/floor() reject under strict_types. false = pending exception */ - [[nodiscard]] static bool roundToLong(zval *value, bool useCeil, zend_long &out) + /* ceil($value) / floor($value) for a non-int $value: a float rounds; + * anything else is what ceil()/floor() reject under strict_types. + * false = pending exception */ + [[nodiscard]] static bool roundToDouble(zval *value, bool useCeil, double &out) { if (UNEXPECTED(Z_TYPE_P(value) != IS_DOUBLE)) { zend_type_error("%s(): Argument #1 ($num) must be of type int|float, %s given", useCeil ? "ceil" : "floor", zend_zval_value_name(value)); return false; } - out = zend_dval_to_lval(useCeil ? ceil(Z_DVAL_P(value)) : floor(Z_DVAL_P(value))); + out = useCeil ? ceil(Z_DVAL_P(value)) : floor(Z_DVAL_P(value)); + return true; + } + + /* (int) ceil($value) / (int) floor($value) for a non-int $value, cast as + * the engine casts. false = pending exception */ + [[nodiscard]] static bool roundToLong(zval *value, bool useCeil, zend_long &out) + { + double rounded; + if (UNEXPECTED(!roundToDouble(value, useCeil, rounded))) return false; + out = zend_dval_to_lval(rounded); return true; } @@ -1334,6 +1362,11 @@ static void pt_irt_bound(INTERNAL_FUNCTION_PARAMETERS, bool (IntegerRangeType::* RETURN_LONG(bound.value); } +static void pt_irt_float_above_int_max_constant(zval *out) +{ + ZVAL_DOUBLE(out, PT_IRT_FLOAT_ABOVE_INT_MAX); +} + PT_MINIT_REGISTRATION(pt_register_integer_range_type) { reg::Class cls("PHPStan\\Type\\IntegerRangeType"); @@ -1341,6 +1374,7 @@ PT_MINIT_REGISTRATION(pt_register_integer_range_type) /* "min" and "max" must stay the first two declared properties * (slots::min, slots::max) */ ptdecl::IntegerRangeType::declareProperties(cls); + cls.privateClassConstantValue("FLOAT_ABOVE_INT_MAX", pt_irt_float_above_int_max_constant); cls.method(sigs::__construct, [](INTERNAL_FUNCTION_PARAMETERS) { zend_long min, max; diff --git a/turbo-ext/tests/type-family.php b/turbo-ext/tests/type-family.php index 2f205bef50d..c8e72566202 100644 --- a/turbo-ext/tests/type-family.php +++ b/turbo-ext/tests/type-family.php @@ -353,13 +353,13 @@ } } } - foreach ([0, 1, -1, 42, PHP_INT_MAX, PHP_INT_MIN, PHP_INT_MAX - 1, PHP_INT_MIN + 1, 0.0, 0.5, -0.5, 2.5, -2.5, 1e30, -1e30, 9.2233720368547758E+18, -9.2233720368547758E+18, 9.2e18, -9.2e18, (float) PHP_INT_MAX, (float) PHP_INT_MIN, NAN, INF, -INF] as $value) { + foreach ([0, 1, -1, 42, PHP_INT_MAX, PHP_INT_MIN, PHP_INT_MAX - 1, PHP_INT_MIN + 1, 0.0, 0.5, -0.5, 2.5, -2.5, 1e30, -1e30, 9.2233720368547758E+18, -9.2233720368547758E+18, 9.2e18, -9.2e18, (float) PHP_INT_MAX, (float) PHP_INT_MIN, PHP_INT_MAX + 1.0, 9223372036854774784.0, 2147483647.0, 2147483646.5, 2147483647.5, -2147483648.0, -2147483648.5, NAN, INF, -INF] as $value) { $key = is_float($value) ? var_export($value, true) : (string) $value; foreach (['createAllSmallerThan', 'createAllSmallerThanOrEqualTo', 'createAllGreaterThan', 'createAllGreaterThanOrEqualTo'] as $factory) { $r["$factory $key"] = @$view($rangeClass::$factory($value)); } } - foreach (['5', true, null] as $value) { + foreach (['5', '1e30', '-1e30', true, null] as $value) { foreach (['createAllSmallerThan', 'createAllGreaterThanOrEqualTo'] as $factory) { try { $r["$factory " . var_export($value, true)] = $view($rangeClass::$factory($value));