flybrain/services/flysim/crates/flybrain-core/src/json.rs
acamilo 660c3cf00d
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
flybrain v0.4.0: public tree (history retained privately)
2026-09-21 15:09:46 +00:00

287 lines
8.7 KiB
Rust

//! `JSON.parse` / `JSON.stringify` with JavaScript semantics.
//!
//! Two places need byte-exact agreement with V8 rather than merely valid JSON:
//!
//! - the dataset fingerprint hashes `JSON.stringify(meta)`, so key order, number formatting and
//! string escaping all have to match;
//! - a checkpoint envelope's manifest is hashed by the envelope's own CRC32, so the same applies.
//!
//! `serde_json` would print `1.0` where `JSON.stringify` prints `1`, so values are built and
//! written through [`JsonValue`] here. Parsing goes through `serde_json` with `preserve_order` and
//! is then converted, which reproduces `JSON.parse` (every number becomes an `f64`, object key
//! order is insertion order).
use std::fmt::Write as _;
use crate::error::{Error, Result};
use crate::jsmath::number_to_string;
/// A JavaScript JSON value. Objects keep insertion order.
#[derive(Debug, Clone, PartialEq)]
pub enum JsonValue {
Null,
Bool(bool),
Number(f64),
String(String),
Array(Vec<JsonValue>),
Object(Vec<(String, JsonValue)>),
}
impl JsonValue {
pub fn object() -> Self {
JsonValue::Object(Vec::new())
}
/// `object[key] = value`, with JavaScript's rule that an existing key keeps its position.
pub fn set(&mut self, key: &str, value: JsonValue) {
if let JsonValue::Object(entries) = self {
if let Some(slot) = entries.iter_mut().find(|(name, _)| name == key) {
slot.1 = value;
} else {
entries.push((key.to_string(), value));
}
}
}
pub fn get(&self, key: &str) -> Option<&JsonValue> {
match self {
JsonValue::Object(entries) => entries
.iter()
.find(|(name, _)| name == key)
.map(|(_, value)| value),
_ => None,
}
}
pub fn as_f64(&self) -> Option<f64> {
match self {
JsonValue::Number(value) => Some(*value),
_ => None,
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
JsonValue::String(value) => Some(value),
_ => None,
}
}
pub fn as_bool(&self) -> Option<bool> {
match self {
JsonValue::Bool(value) => Some(*value),
_ => None,
}
}
pub fn as_array(&self) -> Option<&[JsonValue]> {
match self {
JsonValue::Array(items) => Some(items),
_ => None,
}
}
pub fn as_object(&self) -> Option<&[(String, JsonValue)]> {
match self {
JsonValue::Object(entries) => Some(entries),
_ => None,
}
}
pub fn is_null(&self) -> bool {
matches!(self, JsonValue::Null)
}
/// A non-negative integer field, as `Number.isSafeInteger` would accept it.
pub fn as_usize(&self) -> Option<usize> {
let value = self.as_f64()?;
if value.fract() != 0.0 || !(0.0..=9_007_199_254_740_991.0).contains(&value) {
return None;
}
Some(value as usize)
}
/// `JSON.stringify(value)`.
pub fn stringify(&self) -> String {
let mut out = String::new();
self.write(&mut out);
out
}
fn write(&self, out: &mut String) {
match self {
JsonValue::Null => out.push_str("null"),
JsonValue::Bool(true) => out.push_str("true"),
JsonValue::Bool(false) => out.push_str("false"),
// JSON.stringify writes a non-finite number as null.
JsonValue::Number(value) if !value.is_finite() => out.push_str("null"),
JsonValue::Number(value) => out.push_str(&number_to_string(*value)),
JsonValue::String(value) => write_quoted(value, out),
JsonValue::Array(items) => {
out.push('[');
for (index, item) in items.iter().enumerate() {
if index > 0 {
out.push(',');
}
item.write(out);
}
out.push(']');
}
JsonValue::Object(entries) => {
out.push('{');
for (index, (key, value)) in entries.iter().enumerate() {
if index > 0 {
out.push(',');
}
write_quoted(key, out);
out.push(':');
value.write(out);
}
out.push('}');
}
}
}
/// `JSON.parse(text)`.
pub fn parse(text: &str) -> Result<JsonValue> {
let parsed: serde_json::Value = serde_json::from_str(text)
.map_err(|error| Error::new(format!("Unexpected token in JSON: {error}")))?;
Ok(JsonValue::from_serde(&parsed))
}
fn from_serde(value: &serde_json::Value) -> JsonValue {
match value {
serde_json::Value::Null => JsonValue::Null,
serde_json::Value::Bool(flag) => JsonValue::Bool(*flag),
// JSON.parse yields a double for every numeric literal.
serde_json::Value::Number(number) => {
JsonValue::Number(number.as_f64().unwrap_or(f64::NAN))
}
serde_json::Value::String(text) => JsonValue::String(text.clone()),
serde_json::Value::Array(items) => {
JsonValue::Array(items.iter().map(JsonValue::from_serde).collect())
}
serde_json::Value::Object(entries) => JsonValue::Object(
entries
.iter()
.map(|(key, value)| (key.clone(), JsonValue::from_serde(value)))
.collect(),
),
}
}
}
impl From<f64> for JsonValue {
fn from(value: f64) -> Self {
JsonValue::Number(value)
}
}
impl From<bool> for JsonValue {
fn from(value: bool) -> Self {
JsonValue::Bool(value)
}
}
impl From<&str> for JsonValue {
fn from(value: &str) -> Self {
JsonValue::String(value.to_string())
}
}
impl From<String> for JsonValue {
fn from(value: String) -> Self {
JsonValue::String(value)
}
}
impl From<usize> for JsonValue {
fn from(value: usize) -> Self {
JsonValue::Number(value as f64)
}
}
impl From<u32> for JsonValue {
fn from(value: u32) -> Self {
JsonValue::Number(f64::from(value))
}
}
impl From<i32> for JsonValue {
fn from(value: i32) -> Self {
JsonValue::Number(f64::from(value))
}
}
/// `QuoteJSONString`: escape `"`, `\` and the C0 controls, and pass everything else through.
///
/// A Rust `str` is well-formed UTF-8 and so holds no lone surrogates, which is the only case where
/// `JSON.stringify` emits a `\uXXXX` escape for a non-control character. Non-BMP scalars are
/// emitted verbatim, exactly as V8 does.
fn write_quoted(text: &str, out: &mut String) {
out.push('"');
for character in text.chars() {
match character {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\u{8}' => out.push_str("\\b"),
'\t' => out.push_str("\\t"),
'\n' => out.push_str("\\n"),
'\u{c}' => out.push_str("\\f"),
'\r' => out.push_str("\\r"),
character if (character as u32) < 0x20 => {
let _ = write!(out, "\\u{:04x}", character as u32);
}
character => out.push(character),
}
}
out.push('"');
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stringify_prints_integers_without_a_fraction() {
let mut object = JsonValue::object();
object.set("schemaVersion", 1.0.into());
object.set("dataset", "test".into());
object.set("gain", 0.2.into());
object.set(
"roles",
JsonValue::Array(vec![0.0.into(), 1.0.into(), 2.0.into()]),
);
assert_eq!(
object.stringify(),
r#"{"schemaVersion":1,"dataset":"test","gain":0.2,"roles":[0,1,2]}"#
);
}
#[test]
fn set_keeps_the_position_of_an_existing_key() {
let mut object = JsonValue::object();
object.set("a", 1.0.into());
object.set("b", 2.0.into());
object.set("a", 3.0.into());
assert_eq!(object.stringify(), r#"{"a":3,"b":2}"#);
}
#[test]
fn round_trips_through_parse() {
let text = r#"{"a":[1,2.5,-3],"b":{"c":"x\ny"},"d":null,"e":true}"#;
assert_eq!(JsonValue::parse(text).unwrap().stringify(), text);
}
#[test]
fn escapes_the_same_characters_as_json_stringify() {
assert_eq!(
JsonValue::String("a\"b\\c\nd\u{1}".to_string()).stringify(),
"\"a\\\"b\\\\c\\nd\\u0001\""
);
assert_eq!(
JsonValue::String("héllo".to_string()).stringify(),
"\"héllo\""
);
}
}