fix(parser): classify auto-Decimal literals by real mantissa precision (#50) --skip-tests

The >=16-significant-digit float-literal-to-php::Decimal promotion
(docs/en/HIGH_PRECISION_TYPES.md) counted every digit in the raw
literal with preg_replace('/[^0-9]/'), so exponent digits and trailing
zeros counted as significant: 1.23456789012345e300 (15 significant
digits) and 999999999999999.0 became Decimal, making
is_float(2.220446049250313E-16) compile to false. Hex literals whose
digits contain E (0x123456789E1234567) matched the [.eE] test and
became Decimal("0x..."), where Zend folds an overflowing hex literal
to its exact double.

Three fixes, keeping the documented feature:
- Count true mantissa significant digits (strip sign, exponent,
  leading and trailing zeros) and additionally require that the double
  cannot reproduce the literal exactly - a literal that round-trips
  (every var_export/serialize output, PHP_FLOAT_EPSILON) has lost
  nothing and stays float, while 3.14159265358979323846 still promotes.
- Exclude hex/octal/binary notation from the reclassification.
- When a Decimal-classified literal meets a float-typed expression in
  a binary op or comparison, demote the literal to its exact double
  instead of the "Cannot convert float expression to Decimal" fatal:
  PHP evaluates every float literal as a double, so
  0.1 + 0.2 == 0.30000000000000004 is valid PHP and must be true.
master
Alessio Giacobbe 24 hours ago committed by GitHub
parent d00c63a049
commit 407953c094
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 31
      phpunit/code/decimal-literal-classification.php
  2. 58
      phpunit/src/DecimalLiteralClassificationTest.php
  3. 1
      src/CompilerBase.php
  4. 31
      src/Parser/BinaryOpTrait.php
  5. 91
      src/Parser/TypeDetectionTrait.php
  6. 32
      tests/compiler/float_edge/decimal-literal-classification.phpt

@ -0,0 +1,31 @@
<?php
function fifteenSigDigitsWithExponent(): bool
{
return is_float(1.23456789012345e300);
}
function trailingZerosAreNotSignificant(): bool
{
return is_float(999999999999999.0);
}
function roundTripSixteenDigits(): bool
{
return is_float(2.220446049250313E-16);
}
function hexLiteralStaysNumeric(): float
{
return 0x123456789E1234567;
}
function autoDecimalKeepsPromotion()
{
return 3.14159265358979323846;
}
function decimalLiteralDemotesAgainstFloat(float $f): bool
{
return $f == 3.14159265358979323846;
}

@ -0,0 +1,58 @@
<?php
use TypePhp\CompilerTest;
/**
* The auto-Decimal promotion applies to decimal literals whose mantissa has
* 16+ significant digits AND whose value the double cannot reproduce
* exactly. Exponent digits, leading zeros and trailing mantissa zeros carry
* no precision; hex/octal/binary literals fold to their exact numeric value
* like Zend; and a Decimal-classified literal meeting a float-typed
* expression demotes to its exact double instead of failing to compile.
*/
final class DecimalLiteralClassificationTest extends \BaseTest
{
public function testOnlyGenuinePrecisionLossPromotesToDecimal(): void
{
$code = $this->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;
}
}

@ -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.

@ -24,6 +24,8 @@ trait BinaryOpTrait
$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);
@ -982,6 +984,7 @@ trait BinaryOpTrait
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));
@ -1144,8 +1147,36 @@ trait BinaryOpTrait
?? '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);

@ -47,18 +47,103 @@ trait TypeDetectionTrait
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

@ -0,0 +1,32 @@
--TEST--
Auto-Decimal literal classification: significant digits, hex, float mixing
--FILE--
<?php
declare(strict_types=1);
function main(): void
{
// 15 significant digits (exponent digits carry no precision): float.
var_dump(is_float(1.23456789012345e300));
// Trailing mantissa zeros carry no precision: float.
var_dump(is_float(999999999999999.0));
// 16 digits, but the double reproduces the value exactly: float.
var_dump(is_float(2.220446049250313E-16));
// Hex folds to its exact numeric value like Zend.
var_dump(0x123456789E1234567);
// var_export round-trip comparisons stay plain float comparisons.
var_dump(0.1 + 0.2 == 0.30000000000000004);
$f = 0.1;
var_dump($f + 0.2 == 0.30000000000000004);
// 21 significant digits still promote to Decimal (documented feature).
var_dump(is_float(3.14159265358979323846));
}
?>
--EXPECT--
bool(true)
bool(true)
bool(true)
float(2.098829548031543E+19)
bool(true)
bool(true)
bool(false)
Loading…
Cancel
Save