Skip to main content

long_screen/
value.rs

1//! Persisted rows as the web page stores them: JavaScript values with ordered object fields, typed arrays and blobs.
2//!
3//! The page writes plain JS objects into IndexedDB, and both its export package and the scenario fingerprint
4//! (`scripts/fingerprint-scenarios.ts`) read them back field by field, in insertion order. The native host keeps the
5//! same values, built field by field in the same order, so a native run's rows can be compared with the page's row
6//! for row ([`fingerprint`]).
7
8use sha2::{Digest, Sha256};
9use std::sync::Arc;
10
11/// The typed-array constructor a byte buffer stands for (its elements are little-endian).
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum Typed {
14    Uint8,
15    Uint8Clamped,
16    Int16,
17    Int32,
18    Uint32,
19    Float32,
20}
21
22impl Typed {
23    pub fn name(self) -> &'static str {
24        match self {
25            Typed::Uint8 => "Uint8Array",
26            Typed::Uint8Clamped => "Uint8ClampedArray",
27            Typed::Int16 => "Int16Array",
28            Typed::Int32 => "Int32Array",
29            Typed::Uint32 => "Uint32Array",
30            Typed::Float32 => "Float32Array",
31        }
32    }
33}
34
35/// A JavaScript value as a row holds it.
36#[derive(Clone, Debug, PartialEq)]
37pub enum Js {
38    /// `undefined`: left out of an object, `null` in an array (as `JSON.stringify` does).
39    Undefined,
40    Null,
41    Bool(bool),
42    Num(f64),
43    Str(String),
44    Arr(Vec<Js>),
45    Obj(Obj),
46    /// A typed array, as its little-endian bytes.
47    Typed(Typed, Arc<Vec<u8>>),
48    /// A `Blob` of the given media type.
49    Blob(String, Arc<Vec<u8>>),
50}
51
52/// An object: fields in insertion order. Setting an existing field keeps its position, as in JavaScript.
53#[derive(Clone, Debug, Default, PartialEq)]
54pub struct Obj(pub Vec<(String, Js)>);
55
56impl Obj {
57    pub fn new() -> Obj {
58        Obj(Vec::new())
59    }
60
61    /// Appends or replaces `key` (keeping its position when it exists).
62    pub fn set(&mut self, key: &str, value: impl Into<Js>) -> &mut Obj {
63        let value = value.into();
64        match self.0.iter_mut().find(|(k, _)| k == key) {
65            Some((_, v)) => *v = value,
66            None => self.0.push((key.to_string(), value)),
67        }
68        self
69    }
70
71    /// Builder form of [`Obj::set`].
72    pub fn with(mut self, key: &str, value: impl Into<Js>) -> Obj {
73        self.set(key, value);
74        self
75    }
76
77    /// Sets `key` only when `value` is `Some`.
78    pub fn with_opt<T: Into<Js>>(mut self, key: &str, value: Option<T>) -> Obj {
79        if let Some(v) = value {
80            self.set(key, v);
81        }
82        self
83    }
84
85    pub fn get(&self, key: &str) -> Option<&Js> {
86        self.0.iter().find(|(k, _)| k == key).map(|(_, v)| v)
87    }
88
89    pub fn get_mut(&mut self, key: &str) -> Option<&mut Js> {
90        self.0.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v)
91    }
92
93    pub fn remove(&mut self, key: &str) -> Option<Js> {
94        let at = self.0.iter().position(|(k, _)| k == key)?;
95        Some(self.0.remove(at).1)
96    }
97
98    pub fn num(&self, key: &str) -> f64 {
99        self.get(key).map_or(f64::NAN, Js::num)
100    }
101
102    pub fn str(&self, key: &str) -> Option<&str> {
103        self.get(key).and_then(Js::as_str)
104    }
105
106    pub fn bool(&self, key: &str) -> bool {
107        matches!(self.get(key), Some(Js::Bool(true)))
108    }
109}
110
111impl Js {
112    pub fn num(&self) -> f64 {
113        match self {
114            Js::Num(n) => *n,
115            _ => f64::NAN,
116        }
117    }
118
119    pub fn as_str(&self) -> Option<&str> {
120        match self {
121            Js::Str(s) => Some(s),
122            _ => None,
123        }
124    }
125
126    pub fn as_obj(&self) -> Option<&Obj> {
127        match self {
128            Js::Obj(o) => Some(o),
129            _ => None,
130        }
131    }
132
133    pub fn as_obj_mut(&mut self) -> Option<&mut Obj> {
134        match self {
135            Js::Obj(o) => Some(o),
136            _ => None,
137        }
138    }
139
140    pub fn as_arr(&self) -> Option<&[Js]> {
141        match self {
142            Js::Arr(a) => Some(a),
143            _ => None,
144        }
145    }
146
147    /// The bytes of a typed array or blob.
148    pub fn bytes(&self) -> Option<&Arc<Vec<u8>>> {
149        match self {
150            Js::Typed(_, b) | Js::Blob(_, b) => Some(b),
151            _ => None,
152        }
153    }
154
155    pub fn typed(kind: Typed, bytes: Vec<u8>) -> Js {
156        Js::Typed(kind, Arc::new(bytes))
157    }
158
159    pub fn u8s(bytes: Vec<u8>) -> Js {
160        Js::typed(Typed::Uint8, bytes)
161    }
162
163    pub fn u32s(values: &[u32]) -> Js {
164        Js::typed(
165            Typed::Uint32,
166            values.iter().flat_map(|v| v.to_le_bytes()).collect(),
167        )
168    }
169
170    pub fn f32s(values: &[f32]) -> Js {
171        Js::typed(
172            Typed::Float32,
173            values.iter().flat_map(|v| v.to_le_bytes()).collect(),
174        )
175    }
176
177    pub fn png(bytes: Vec<u8>) -> Js {
178        Js::Blob("image/png".into(), Arc::new(bytes))
179    }
180
181    /// Reads a JSON document into values, keeping its field order.
182    pub fn from_json(value: &serde_json::Value) -> Js {
183        match value {
184            serde_json::Value::Null => Js::Null,
185            serde_json::Value::Bool(b) => Js::Bool(*b),
186            serde_json::Value::Number(n) => Js::Num(n.as_f64().unwrap_or(f64::NAN)),
187            serde_json::Value::String(s) => Js::Str(s.clone()),
188            serde_json::Value::Array(a) => Js::Arr(a.iter().map(Js::from_json).collect()),
189            serde_json::Value::Object(o) => Js::Obj(Obj(o
190                .iter()
191                .map(|(k, v)| (k.clone(), Js::from_json(v)))
192                .collect())),
193        }
194    }
195}
196
197impl From<f64> for Js {
198    fn from(v: f64) -> Js {
199        Js::Num(v)
200    }
201}
202impl From<u32> for Js {
203    fn from(v: u32) -> Js {
204        Js::Num(v as f64)
205    }
206}
207impl From<i32> for Js {
208    fn from(v: i32) -> Js {
209        Js::Num(v as f64)
210    }
211}
212impl From<u64> for Js {
213    fn from(v: u64) -> Js {
214        Js::Num(v as f64)
215    }
216}
217impl From<usize> for Js {
218    fn from(v: usize) -> Js {
219        Js::Num(v as f64)
220    }
221}
222impl From<bool> for Js {
223    fn from(v: bool) -> Js {
224        Js::Bool(v)
225    }
226}
227impl From<&str> for Js {
228    fn from(v: &str) -> Js {
229        Js::Str(v.to_string())
230    }
231}
232impl From<String> for Js {
233    fn from(v: String) -> Js {
234        Js::Str(v)
235    }
236}
237impl From<Obj> for Js {
238    fn from(v: Obj) -> Js {
239        Js::Obj(v)
240    }
241}
242impl From<Vec<Js>> for Js {
243    fn from(v: Vec<Js>) -> Js {
244        Js::Arr(v)
245    }
246}
247
248/// `{ x, y, width, height }`.
249pub fn rect(r: crate::kernels::geometry::Rect) -> Js {
250    Js::Obj(
251        Obj::new()
252            .with("x", r.x)
253            .with("y", r.y)
254            .with("width", r.width)
255            .with("height", r.height),
256    )
257}
258
259/// Reads `{ x, y, width, height }`.
260pub fn read_rect(v: &Js) -> Option<crate::kernels::geometry::Rect> {
261    let o = v.as_obj()?;
262    Some(crate::kernels::geometry::Rect {
263        x: o.num("x"),
264        y: o.num("y"),
265        width: o.num("width"),
266        height: o.num("height"),
267    })
268}
269
270/// A diagnostic `detail` payload as a value.
271pub fn detail(v: &crate::engine::diagnostic::Value) -> Js {
272    use crate::engine::diagnostic::Value;
273    match v {
274        Value::Number(n) => Js::Num(*n),
275        Value::Bool(b) => Js::Bool(*b),
276        Value::Text(s) => Js::Str(s.clone()),
277        Value::List(l) => Js::Arr(l.iter().map(detail).collect()),
278        Value::Object(fields) => Js::Obj(Obj(fields
279            .iter()
280            .map(|(k, v)| (k.to_string(), detail(v)))
281            .collect())),
282    }
283}
284
285// --- JSON as `JSON.stringify` writes it ---
286
287/// `JSON.stringify` of a value (typed arrays and blobs must have been replaced first; see [`canonical`]).
288pub fn write_json(v: &Js, out: &mut String) {
289    match v {
290        Js::Undefined | Js::Null => out.push_str("null"),
291        Js::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
292        Js::Num(n) => {
293            if n.is_finite() {
294                out.push_str(&crate::engine::catalog::js_number(*n))
295            } else {
296                out.push_str("null")
297            }
298        }
299        Js::Str(s) => write_string(s, out),
300        Js::Arr(a) => {
301            out.push('[');
302            for (i, x) in a.iter().enumerate() {
303                if i > 0 {
304                    out.push(',');
305                }
306                write_json(x, out);
307            }
308            out.push(']');
309        }
310        Js::Obj(o) => {
311            out.push('{');
312            let mut first = true;
313            for (k, x) in ordered(o) {
314                if matches!(x, Js::Undefined) {
315                    continue;
316                }
317                if !first {
318                    out.push(',');
319                }
320                first = false;
321                write_string(k, out);
322                out.push(':');
323                write_json(x, out);
324            }
325            out.push('}');
326        }
327        // Not JSON: a typed array stringifies as an object of its indices, a blob as `{}`. Rows never reach here
328        // without [`canonical`]; the report writes their sizes instead.
329        Js::Typed(..) | Js::Blob(..) => out.push_str("{}"),
330    }
331}
332
333/// JavaScript's own-property order: integer-like keys first, ascending, then the rest in insertion order.
334fn ordered(o: &Obj) -> Vec<(&String, &Js)> {
335    let index = |k: &str| -> Option<u32> {
336        if k == "0"
337            || (!k.starts_with('0') && !k.is_empty() && k.bytes().all(|b| b.is_ascii_digit()))
338        {
339            k.parse::<u32>().ok().filter(|n| *n < u32::MAX)
340        } else {
341            None
342        }
343    };
344    let mut ints: Vec<(u32, &String, &Js)> =
345        o.0.iter()
346            .filter_map(|(k, v)| index(k).map(|n| (n, k, v)))
347            .collect();
348    ints.sort_by_key(|(n, _, _)| *n);
349    let mut out: Vec<(&String, &Js)> = ints.into_iter().map(|(_, k, v)| (k, v)).collect();
350    out.extend(
351        o.0.iter()
352            .filter(|(k, _)| index(k).is_none())
353            .map(|(k, v)| (k, v)),
354    );
355    out
356}
357
358fn write_string(s: &str, out: &mut String) {
359    out.push('"');
360    for c in s.chars() {
361        match c {
362            '"' => out.push_str("\\\""),
363            '\\' => out.push_str("\\\\"),
364            '\n' => out.push_str("\\n"),
365            '\r' => out.push_str("\\r"),
366            '\t' => out.push_str("\\t"),
367            '\u{8}' => out.push_str("\\b"),
368            '\u{c}' => out.push_str("\\f"),
369            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
370            c => out.push(c),
371        }
372    }
373    out.push('"');
374}
375
376pub fn to_json(v: &Js) -> String {
377    let mut s = String::new();
378    write_json(v, &mut s);
379    s
380}
381
382// --- The scenario fingerprint ---
383
384/// The first 12 bytes of SHA-256, in hex: the fingerprint's digest.
385pub fn short_sha(bytes: &[u8]) -> String {
386    let d = Sha256::digest(bytes);
387    d[..12].iter().map(|b| format!("{b:02x}")).collect()
388}
389
390fn volatile(key: &str) -> bool {
391    matches!(key, "created" | "updated" | "compute" | "id")
392        || key.ends_with("MS")
393        || key == "seconds"
394}
395
396/// The value `scripts/fingerprint-scenarios.ts`'s `canon()` makes of a row: wall-clock and id fields dropped, the
397/// run id replaced by `<run>`, typed arrays and blobs replaced by their digests, `COMPUTE_BACKEND`'s host-timed
398/// message and detail dropped.
399pub fn canonical(v: &Js, run: &str) -> Js {
400    match v {
401        Js::Blob(kind, bytes) => Js::Obj(
402            Obj::new()
403                .with("blob", kind.as_str())
404                .with("sha", short_sha(bytes)),
405        ),
406        Js::Typed(kind, bytes) => Js::Obj(Obj::new().with(kind.name(), short_sha(bytes))),
407        Js::Str(s) => Js::Str(if run.is_empty() {
408            s.clone()
409        } else {
410            s.replace(run, "<run>")
411        }),
412        Js::Arr(a) => Js::Arr(a.iter().map(|x| canonical(x, run)).collect()),
413        Js::Obj(o) => {
414            let host_timed = o.str("code") == Some("COMPUTE_BACKEND");
415            Js::Obj(Obj(o
416                .0
417                .iter()
418                .filter(|(k, _)| !volatile(k) && !(host_timed && (k == "message" || k == "detail")))
419                .map(|(k, x)| (k.clone(), canonical(x, run)))
420                .collect()))
421        }
422        other => other.clone(),
423    }
424}
425
426/// A row's fingerprint digest: the short SHA-256 of `JSON.stringify(canon(row))`.
427pub fn fingerprint(v: &Js, run: &str) -> String {
428    short_sha(to_json(&canonical(v, run)).as_bytes())
429}
430
431// --- Number formatting the page's messages use ---
432
433/// `Number.prototype.toFixed(digits)`: the exact value rounded half away from zero (JS picks the larger `n` on a
434/// tie of the exact binary value), in plain notation below 1e21.
435pub fn to_fixed(value: f64, digits: usize) -> String {
436    if !value.is_finite() {
437        return crate::engine::catalog::js_number(value);
438    }
439    if value.abs() >= 1e21 {
440        return crate::engine::catalog::js_number(value);
441    }
442    let negative = value < 0.0;
443    // Rust prints the exact decimal expansion of the double with enough digits; round that half up.
444    let exact = format!("{:.*}", digits + 40, value.abs());
445    let (int, frac) = exact.split_once('.').unwrap_or((&exact, ""));
446    let mut digits_vec: Vec<u8> = int.bytes().chain(frac.bytes().take(digits)).collect();
447    let rest = &frac.as_bytes()[digits.min(frac.len())..];
448    let round_up = rest.first().is_some_and(|&d| d >= b'5');
449    if round_up {
450        let mut i = digits_vec.len();
451        loop {
452            if i == 0 {
453                digits_vec.insert(0, b'1');
454                break;
455            }
456            i -= 1;
457            if digits_vec[i] == b'9' {
458                digits_vec[i] = b'0';
459            } else {
460                digits_vec[i] += 1;
461                break;
462            }
463        }
464    }
465    let s = String::from_utf8(digits_vec).unwrap();
466    let split = s.len() - digits;
467    let body = if digits == 0 {
468        s
469    } else {
470        format!("{}.{}", &s[..split], &s[split..])
471    };
472    // JS: "if x < 0, let s be "-"" — so (-0.04).toFixed(1) is "-0.0", while -0 itself has no sign.
473    if negative {
474        format!("-{body}")
475    } else {
476        body
477    }
478}
479
480/// `Number.prototype.toLocaleString()` in the `en-US` locale Deno and the page default to: grouped thousands, at
481/// most three fraction digits.
482pub fn to_locale(value: f64) -> String {
483    if !value.is_finite() {
484        return crate::engine::catalog::js_number(value);
485    }
486    let fixed = to_fixed(value, 3);
487    let (int, frac) = fixed.split_once('.').unwrap_or((&fixed, ""));
488    let (sign, int) = int.strip_prefix('-').map_or(("", int), |i| ("-", i));
489    let mut grouped = String::new();
490    for (i, c) in int.chars().enumerate() {
491        if i > 0 && (int.len() - i) % 3 == 0 {
492            grouped.push(',');
493        }
494        grouped.push(c);
495    }
496    let frac = frac.trim_end_matches('0');
497    if frac.is_empty() {
498        format!("{sign}{grouped}")
499    } else {
500        format!("{sign}{grouped}.{frac}")
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    #[test]
509    fn to_fixed_rounds_the_exact_value_half_up_like_javascript() {
510        assert_eq!(to_fixed(0.125, 2), "0.13");
511        assert_eq!(to_fixed(1.005, 2), "1.00"); // 1.005 is 1.00499999… as a double
512        assert_eq!(to_fixed(2.5, 0), "3");
513        assert_eq!(to_fixed(-1.25, 1), "-1.3");
514        assert_eq!(to_fixed(-0.04, 1), "-0.0");
515        assert_eq!(to_fixed(9.996, 2), "10.00");
516        assert_eq!(to_fixed(3.0, 2), "3.00");
517    }
518
519    #[test]
520    fn to_locale_groups_thousands() {
521        assert_eq!(to_locale(1234567.0), "1,234,567");
522        assert_eq!(to_locale(999.0), "999");
523        assert_eq!(to_locale(1000.5), "1,000.5");
524    }
525
526    #[test]
527    fn json_matches_json_stringify() {
528        let v = Js::Obj(
529            Obj::new()
530                .with("b", 1.5)
531                .with("2", true)
532                .with("a", Js::Undefined)
533                .with("s", "x\"\n\u{1}")
534                .with(
535                    "1",
536                    Js::Arr(vec![Js::Undefined, Js::Num(f64::NAN), Js::Num(-0.0)]),
537                ),
538        );
539        assert_eq!(
540            to_json(&v),
541            r#"{"1":[null,null,0],"2":true,"b":1.5,"s":"x\"\n\u0001"}"#
542        );
543    }
544}