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 2 days 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_STATEMENT_EXPRESSION = 'aotStatementExpression';
protected const string ATTR_MULTI_RETURN_IMPL = 'aotMultiReturnImpl'; protected const string ATTR_MULTI_RETURN_IMPL = 'aotMultiReturnImpl';
protected const string ATTR_SCOPED_CALLBACK = 'aotScopedCallback'; protected const string ATTR_SCOPED_CALLBACK = 'aotScopedCallback';
protected const string ATTR_FORCE_FLOAT_LITERAL = 'aotForceFloatLiteral';
/** /**
* Keyword methods (to* builtins) with mandated return types. * Keyword methods (to* builtins) with mandated return types.

@ -24,6 +24,8 @@ trait BinaryOpTrait
$this->assertExprCanBeUsedAsValue($left, 'binary operand'); $this->assertExprCanBeUsedAsValue($left, 'binary operand');
$this->assertExprCanBeUsedAsValue($right, 'binary operand'); $this->assertExprCanBeUsedAsValue($right, 'binary operand');
$this->demoteAutoDecimalLiteralAgainstFloat($left, $right);
// Arithmetic logic: convert to a numeric type first when possible // Arithmetic logic: convert to a numeric type first when possible
$leftExpr = $this->parseOrderedBinaryOperand($left); $leftExpr = $this->parseOrderedBinaryOperand($left);
$rightExpr = $this->parseOrderedBinaryOperand($right); $rightExpr = $this->parseOrderedBinaryOperand($right);
@ -982,6 +984,7 @@ trait BinaryOpTrait
if ($pythonOperator !== null) { if ($pythonOperator !== null) {
return $pythonOperator; return $pythonOperator;
} }
$this->demoteAutoDecimalLiteralAgainstFloat($expr->left, $expr->right);
$left = $this->parseCompareExpr($expr->left); $left = $this->parseCompareExpr($expr->left);
$right = $this->parseCompareExpr($expr->right); $right = $this->parseCompareExpr($expr->right);
$leftIsNative = $this->isNativeObjectClass($this->detectClassOfExpr($expr->left)); $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) . ')'; ?? '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 protected function genBigNumericCmp(Expr\BinaryOp $expr, string $suffix = ''): ?string
{ {
$this->demoteAutoDecimalLiteralAgainstFloat($expr->left, $expr->right);
$leftType = $this->detectTypeOfExpr($expr->left); $leftType = $this->detectTypeOfExpr($expr->left);
$rightType = $this->detectTypeOfExpr($expr->right); $rightType = $this->detectTypeOfExpr($expr->right);

@ -47,18 +47,103 @@ trait TypeDetectionTrait
protected function isDecimalLiteral(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'); $rawValue = $expr->getAttribute('rawValue');
if ($rawValue === null) { if ($rawValue === null) {
return false; return false;
} }
$clean = $this->stripNumericUnderscores($rawValue); $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) // Must have a decimal point or exponent (not a pure integer)
if (!preg_match('/[\.eE]/', $clean)) { if (!preg_match('/[\.eE]/', $clean)) {
return false; return false;
} }
// Count significant digits (exclude ., e, E, +, -) // Documented rule: 16 or more significant digits promote to Decimal.
$digits = preg_replace('/[^0-9]/', '', $clean); // Exponent digits carry no precision, and neither do leading or
return strlen(ltrim($digits, '0')) >= 16; // 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 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