aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-php-src/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'crates/shirabe-php-src/src/main')
-rw-r--r--crates/shirabe-php-src/src/main/snprintf.rs142
1 files changed, 125 insertions, 17 deletions
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");
+ }
}