From b5ba47c95b4b0666e9ff6e62ccc20e689d785f25 Mon Sep 17 00:00:00 2001 From: alwaysLinger <43213269+alwaysLinger@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:48:55 +0800 Subject: [PATCH] fix(compiler): treat numeric string literals as numeric operands so their arithmetic matches PHP semantics across all modes --- .../code/float-numeric-string-arithmetic.php | 16 ++++++ phpunit/src/CompilerBaseApiTest.php | 2 +- .../src/FloatNumericStringArithmeticTest.php | 33 +++++++++++ src/CompilerBase.php | 26 ++++++++- src/Parser/BinaryOpTrait.php | 10 ++++ src/Parser/TypeDetectionTrait.php | 44 +++++++++++++++ .../float-numeric-string-arithmetic.phpt | 55 +++++++++++++++++++ .../operator/numeric-string-varint-types.phpt | 24 ++++++++ 8 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 phpunit/code/float-numeric-string-arithmetic.php create mode 100644 phpunit/src/FloatNumericStringArithmeticTest.php create mode 100644 tests/compiler/operator/float-numeric-string-arithmetic.phpt create mode 100644 tests/compiler/operator/numeric-string-varint-types.phpt diff --git a/phpunit/code/float-numeric-string-arithmetic.php b/phpunit/code/float-numeric-string-arithmetic.php new file mode 100644 index 00000000..2b5bdac8 --- /dev/null +++ b/phpunit/code/float-numeric-string-arithmetic.php @@ -0,0 +1,16 @@ +assertSame( - (string) floatval('0.2'), + '2e-1', $this->invokeMethod('parseNumericIdentifier', new \PhpParser\Node\Scalar\String_('0.2')) ); $this->assertSame( diff --git a/phpunit/src/FloatNumericStringArithmeticTest.php b/phpunit/src/FloatNumericStringArithmeticTest.php new file mode 100644 index 00000000..c4545668 --- /dev/null +++ b/phpunit/src/FloatNumericStringArithmeticTest.php @@ -0,0 +1,33 @@ +addFiles([$source]); + $compiler->prepareFile($source); + $code = file_get_contents($compiler->convertFile($source)); + self::assertIsString($code); + + // "1e2" must convert to a C++ double literal, not an integer literal, + // so the division stays in the floating-point domain. + self::assertStringContainsString('((1e+2) / (4L))', $code); + self::assertDoesNotMatchRegularExpression('/\(\(100\) \/ \(4L\)\)/', $code); + + // The inferred storage of an assignment from float-valued numeric + // string arithmetic is php::Float, not php::Int. + self::assertMatchesRegularExpression('/php::Float x = /', $code); + self::assertMatchesRegularExpression('/php::Float y = /', $code); + self::assertMatchesRegularExpression('/php::Float php_floatnumericstringquotient\(\)/', $code); + + // Integer-syntax numeric strings keep native integer arithmetic. + self::assertStringContainsString('((10) / (2L))', $code); + } +} diff --git a/src/CompilerBase.php b/src/CompilerBase.php index db84d361..2a6685d2 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -2427,12 +2427,16 @@ protected function parseEcho(mixed $v): string protected function parseNumericIdentifier(NodeAbstract $expr): string { if ($expr instanceof Node\Scalar\String_) { - if ($this->isFloatStr($expr->value)) { - return (string) floatval($expr->value); + $floatValue = floatval($expr->value); + if ($this->isFloatNumericString($expr->value) && is_finite($floatValue)) { + return $this->shortestFloatRepr($floatValue); } if ($this->isIntStr($expr->value)) { return (string) intval($expr->value); } + if ($this->isFloatStr($expr->value)) { + return (string) floatval($expr->value); + } if ($expr->value === '0') { return '0'; } @@ -3542,7 +3546,23 @@ protected function detectTypeOfExpr($expr): string if ($leftType === Type::FLOAT || $rightType === Type::FLOAT) { return Type::FLOAT; } - if ($this->varIntTypes && $leftType === Type::INT && $rightType === Type::INT) { + if (in_array($exprType, [ + 'Expr_BinaryOp_Plus', + 'Expr_BinaryOp_Minus', + 'Expr_BinaryOp_Mul', + 'Expr_BinaryOp_Div', + 'Expr_BinaryOp_Pow', + ], true) + && (($leftType === Type::STR && $this->isFloatNumericStringExpr($expr->left)) + || ($rightType === Type::STR && $this->isFloatNumericStringExpr($expr->right))) + ) { + return Type::FLOAT; + } + $leftIntLike = $leftType === Type::INT + || ($leftType === Type::STR && $this->isIntNumericStringExpr($expr->left)); + $rightIntLike = $rightType === Type::INT + || ($rightType === Type::STR && $this->isIntNumericStringExpr($expr->right)); + if ($this->varIntTypes && $leftIntLike && $rightIntLike) { $op = match ($exprType) { 'Expr_BinaryOp_Plus' => '+', 'Expr_BinaryOp_Minus' => '-', diff --git a/src/Parser/BinaryOpTrait.php b/src/Parser/BinaryOpTrait.php index fed76ccb..55c6fc7c 100644 --- a/src/Parser/BinaryOpTrait.php +++ b/src/Parser/BinaryOpTrait.php @@ -463,6 +463,16 @@ protected function constantNumericValue(NodeAbstract $expr, bool $nativeSemantic if ($expr instanceof Node\Scalar\Float_) { return $expr->value; } + if ($expr instanceof Node\Scalar\String_) { + if ($this->isIntNumericString($expr->value)) { + return intval($expr->value); + } + if ($this->isFloatNumericString($expr->value)) { + $floatValue = floatval($expr->value); + return is_finite($floatValue) ? $floatValue : null; + } + return null; + } if ($expr instanceof Node\Expr\UnaryPlus) { return $this->constantNumericValue($expr->expr, $nativeSemantics); } diff --git a/src/Parser/TypeDetectionTrait.php b/src/Parser/TypeDetectionTrait.php index 2fb36078..179a3f55 100644 --- a/src/Parser/TypeDetectionTrait.php +++ b/src/Parser/TypeDetectionTrait.php @@ -151,6 +151,50 @@ protected function isFloatStr(string $str): bool return filter_var($str, FILTER_VALIDATE_FLOAT) !== false; } + /** + * Whether the value is an integer-syntax numeric string (one that PHP + * juggles as an int in arithmetic): optionally signed decimal digits + * with optional surrounding whitespace, within the int64 range. + * Matches "5", " 10 ", but not "1.5", "1e2", or "9223372036854775808" + * (out of int64 range — PHP promotes those to float). + */ + protected function isIntNumericString(string $value): bool + { + if (!is_numeric($value) + || !preg_match('/^[\t\n\r\v\f ]*([+-]?)([0-9]+)[\t\n\r\v\f ]*$/', $value, $m)) { + return false; + } + $digits = ltrim($m[2], '0'); + if ($digits === '') { + return true; + } + $len = strlen($digits); + if ($len < 19) { + return true; + } + if ($len > 19) { + return false; + } + // Exactly 19 digits: must fit in int64. PHP_INT_MAX is + // 9223372036854775807; negative values may reach |PHP_INT_MIN|. + return strcmp($digits, $m[1] === '-' ? '9223372036854775808' : '9223372036854775807') <= 0; + } + + protected function isFloatNumericString(string $value): bool + { + return is_numeric($value) && !$this->isIntNumericString($value); + } + + protected function isFloatNumericStringExpr(NodeAbstract $expr): bool + { + return $expr instanceof Node\Scalar\String_ && $this->isFloatNumericString($expr->value); + } + + protected function isIntNumericStringExpr(NodeAbstract $expr): bool + { + return $expr instanceof Node\Scalar\String_ && $this->isIntNumericString($expr->value); + } + protected function isIntStr(string $str): bool { return filter_var($str, FILTER_VALIDATE_INT) !== false; diff --git a/tests/compiler/operator/float-numeric-string-arithmetic.phpt b/tests/compiler/operator/float-numeric-string-arithmetic.phpt new file mode 100644 index 00000000..209972c6 --- /dev/null +++ b/tests/compiler/operator/float-numeric-string-arithmetic.phpt @@ -0,0 +1,55 @@ +--TEST-- +Float-valued numeric string arithmetic keeps PHP float semantics +--FILE-- + PHP_INT_MAX); + var_dump("2.5" ** "2"); + var_dump("9223372036854775807" ** 2); + var_dump("1e300" * "1e300"); + var_dump("1.5" < 2); +} +?> +--EXPECT-- +float(25) +float(100) +float(40) +float(400) +float(-20) +float(0.5) +float(1.5) +float(25) +float(3) +float(25) +int(6) +int(5) +float(INF) +bool(true) +int(9223372036854775807) +bool(true) +bool(true) +float(6.25) +float(8.507059173023462E+37) +float(INF) +bool(true) diff --git a/tests/compiler/operator/numeric-string-varint-types.phpt b/tests/compiler/operator/numeric-string-varint-types.phpt new file mode 100644 index 00000000..a6b67a48 --- /dev/null +++ b/tests/compiler/operator/numeric-string-varint-types.phpt @@ -0,0 +1,24 @@ +--TEST-- +Integer-syntax numeric strings follow varint_types PHP semantics +--FILE-- + +--EXPECT-- +float(1.8446744073709552E+19) +int(9223372036854775807) +float(2.5) +int(5) +float(2.5) +DivisionByZeroError