Skip to main content

eui_core/
json.rs

1//! JSON helpers shared by the reducer, the expander and the theme resolver:
2//! the JavaScript number model (one double; integral values print without a
3//! fraction) and a canonical form for byte-for-byte fixture comparisons.
4
5use serde_json::{Map, Number, Value};
6
7/// A JS-style number: integral doubles become JSON integers (`1`, not `1.0`),
8/// everything else stays a float. `NaN`/`±∞` become `null` like `JSON.stringify`.
9pub fn number(v: f64) -> Value {
10    if !v.is_finite() {
11        return Value::Null;
12    }
13    if v.fract() == 0.0 && v.abs() < 9_007_199_254_740_992.0 {
14        if v < 0.0 {
15            return Value::from(v as i64);
16        }
17        return Value::from(v as u64);
18    }
19    Number::from_f64(v).map(Value::Number).unwrap_or(Value::Null)
20}
21
22/// Every number as `f64`.
23pub fn as_f64(v: &Value) -> Option<f64> {
24    v.as_f64()
25}
26
27/// The canonical form two JSON documents are compared in: numbers through
28/// [`number`] (so `2.0 == 2`), `-0 == 0`, objects and arrays recursively.
29pub fn canonical(v: &Value) -> Value {
30    match v {
31        Value::Number(n) => number(n.as_f64().unwrap_or(0.0)),
32        Value::Array(items) => Value::Array(items.iter().map(canonical).collect()),
33        Value::Object(map) => Value::Object(map.iter().map(|(k, v)| (k.clone(), canonical(v))).collect()),
34        other => other.clone(),
35    }
36}
37
38/// `true` when both documents are the same in canonical form.
39pub fn equal(a: &Value, b: &Value) -> bool {
40    canonical(a) == canonical(b)
41}
42
43/// JavaScript's `String(number)` (ECMAScript `Number::toString`): the
44/// shortest round-trip digits, plain from 1e-6 up to 1e21, exponent form
45/// (`1e-7`, `1e+21`) outside; `-0` → `0`, `NaN`, `Infinity`.
46pub fn number_to_string(v: f64) -> String {
47    if v.is_nan() {
48        return "NaN".into();
49    }
50    if v.is_infinite() {
51        return if v > 0.0 { "Infinity".into() } else { "-Infinity".into() };
52    }
53    if v == 0.0 {
54        return "0".into();
55    }
56    let sign = if v < 0.0 { "-" } else { "" };
57    let (digits, n) = crate::format::shortest_digits(v.abs());
58    let k = digits.len() as i32;
59    let body = if k <= n && n <= 21 {
60        format!("{digits}{}", "0".repeat((n - k) as usize))
61    } else if 0 < n && n <= 21 {
62        format!("{}.{}", &digits[..n as usize], &digits[n as usize..])
63    } else if -6 < n && n <= 0 {
64        format!("0.{}{digits}", "0".repeat((-n) as usize))
65    } else {
66        let e = n - 1;
67        let exp = if e >= 0 { format!("e+{e}") } else { format!("e{e}") };
68        if k == 1 {
69            format!("{digits}{exp}")
70        } else {
71            format!("{}.{}{exp}", &digits[..1], &digits[1..])
72        }
73    };
74    format!("{sign}{body}")
75}
76
77/// JavaScript's `String(value)` for the scalar values a template may
78/// interpolate: strings verbatim, numbers through [`number_to_string`],
79/// booleans `true`/`false`, `null`, arrays comma-joined, objects
80/// `[object Object]`.
81pub fn to_js_string(v: &Value) -> String {
82    match v {
83        Value::Null => "null".into(),
84        Value::Bool(b) => b.to_string(),
85        Value::Number(n) => number_to_string(n.as_f64().unwrap_or(0.0)),
86        Value::String(s) => s.clone(),
87        Value::Array(items) => items.iter().map(to_js_string).collect::<Vec<_>>().join(","),
88        Value::Object(_) => "[object Object]".into(),
89    }
90}
91
92/// JavaScript's `Number(value) || 0`.
93pub fn to_number(v: &Value) -> f64 {
94    match v {
95        Value::Null => 0.0,
96        Value::Bool(b) => {
97            if *b {
98                1.0
99            } else {
100                0.0
101            }
102        }
103        Value::Number(n) => n.as_f64().unwrap_or(0.0),
104        Value::String(s) => {
105            let t = s.trim();
106            if t.is_empty() {
107                0.0
108            } else {
109                t.parse::<f64>().unwrap_or(0.0)
110            }
111        }
112        Value::Array(items) => {
113            if items.len() == 1 {
114                to_number(&items[0])
115            } else {
116                0.0
117            }
118        }
119        Value::Object(_) => 0.0,
120    }
121}
122
123/// JavaScript `===` over JSON values (numbers by value, no coercion).
124pub fn strict_eq(a: &Value, b: &Value) -> bool {
125    match (a, b) {
126        (Value::Number(x), Value::Number(y)) => x.as_f64() == y.as_f64(),
127        (Value::Object(_), Value::Object(_)) | (Value::Array(_), Value::Array(_)) => false,
128        _ => a == b,
129    }
130}
131
132/// The empty object.
133pub fn obj() -> Map<String, Value> {
134    Map::new()
135}
136
137/// `value` as an object, if it is one.
138pub fn as_obj(v: &Value) -> Option<&Map<String, Value>> {
139    v.as_object()
140}
141
142/// The object's key `k` as a `&str`.
143pub fn str_of<'a>(m: &'a Map<String, Value>, k: &str) -> Option<&'a str> {
144    m.get(k).and_then(Value::as_str)
145}
146
147/// A deep clone through `Value` (the TS `JSON.parse(JSON.stringify(x))`).
148pub fn clone_value<T: serde::Serialize + serde::de::DeserializeOwned>(v: &T) -> T {
149    serde_json::from_value(serde_json::to_value(v).expect("serialize")).expect("deserialize")
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use serde_json::json;
156
157    #[test]
158    fn numbers_print_like_javascript() {
159        assert_eq!(number(2.0), json!(2));
160        assert_eq!(number(-3.0), json!(-3));
161        assert_eq!(number(66.67), json!(66.67));
162        assert_eq!(number_to_string(100.0), "100");
163        assert_eq!(number_to_string(66.67), "66.67");
164        assert_eq!(number_to_string(1.7777778), "1.7777778");
165        assert_eq!(number_to_string(123456789012345680000.0), "123456789012345680000");
166        assert_eq!(number_to_string(1e21), "1e+21");
167        assert_eq!(number_to_string(1e-7), "1e-7");
168        assert_eq!(number_to_string(1.5e-7), "1.5e-7");
169        assert_eq!(number_to_string(1e-6), "0.000001");
170        assert_eq!(number_to_string(-0.0), "0");
171        assert!(equal(&json!({"a": 2.0}), &json!({"a": 2})));
172        assert!(!equal(&json!({"a": 2.5}), &json!({"a": 2})));
173    }
174
175    #[test]
176    fn js_coercions() {
177        assert_eq!(to_number(&json!("12")), 12.0);
178        assert_eq!(to_number(&json!("x")), 0.0);
179        assert_eq!(to_number(&json!(null)), 0.0);
180        assert!(strict_eq(&json!(1), &json!(1.0)));
181        assert!(!strict_eq(&json!("1"), &json!(1)));
182    }
183}