diff --git a/phpunit/code/decimal-literal-classification.php b/phpunit/code/decimal-literal-classification.php new file mode 100644 index 00000000..f2c7c0da --- /dev/null +++ b/phpunit/code/decimal-literal-classification.php @@ -0,0 +1,31 @@ +compileFixture(); + + // Exactly one literal (the 21-digit pi) is promoted... + self::assertSame(1, substr_count($code, 'php::toDecimal(')); + // ...and the borderline literals stay native floats, so every + // is_float() probe statically folds to true. + self::assertGreaterThanOrEqual(3, substr_count($code, 'php::toBool(true)')); + } + + public function testHexLiteralFoldsToExactDouble(): void + { + $code = $this->compileFixture(); + + self::assertStringContainsString('2.0988295480315429e+19', $code); + self::assertStringNotContainsString('0x123456789E1234567', $code); + } + + public function testDecimalLiteralDemotesAgainstFloatTypedExpression(): void + { + $code = $this->compileFixture(); + + // The comparison compiles (no "Cannot convert float expression to + // Decimal" fatal) and compares doubles like Zend. + self::assertStringContainsString('php::equals(f, 3.1415926535897931)', $code); + } + + private function compileFixture(): string + { + global $translator; + + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/decimal-literal-classification.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $generated = $compiler->convertFile($source); + $code = file_get_contents($generated); + + self::assertIsString($code); + return $code; + } +} diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 924b34f7..9d946f07 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -160,6 +160,7 @@ class CompilerBase implements PropertyAccessContext protected const string ATTR_STATEMENT_EXPRESSION = 'aotStatementExpression'; protected const string ATTR_MULTI_RETURN_IMPL = 'aotMultiReturnImpl'; protected const string ATTR_SCOPED_CALLBACK = 'aotScopedCallback'; + protected const string ATTR_FORCE_FLOAT_LITERAL = 'aotForceFloatLiteral'; /** * Keyword methods (to* builtins) with mandated return types. diff --git a/src/Parser/BinaryOpTrait.php b/src/Parser/BinaryOpTrait.php index 8ba4e9aa..af1a307a 100644 --- a/src/Parser/BinaryOpTrait.php +++ b/src/Parser/BinaryOpTrait.php @@ -24,6 +24,8 @@ protected function parseBinaryOp(NodeAbstract $left, NodeAbstract $right, string $this->assertExprCanBeUsedAsValue($left, 'binary operand'); $this->assertExprCanBeUsedAsValue($right, 'binary operand'); + $this->demoteAutoDecimalLiteralAgainstFloat($left, $right); + // Arithmetic logic: convert to a numeric type first when possible $leftExpr = $this->parseOrderedBinaryOperand($left); $rightExpr = $this->parseOrderedBinaryOperand($right); @@ -951,6 +953,7 @@ protected function parseBinaryOpIdentical(Expr\BinaryOp $expr): string if ($pythonOperator !== null) { return $pythonOperator; } + $this->demoteAutoDecimalLiteralAgainstFloat($expr->left, $expr->right); $left = $this->parseCompareExpr($expr->left); $right = $this->parseCompareExpr($expr->right); $leftIsNative = $this->isNativeObjectClass($this->detectClassOfExpr($expr->left)); @@ -1113,8 +1116,36 @@ protected function parseBinaryOpSpaceship(Expr\BinaryOp\Spaceship $expr): string ?? 'php::compare(' . $this->parseOrderedOperand($expr->left, false) . ', ' . $this->parseOrderedOperand($expr->right, false) . ')'; } + /** + * When an auto-Decimal-classified float literal meets a float-typed + * expression in a binary operation, demote the literal to its exact + * double. PHP evaluates every float literal as a double, so rejecting + * the mix ("Cannot convert float expression to Decimal") refuses valid + * PHP — e.g. `0.1 + 0.2 == 0.30000000000000004` from a var_export round + * trip — and keeping the Decimal would change comparison semantics. + */ + protected function demoteAutoDecimalLiteralAgainstFloat(NodeAbstract $left, NodeAbstract $right): void + { + if ($this->decimalTypes) { + return; + } + $leftType = $this->detectTypeOfExpr($left); + $rightType = $this->detectTypeOfExpr($right); + foreach ([[$left, $leftType, $rightType], [$right, $rightType, $leftType]] as [$node, $type, $otherType]) { + if ($type === Type::DECIMAL + && $otherType === Type::FLOAT + && $node instanceof Node\Scalar\Float_ + && $this->isDecimalLiteral($node) + ) { + $node->setAttribute(self::ATTR_FORCE_FLOAT_LITERAL, true); + } + } + } + protected function genBigNumericCmp(Expr\BinaryOp $expr, string $suffix = ''): ?string { + $this->demoteAutoDecimalLiteralAgainstFloat($expr->left, $expr->right); + $leftType = $this->detectTypeOfExpr($expr->left); $rightType = $this->detectTypeOfExpr($expr->right); diff --git a/src/Parser/TypeDetectionTrait.php b/src/Parser/TypeDetectionTrait.php index 0e3762ca..2fb36078 100644 --- a/src/Parser/TypeDetectionTrait.php +++ b/src/Parser/TypeDetectionTrait.php @@ -47,18 +47,103 @@ protected function isBigIntLiteral(Node\Scalar $expr): bool protected function isDecimalLiteral(Node\Scalar $expr): bool { + if ($expr->getAttribute(self::ATTR_FORCE_FLOAT_LITERAL, false)) { + return false; + } $rawValue = $expr->getAttribute('rawValue'); if ($rawValue === null) { return false; } $clean = $this->stripNumericUnderscores($rawValue); + // Hex/octal/binary notation folds to its exact numeric value in Zend + // (an overflowing hex literal becomes the exact double); only decimal + // notation participates in the Decimal promotion. A hex literal whose + // digits contain E would otherwise match the exponent test below. + if (preg_match('/^[+-]?0[xXbBoO]/', $clean)) { + return false; + } // Must have a decimal point or exponent (not a pure integer) if (!preg_match('/[\.eE]/', $clean)) { return false; } - // Count significant digits (exclude ., e, E, +, -) - $digits = preg_replace('/[^0-9]/', '', $clean); - return strlen(ltrim($digits, '0')) >= 16; + // Documented rule: 16 or more significant digits promote to Decimal. + // Exponent digits carry no precision, and neither do leading or + // trailing mantissa zeros (999999999999999.0 has 15). + if ($this->countSignificantMantissaDigits($clean) < 16) { + return false; + } + // The promotion exists for literals that exceed double precision. A + // literal the double reproduces exactly — every var_export/serialize + // round-trip, PHP_FLOAT_EPSILON, ... — has lost nothing and stays a + // native float. + return !$this->floatLiteralRoundTripsExactly($clean); + } + + /** + * Count the significant decimal digits of a numeric literal's mantissa: + * sign and exponent are ignored, leading zeros carry no precision, and + * trailing mantissa zeros do not require more precision than the double. + */ + protected function countSignificantMantissaDigits(string $literal): int + { + $mantissa = ltrim($literal, '+-'); + $mantissa = preg_split('/[eE]/', $mantissa)[0]; + $digits = str_replace('.', '', $mantissa); + $digits = trim($digits, '0'); + return strlen($digits); + } + + /** + * Whether the decimal literal denotes exactly the value of its double + * representation (i.e. converting to double loses nothing). + */ + protected function floatLiteralRoundTripsExactly(string $literal): bool + { + $value = (float) $literal; + if (!is_finite($value)) { + // The double overflowed; Decimal preserves the written value. + return false; + } + $shortest = $this->shortestFloatRepr($value); + return $this->normalizeDecimalLiteral($literal) === $this->normalizeDecimalLiteral($shortest); + } + + /** + * Shortest decimal representation that parses back to exactly $value, + * independent of the precision/serialize_precision ini settings. + */ + protected function shortestFloatRepr(float $value): string + { + for ($precision = 0; $precision <= 17; $precision++) { + $candidate = sprintf('%.' . $precision . 'e', $value); + if ((float) $candidate === $value) { + return $candidate; + } + } + return sprintf('%.17e', $value); + } + + /** + * Normalize a decimal literal to [sign, significant digits, exponent] so + * two spellings of the same real number compare equal. + * + * @return array{string, string, int}|null + */ + protected function normalizeDecimalLiteral(string $literal): ?array + { + if (!preg_match('/^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/', trim($literal), $m)) { + return null; + } + $sign = $m[1] === '-' ? '-' : '+'; + $fraction = $m[3] ?? ''; + $exponent = (int) ($m[4] ?? 0) - strlen($fraction); + $digits = ltrim($m[2] . $fraction, '0'); + $trimmed = rtrim($digits, '0'); + $exponent += strlen($digits) - strlen($trimmed); + if ($trimmed === '') { + return ['+', '', 0]; + } + return [$sign, $trimmed, $exponent]; } protected function isFloatStr(string $str): bool diff --git a/tests/compiler/float_edge/decimal-literal-classification.phpt b/tests/compiler/float_edge/decimal-literal-classification.phpt new file mode 100644 index 00000000..41ecb2e5 --- /dev/null +++ b/tests/compiler/float_edge/decimal-literal-classification.phpt @@ -0,0 +1,32 @@ +--TEST-- +Auto-Decimal literal classification: significant digits, hex, float mixing +--FILE-- + +--EXPECT-- +bool(true) +bool(true) +bool(true) +float(2.098829548031543E+19) +bool(true) +bool(true) +bool(false)