Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions phpunit/code/decimal-literal-classification.php
Original file line number Diff line number Diff line change
@@ -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;
}
58 changes: 58 additions & 0 deletions phpunit/src/DecimalLiteralClassificationTest.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
1 change: 1 addition & 0 deletions src/CompilerBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions src/Parser/BinaryOpTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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);

Expand Down
91 changes: 88 additions & 3 deletions src/Parser/TypeDetectionTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions tests/compiler/float_edge/decimal-literal-classification.phpt
Original file line number Diff line number Diff line change
@@ -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