From d8992fd631ed66b85e784e7dd3fca77ce979c00a Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 16 Aug 2026 16:24:40 +0900 Subject: fix(php-src): round dtoa ties to even and port gcvt mode 2 Rust's shortest float formatting rounds a tie away from zero where zend_dtoa mode 0 rounds to even, so digits taken straight from `{:e}` diverged from PHP on values such as -166050639803968.125. Take only the digit count from `{:e}` and re-format at that fixed precision, which rounds the exact decimal value half to even. The same routine supplies the fixed-precision mode `php_gcvt` previously refused with an assert, so both modes now share one path. Co-Authored-By: Claude Opus 5 (1M context) --- crates/shirabe-php-src/src/main/snprintf.rs | 142 ++++++++++++++++++++++++---- 1 file changed, 125 insertions(+), 17 deletions(-) (limited to 'crates/shirabe-php-src/src') diff --git a/crates/shirabe-php-src/src/main/snprintf.rs b/crates/shirabe-php-src/src/main/snprintf.rs index c0a3f23a..d7804db4 100644 --- a/crates/shirabe-php-src/src/main/snprintf.rs +++ b/crates/shirabe-php-src/src/main/snprintf.rs @@ -1,22 +1,19 @@ /// php-src: main/snprintf.c `php_gcvt` (PHP 8.5.8) /// -/// Only the `ndigit < 0` path (dtoa mode 0, the shortest round-trip representation used when -/// `serialize_precision=-1`) is ported; the fixed-precision mode 2 path is not needed yet. -/// The digit extraction delegates to Rust's own shortest round-trip float formatting, which -/// produces the same digit string as `zend_dtoa` in mode 0 (both compute the unique shortest -/// decimal that round-trips), so only the digit placement logic is ported here. +/// The digit extraction is delegated to `dtoa_shortest` / `dtoa_fixed`, so only the digit +/// placement logic is ported here. `value` must be finite: the caller strips NAN and INF, so the +/// `decpt == 9999` branch has no counterpart here. pub fn php_gcvt(value: f64, ndigit: i32, dec_point: char, exponent: char) -> String { - assert!(ndigit == -1); - assert!(dec_point == '.'); - assert!(exponent == 'E'); - let mode = if ndigit >= 0 { 2 } else { 0 }; - if mode != 0 { - unreachable!("php_gcvt is only ported for ndigit < 0 (serialize_precision=-1)"); - } - let ndigit = 17i32; + // Mode 0 asks dtoa for the shortest round-trip digits, so its digit budget is the widest a + // double can need. Mode 2 yields a single digit when asked for fewer. + let ndigit = if mode == 0 { 17 } else { ndigit.max(1) }; - let (sign, digits, decpt) = dtoa_shortest(value); + let (sign, digits, decpt) = if mode == 0 { + dtoa_shortest(value) + } else { + dtoa_fixed(value, ndigit) + }; let mut buf = String::new(); if sign { @@ -74,14 +71,125 @@ pub fn php_gcvt(value: f64, ndigit: i32, dec_point: char, exponent: char) -> Str /// php-src: Zend/zend_strtod.c `zend_dtoa` mode 0 equivalent: the shortest round-trip digit /// string of `|value|`, its sign, and the decimal point position (`value = 0.digits * 10^decpt`). -/// Implemented on top of Rust's `{:e}` formatting, which is also shortest-round-trip. +/// Rust's `{:e}` supplies the shortest round-trip digit count, but it breaks a tie between two +/// equally distant digit strings away from zero while `zend_dtoa` breaks it to even, so the digits +/// themselves come from `dtoa_fixed` at that count. fn dtoa_shortest(value: f64) -> (bool, String, i32) { + let shortest = format!("{:e}", value.abs()); + let shortest_mantissa = shortest + .split_once('e') + .expect("`{:e}` always contains an exponent") + .0; + let ndigits = shortest_mantissa.chars().filter(|c| *c != '.').count(); + + dtoa_fixed(value, ndigits as i32) +} + +/// php-src: Zend/zend_strtod.c `zend_dtoa` mode 2 equivalent: `|value|` rounded to `ndigit` +/// significant decimal digits, its sign, and the decimal point position +/// (`value = 0.digits * 10^decpt`). Rust's `{:.*e}` rounds the exact decimal value of the double +/// half to even, which is how `zend_dtoa` breaks a tie. Trailing zeros are dropped, as dtoa never +/// emits them; a value that rounds down to nothing keeps a single `0` digit. +fn dtoa_fixed(value: f64, ndigit: i32) -> (bool, String, i32) { let sign = value.is_sign_negative(); - let formatted = format!("{:e}", value.abs()); + let value = value.abs(); + + let formatted = format!("{:.*e}", (ndigit - 1) as usize, value); let (mantissa, exp) = formatted .split_once('e') .expect("`{:e}` always contains an exponent"); let digits: String = mantissa.chars().filter(|c| *c != '.').collect(); let exp: i32 = exp.parse().expect("`{:e}` exponent is a decimal integer"); - (sign, digits, exp + 1) + + let trimmed = digits.trim_end_matches('0'); + let digits = if trimmed.is_empty() { "0" } else { trimmed }; + (sign, digits.to_string(), exp + 1) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn gcvt(value: f64) -> String { + php_gcvt(value, -1, '.', 'E') + } + + fn gcvt14(value: f64) -> String { + php_gcvt(value, 14, '.', 'E') + } + + // Expected strings are the output of `(string) $v` under PHP 8.5.9 with precision=14, which + // reaches php_gcvt with ndigit=14. Compared with ndigit=17 the digits are rounded to 14 + // significant places and the switch to exponential format moves down with ndigit. + #[test] + #[allow(clippy::excessive_precision)] + fn fixed_precision_rounds_to_ndigit_digits() { + assert_eq!(gcvt14(0.0), "0"); + assert_eq!(gcvt14(-0.0), "-0"); + assert_eq!(gcvt14(1.0), "1"); + assert_eq!(gcvt14(100.0), "100"); + assert_eq!(gcvt14(-1.5), "-1.5"); + assert_eq!(gcvt14(1.0 / 3.0), "0.33333333333333"); + assert_eq!(gcvt14(1.0 / 7.0), "0.14285714285714"); + assert_eq!(gcvt14(0.30000000000000004), "0.3"); + assert_eq!(gcvt14(1e-4), "0.0001"); + assert_eq!(gcvt14(1e-5), "1.0E-5"); + assert_eq!(gcvt14(9.99999999999999e-5), "0.0001"); + assert_eq!(gcvt14(1.23456789e-5), "1.23456789E-5"); + assert_eq!(gcvt14(1e13), "10000000000000"); + assert_eq!(gcvt14(1e14), "1.0E+14"); + assert_eq!(gcvt14(1e15), "1.0E+15"); + assert_eq!(gcvt14(99999999999999.99), "1.0E+14"); + assert_eq!(gcvt14(123456789012345.6), "1.2345678901235E+14"); + assert_eq!(gcvt14(1.2345678901234568e16), "1.2345678901235E+16"); + assert_eq!(gcvt14(f64::MAX), "1.7976931348623E+308"); + assert_eq!(gcvt14(f64::MIN_POSITIVE), "2.2250738585072E-308"); + assert_eq!(gcvt14(5e-324), "4.9406564584125E-324"); + } + + // Each value here is exactly representable and sits halfway between two 14-digit decimal + // strings, so it pins down the tie break. Expected strings are the output of `(string) $v` + // under PHP 8.5.9 with precision=14. + #[test] + fn fixed_precision_ties_round_to_even() { + assert_eq!(gcvt14(12345678901234.5), "12345678901234"); + assert_eq!(gcvt14(12345678901235.5), "12345678901236"); + assert_eq!(gcvt14(12345678901236.5), "12345678901236"); + assert_eq!(gcvt14(99999999999998.5), "99999999999998"); + assert_eq!(gcvt14(1234567890123.25), "1234567890123.2"); + assert_eq!(gcvt14(1234567890123.75), "1234567890123.8"); + } + + // PHP clamps precision=0 to a single significant digit. + #[test] + fn fixed_precision_below_one_digit_yields_one_digit() { + assert_eq!(php_gcvt(1.0 / 3.0, 0, '.', 'E'), "0.3"); + assert_eq!(php_gcvt(123.456, 0, '.', 'E'), "1.0E+2"); + assert_eq!(php_gcvt(123.456, 2, '.', 'E'), "1.2E+2"); + } + + // Each value here sits exactly halfway between the two shortest decimal strings that round + // trip to it, so it pins down the tie break. Expected strings are the output of `serialize()` + // under PHP 8.5.9 with serialize_precision=-1 (without the `d:`/`;` wrapper). + #[test] + #[allow(clippy::excessive_precision)] + fn ties_round_to_even() { + assert_eq!(gcvt(-166050639803968.125), "-166050639803968.12"); + assert_eq!(gcvt(-1093304034815995.25), "-1093304034815995.2"); + assert_eq!(gcvt(1958936080342852.25), "1958936080342852.2"); + assert_eq!(gcvt(1406236375946777.25), "1406236375946777.2"); + assert_eq!(gcvt(96483674649693.625), "96483674649693.62"); + assert_eq!(gcvt(1394865425023536.25), "1394865425023536.2"); + assert_eq!(gcvt(-167581363823776.125), "-167581363823776.12"); + assert_eq!(gcvt(1712200237658961.25), "1712200237658961.2"); + assert_eq!(gcvt(1918163363515546.25), "1918163363515546.2"); + assert_eq!(gcvt(2127524128142182.25), "2127524128142182.2"); + assert_eq!(gcvt(2067776186925270.25), "2067776186925270.2"); + assert_eq!(gcvt(-1.4074337013955528e179), "-1.4074337013955528E+179"); + assert_eq!(gcvt(-1.487618938859184e19), "-1.487618938859184E+19"); + assert_eq!(gcvt(-5.181233598032867e-173), "-5.181233598032867E-173"); + assert_eq!(gcvt(-1.1141679308961279e-114), "-1.1141679308961279E-114"); + assert_eq!(gcvt(-1.638344060600543e-227), "-1.638344060600543E-227"); + assert_eq!(gcvt(-1.2377569472211426e-164), "-1.2377569472211426E-164"); + } } -- cgit v1.3.1-4-g156e