parse_rust_core/js_number.rs
1//! ECMAScript `Number::toString(x, 10)`, which is what `JSON.stringify` uses for numbers.
2//!
3//! This exists because `serde_json` formats `f64` with ryu, producing the shortest
4//! round-tripping decimal, and that is **not** the same string ECMAScript produces. Parse
5//! Server is a Node server, so its number output is ECMAScript's, and wire compatibility
6//! means matching it byte for byte.
7//!
8//! Measured divergences between ryu and `JSON.stringify`, all reproduced in the tests below:
9//!
10//! | Value | `JSON.stringify` | `serde_json` |
11//! |----------|------------------|--------------|
12//! | `100.0` | `100` | `100.0` |
13//! | `1e20` | `100000000000000000000` | `1e20` |
14//! | `1e-6` | `0.000001` | `1e-6` |
15//! | `-0.0` | `0` | `-0.0` |
16//!
17//! Not a divergence, despite looking like one: both emit `+` in a positive exponent, so
18//! `1.5e+300` already matches. Do not "fix" that.
19//!
20//! Spec: ECMA-262 §6.1.6.1.20, `Number::toString`.
21//!
22//! Note the distinction this module does *not* cover: number **representation**, meaning which
23//! BSON type a value is stored as, is a separate problem handled at the storage boundary in
24//! `parse-rust-mongo`. Conflating the two is a mistake this project made once.
25
26use std::fmt::Write as _;
27
28/// Format an `f64` exactly as ECMAScript's `String(x)` / `JSON.stringify(x)` would.
29///
30/// Note `NaN` and the infinities: this returns their ECMAScript *string* forms, which is
31/// correct for `String(x)` but is **not** valid JSON. `JSON.stringify` emits `null` for all
32/// three. Callers serializing to JSON must handle that before calling here; the encoder in
33/// `crate::value` does.
34pub fn to_ecma_string(x: f64) -> String {
35 if x.is_nan() {
36 return "NaN".to_string();
37 }
38 // Step 2: both zeros render as "0". The sign of -0.0 is deliberately dropped.
39 if x == 0.0 {
40 return "0".to_string();
41 }
42 if x < 0.0 {
43 return format!("-{}", to_ecma_string(-x));
44 }
45 if x.is_infinite() {
46 return "Infinity".to_string();
47 }
48
49 let (digits, n) = shortest_digits(x);
50 let k = digits.len() as i32;
51 render(&digits, k, n)
52}
53
54/// Decompose a positive, finite `f64` into its shortest round-tripping decimal digits and the
55/// position of the decimal point.
56///
57/// Returns `(digits, n)` with no trailing zeros, such that `0.<digits> * 10^n == x`. This is
58/// `s` and `n` from the spec, with `k = digits.len()`.
59///
60/// **Uses ryu, not `format!("{:e}")`, and the difference is not cosmetic.** Spec step 5 does not
61/// merely ask for a shortest representation, it asks for a specific one: among candidates of
62/// minimal length, the one closest in value to `x`, breaking a remaining tie toward the even
63/// digit. Rust's `Display`/`LowerExp` guarantee only that the result round-trips, which is a
64/// weaker property, and the two disagree in practice.
65///
66/// Found by the differential test in `tests/js_number_differential.rs`, not by reading:
67/// `f64::from_bits(4829166033435530498)` renders as `726354065216160.3` via `{:e}` and
68/// `726354065216160.2` via ryu and Node. Both strings parse back to the identical bit pattern,
69/// so both are "shortest round-tripping"; only ryu's is the closest to the true value.
70fn shortest_digits(x: f64) -> (String, i32) {
71 let mut buf = ryu::Buffer::new();
72 let s = buf.format_finite(x); // "100.0", "0.1", "1e20", "1.5e300", "726354065216160.2"
73
74 let (mantissa, exp10) = match s.split_once('e') {
75 // Scientific: mantissa is d[.ddd], exponent has no explicit '+'.
76 Some((m, e)) => (m, e.parse::<i32>().unwrap_or(0)),
77 None => (s, 0),
78 };
79
80 let (int_part, frac_part) = mantissa.split_once('.').unwrap_or((mantissa, ""));
81
82 // n counts digits before the decimal point. For a pure fraction ryu emits "0.000ddd", and
83 // each leading zero in the fraction pushes the point one place further right.
84 let (digits, n) = if int_part == "0" {
85 let lead_zeros = frac_part.len() - frac_part.trim_start_matches('0').len();
86 (frac_part[lead_zeros..].to_string(), -(lead_zeros as i32))
87 } else {
88 let mut d = String::with_capacity(int_part.len() + frac_part.len());
89 d.push_str(int_part);
90 d.push_str(frac_part);
91 (d, int_part.len() as i32)
92 };
93
94 // ryu writes "100.0" for an integer, so the combined digits can carry trailing zeros that
95 // inflate k and would push a value into the wrong rendering branch.
96 let trimmed = digits.trim_end_matches('0');
97 let digits = if trimmed.is_empty() {
98 "0".to_string()
99 } else {
100 trimmed.to_string()
101 };
102
103 (digits, n + exp10)
104}
105
106/// Steps 6 through 10 of ECMA-262 §6.1.6.1.20, given the digits, their count `k`, and the
107/// decimal point position `n`.
108fn render(digits: &str, k: i32, n: i32) -> String {
109 // Step 6: k <= n <= 21. Integer, padded with n-k trailing zeros, no fractional part.
110 if k <= n && n <= 21 {
111 let mut s = String::with_capacity(n as usize);
112 s.push_str(digits);
113 for _ in 0..(n - k) {
114 s.push('0');
115 }
116 return s;
117 }
118 // Step 7: 0 < n <= 21. Decimal point falls inside the digits.
119 if 0 < n && n <= 21 {
120 let (int_part, frac_part) = digits.split_at(n as usize);
121 return format!("{int_part}.{frac_part}");
122 }
123 // Step 8: -6 < n <= 0. Leading "0." then -n zeros then the digits.
124 if -6 < n && n <= 0 {
125 let mut s = String::with_capacity((2 - n) as usize + digits.len());
126 s.push_str("0.");
127 for _ in 0..(-n) {
128 s.push('0');
129 }
130 s.push_str(digits);
131 return s;
132 }
133 // Steps 9 and 10: exponential. The exponent is n-1, and its sign is always explicit.
134 let e = n - 1;
135 let sign = if e >= 0 { '+' } else { '-' };
136 let mut s = String::new();
137 if k == 1 {
138 // Step 9: single digit, no decimal point.
139 let _ = write!(s, "{digits}e{sign}{}", e.abs());
140 } else {
141 // Step 10: first digit, point, remainder.
142 let (first, rest) = digits.split_at(1);
143 let _ = write!(s, "{first}.{rest}e{sign}{}", e.abs());
144 }
145 s
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 /// Every expectation here was produced by running the value through Node and pasting the
153 /// output, not by reasoning about the spec. `tools/js-number-differential.js` re-derives
154 /// them at scale against a live Node.
155 #[test]
156 fn matches_node_on_the_known_divergences() {
157 // The four rules where ryu and ECMAScript disagree.
158 assert_eq!(to_ecma_string(100.0), "100"); // ryu: 100.0
159 assert_eq!(to_ecma_string(3_000_000_000.0), "3000000000"); // ryu: 3000000000.0
160 assert_eq!(to_ecma_string(1e20), "100000000000000000000"); // ryu: 1e20
161 assert_eq!(to_ecma_string(1e-6), "0.000001"); // ryu: 1e-6
162 assert_eq!(to_ecma_string(-0.0), "0"); // ryu: -0.0
163 }
164
165 #[test]
166 fn agrees_with_ryu_where_it_already_agreed() {
167 // Guards against "fixing" the exponent sign, which ryu already gets right.
168 assert_eq!(to_ecma_string(1.5e300), "1.5e+300");
169 assert_eq!(to_ecma_string(1e21), "1e+21");
170 assert_eq!(to_ecma_string(1e-7), "1e-7");
171 assert_eq!(to_ecma_string(0.1), "0.1");
172 assert_eq!(to_ecma_string(5e-324), "5e-324");
173 assert_eq!(to_ecma_string(f64::MAX), "1.7976931348623157e+308");
174 }
175
176 #[test]
177 fn boundaries_are_exact() {
178 // Upper switch to exponential is at 1e21, not before.
179 assert_eq!(to_ecma_string(1e20), "100000000000000000000");
180 assert_eq!(to_ecma_string(1e21), "1e+21");
181 // Lower switch is at 1e-7, not 1e-6.
182 assert_eq!(to_ecma_string(1e-6), "0.000001");
183 assert_eq!(to_ecma_string(1e-7), "1e-7");
184 // 21 significant digits still renders as an integer below 1e21.
185 assert_eq!(
186 to_ecma_string(123456789012345678901.0),
187 "123456789012345680000"
188 );
189 }
190
191 #[test]
192 fn integers_and_fractions() {
193 assert_eq!(to_ecma_string(0.0), "0");
194 assert_eq!(to_ecma_string(1.0), "1");
195 assert_eq!(to_ecma_string(-1.0), "-1");
196 assert_eq!(to_ecma_string(1.5), "1.5");
197 assert_eq!(to_ecma_string(-1.5), "-1.5");
198 assert_eq!(to_ecma_string(9007199254740992.0), "9007199254740992"); // 2^53
199 assert_eq!(to_ecma_string(-1e21), "-1e+21");
200 }
201
202 #[test]
203 fn non_finite_are_ecmascript_strings_not_json() {
204 // Documented above: these are String(x), not JSON. The value encoder maps them to null.
205 assert_eq!(to_ecma_string(f64::NAN), "NaN");
206 assert_eq!(to_ecma_string(f64::INFINITY), "Infinity");
207 assert_eq!(to_ecma_string(f64::NEG_INFINITY), "-Infinity");
208 }
209
210 #[test]
211 fn round_trips_through_parse() {
212 // Whatever we emit must parse back to the same bits, since the digits are shortest
213 // round-tripping by construction. Catches an error in the rendering steps.
214 let vals = [
215 1.0,
216 100.0,
217 0.1,
218 1e20,
219 1e21,
220 1e-6,
221 1e-7,
222 1.5e300,
223 5e-324,
224 f64::MAX,
225 9007199254740992.0,
226 123456789012345678901.0,
227 -42.75,
228 2.2250738585072014e-308,
229 ];
230 for v in vals {
231 let s = to_ecma_string(v);
232 let back: f64 = s
233 .parse()
234 .unwrap_or_else(|e| panic!("{s} did not reparse: {e}"));
235 assert_eq!(back.to_bits(), v.to_bits(), "round trip failed for {s}");
236 }
237 }
238}