Skip to main content

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/// `String(value)`, the coercion a JavaScript template literal or `+` performs.
149///
150/// Upstream builds several client-visible error messages by concatenating a value into a string,
151/// so the rendering is part of the wire contract rather than a debugging convenience:
152/// `You cannot use ${value} as a query parameter.` (`MongoTransform.js:352`) and
153/// `'This is not a valid ' + obj.__type` (`SchemaController.js`) both reach a client.
154///
155/// The two cases worth naming: an array joins its elements on commas **after** coercing each one,
156/// so nesting flattens and `null` renders as the empty string; and every other object is the
157/// literal `[object Object]`, which is why a malformed operand's message says nothing about it.
158pub fn to_ecma_display(value: &crate::value::ParseValue) -> String {
159    use crate::value::ParseValue;
160    match value {
161        ParseValue::String(s) => s.clone(),
162        ParseValue::Number(n) => to_ecma_string(*n),
163        ParseValue::Bool(b) => b.to_string(),
164        ParseValue::Null => "null".to_string(),
165        ParseValue::Array(items) => items
166            .iter()
167            .map(|item| match item {
168                // `Array.prototype.join` renders null and undefined as empty, which is not what
169                // `String(null)` does. The difference is only visible inside an array.
170                ParseValue::Null => String::new(),
171                other => to_ecma_display(other),
172            })
173            .collect::<Vec<_>>()
174            .join(","),
175        _ => "[object Object]".to_string(),
176    }
177}
178
179/// JavaScript truthiness.
180///
181/// Upstream guards several schema decisions with a bare `if (obj.key)`, which is **not** a presence
182/// test: `""`, `0`, `false` and `NaN` are all present and all falsy, and an empty array or object
183/// is falsy in neither JavaScript nor here. Reading those guards as "is the key set" accepts
184/// metadata upstream refuses, and reading them as "is the key a string" refuses metadata upstream
185/// accepts.
186pub fn is_truthy(value: &crate::value::ParseValue) -> bool {
187    use crate::value::ParseValue;
188    match value {
189        ParseValue::Null => false,
190        ParseValue::Bool(b) => *b,
191        ParseValue::Number(n) => *n != 0.0 && !n.is_nan(),
192        ParseValue::String(s) => !s.is_empty(),
193        // Every object is truthy in JavaScript, an empty array and an empty object included.
194        _ => true,
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    /// Every expectation here was produced by running the value through Node and pasting the
203    /// output, not by reasoning about the spec. `tests/js_number_differential.rs`, driving `tools/js-number-oracle.js`, re-derives
204    /// them at scale against a live Node.
205    #[test]
206    fn matches_node_on_the_known_divergences() {
207        // The four rules where ryu and ECMAScript disagree.
208        assert_eq!(to_ecma_string(100.0), "100"); // ryu: 100.0
209        assert_eq!(to_ecma_string(3_000_000_000.0), "3000000000"); // ryu: 3000000000.0
210        assert_eq!(to_ecma_string(1e20), "100000000000000000000"); // ryu: 1e20
211        assert_eq!(to_ecma_string(1e-6), "0.000001"); // ryu: 1e-6
212        assert_eq!(to_ecma_string(-0.0), "0"); // ryu: -0.0
213    }
214
215    #[test]
216    fn agrees_with_ryu_where_it_already_agreed() {
217        // Guards against "fixing" the exponent sign, which ryu already gets right.
218        assert_eq!(to_ecma_string(1.5e300), "1.5e+300");
219        assert_eq!(to_ecma_string(1e21), "1e+21");
220        assert_eq!(to_ecma_string(1e-7), "1e-7");
221        assert_eq!(to_ecma_string(0.1), "0.1");
222        assert_eq!(to_ecma_string(5e-324), "5e-324");
223        assert_eq!(to_ecma_string(f64::MAX), "1.7976931348623157e+308");
224    }
225
226    #[test]
227    fn boundaries_are_exact() {
228        // Upper switch to exponential is at 1e21, not before.
229        assert_eq!(to_ecma_string(1e20), "100000000000000000000");
230        assert_eq!(to_ecma_string(1e21), "1e+21");
231        // Lower switch is at 1e-7, not 1e-6.
232        assert_eq!(to_ecma_string(1e-6), "0.000001");
233        assert_eq!(to_ecma_string(1e-7), "1e-7");
234        // 21 significant digits still renders as an integer below 1e21.
235        assert_eq!(
236            to_ecma_string(123456789012345678901.0),
237            "123456789012345680000"
238        );
239    }
240
241    #[test]
242    fn integers_and_fractions() {
243        assert_eq!(to_ecma_string(0.0), "0");
244        assert_eq!(to_ecma_string(1.0), "1");
245        assert_eq!(to_ecma_string(-1.0), "-1");
246        assert_eq!(to_ecma_string(1.5), "1.5");
247        assert_eq!(to_ecma_string(-1.5), "-1.5");
248        assert_eq!(to_ecma_string(9007199254740992.0), "9007199254740992"); // 2^53
249        assert_eq!(to_ecma_string(-1e21), "-1e+21");
250    }
251
252    #[test]
253    fn non_finite_are_ecmascript_strings_not_json() {
254        // Documented above: these are String(x), not JSON. The value encoder maps them to null.
255        assert_eq!(to_ecma_string(f64::NAN), "NaN");
256        assert_eq!(to_ecma_string(f64::INFINITY), "Infinity");
257        assert_eq!(to_ecma_string(f64::NEG_INFINITY), "-Infinity");
258    }
259
260    #[test]
261    fn round_trips_through_parse() {
262        // Whatever we emit must parse back to the same bits, since the digits are shortest
263        // round-tripping by construction. Catches an error in the rendering steps.
264        let vals = [
265            1.0,
266            100.0,
267            0.1,
268            1e20,
269            1e21,
270            1e-6,
271            1e-7,
272            1.5e300,
273            5e-324,
274            f64::MAX,
275            9007199254740992.0,
276            123456789012345678901.0,
277            -42.75,
278            2.2250738585072014e-308,
279        ];
280        for v in vals {
281            let s = to_ecma_string(v);
282            let back: f64 = s
283                .parse()
284                .unwrap_or_else(|e| panic!("{s} did not reparse: {e}"));
285            assert_eq!(back.to_bits(), v.to_bits(), "round trip failed for {s}");
286        }
287    }
288}