yapi-types 0.1.0

Serde types for pi's JSON formats: messages, events, session entries, settings, models and extension payloads.
Documentation
//! JSON output that matches pi's `JSON.stringify`.
//!
//! serde_json and JavaScript escape strings the same way but print numbers
//! differently: JavaScript writes `0.000003` and `5` where serde_json writes
//! `3e-6` and `5.0`. These functions print numbers the JavaScript way, so a
//! document parsed from a pi file serializes back to the same bytes.

use std::io;

use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::ser::{CompactFormatter, Formatter, PrettyFormatter, Serializer};
use serde_json::{Map, Value};

/// Serializes `value` like `JSON.stringify(value)`.
pub fn to_string<T: Serialize + ?Sized>(value: &T) -> serde_json::Result<String> {
    write(value, JsFormatter(CompactFormatter))
}

/// A JSON document in memory, whose serialization cannot fail.
pub trait JsonValue: Serialize {}

impl JsonValue for Value {}

impl JsonValue for Map<String, Value> {}

/// Serializes a JSON value like `JSON.stringify(value)`.
pub fn stringify(value: &impl JsonValue) -> String {
    // Object keys are strings and numbers are finite, so serialization succeeds.
    to_string(value).unwrap_or_default()
}

/// A JavaScript number as JSON: an integer when it is a safe one, so it
/// compares equal to the same number parsed from a pi file.
pub fn number(value: f64) -> Value {
    if value.fract() == 0.0 && value.abs() <= MAX_SAFE_INTEGER as f64 {
        Value::from(value as i64)
    } else {
        serde_json::Number::from_f64(value).map_or(Value::Null, Value::Number)
    }
}

/// JavaScript's `Number(text)`: surrounding whitespace ignored, `0` for
/// empty text, unsigned `0x`, `0o` and `0b` integers, `Infinity` with an
/// optional sign, else a decimal literal, and `NaN` for anything else.
pub fn js_number(text: &str) -> f64 {
    let text = text.trim_matches(|c: char| c.is_whitespace() || c == '\u{feff}');
    if text.is_empty() {
        return 0.0;
    }
    for (prefix, radix) in [("0x", 16), ("0o", 8), ("0b", 2)] {
        if let Some(digits) = text
            .get(..2)
            .filter(|start| start.eq_ignore_ascii_case(prefix))
            .and_then(|_| text.get(2..))
        {
            let value = digits.chars().try_fold(0.0_f64, |value, c| {
                c.to_digit(radix)
                    .map(|digit| value * f64::from(radix) + f64::from(digit))
            });
            return value.filter(|_| !digits.is_empty()).unwrap_or(f64::NAN);
        }
    }
    match text {
        "Infinity" | "+Infinity" => return f64::INFINITY,
        "-Infinity" => return f64::NEG_INFINITY,
        _ => {}
    }
    // Rust also reads `inf` and `NaN`, which JavaScript does not.
    if !text
        .bytes()
        .all(|byte| byte.is_ascii_digit() || b"+-.eE".contains(&byte))
    {
        return f64::NAN;
    }
    text.parse().unwrap_or(f64::NAN)
}

/// Serializes `value` like `JSON.stringify(value, null, indent)`.
pub fn to_string_pretty<T: Serialize + ?Sized>(
    value: &T,
    indent: &str,
) -> serde_json::Result<String> {
    write(
        value,
        JsFormatter(PrettyFormatter::with_indent(indent.as_bytes())),
    )
}

fn write<T: Serialize + ?Sized>(
    value: &T,
    formatter: impl Formatter,
) -> serde_json::Result<String> {
    let mut out = Vec::new();
    value.serialize(&mut Serializer::with_formatter(&mut out, formatter))?;
    // serde_json only writes UTF-8: string contents come from `str` and escapes are ASCII.
    Ok(String::from_utf8(out).expect("serde_json writes UTF-8"))
}

/// Wraps a serde_json formatter, keeping its layout and printing numbers with
/// ECMAScript `Number.prototype.toString`.
struct JsFormatter<F>(F);

/// The largest integer a JavaScript number holds exactly.
const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1;

impl<F: Formatter> Formatter for JsFormatter<F> {
    fn write_f64<W: ?Sized + io::Write>(&mut self, writer: &mut W, value: f64) -> io::Result<()> {
        // serde_json writes `null` for non-finite values before reaching this point.
        writer.write_all(ryu_js::Buffer::new().format(value).as_bytes())
    }

    // Integers beyond what a double holds exactly are rounded, as
    // `JSON.parse` then `JSON.stringify` round them.
    fn write_i64<W: ?Sized + io::Write>(&mut self, writer: &mut W, value: i64) -> io::Result<()> {
        if value.unsigned_abs() > MAX_SAFE_INTEGER {
            return self.write_f64(writer, value as f64);
        }
        self.0.write_i64(writer, value)
    }

    fn write_u64<W: ?Sized + io::Write>(&mut self, writer: &mut W, value: u64) -> io::Result<()> {
        if value > MAX_SAFE_INTEGER {
            return self.write_f64(writer, value as f64);
        }
        self.0.write_u64(writer, value)
    }

    fn begin_array<W: ?Sized + io::Write>(&mut self, writer: &mut W) -> io::Result<()> {
        self.0.begin_array(writer)
    }

    fn end_array<W: ?Sized + io::Write>(&mut self, writer: &mut W) -> io::Result<()> {
        self.0.end_array(writer)
    }

    fn begin_array_value<W: ?Sized + io::Write>(
        &mut self,
        writer: &mut W,
        first: bool,
    ) -> io::Result<()> {
        self.0.begin_array_value(writer, first)
    }

    fn end_array_value<W: ?Sized + io::Write>(&mut self, writer: &mut W) -> io::Result<()> {
        self.0.end_array_value(writer)
    }

    fn begin_object<W: ?Sized + io::Write>(&mut self, writer: &mut W) -> io::Result<()> {
        self.0.begin_object(writer)
    }

    fn end_object<W: ?Sized + io::Write>(&mut self, writer: &mut W) -> io::Result<()> {
        self.0.end_object(writer)
    }

    fn begin_object_key<W: ?Sized + io::Write>(
        &mut self,
        writer: &mut W,
        first: bool,
    ) -> io::Result<()> {
        self.0.begin_object_key(writer, first)
    }

    fn end_object_key<W: ?Sized + io::Write>(&mut self, writer: &mut W) -> io::Result<()> {
        self.0.end_object_key(writer)
    }

    fn begin_object_value<W: ?Sized + io::Write>(&mut self, writer: &mut W) -> io::Result<()> {
        self.0.begin_object_value(writer)
    }

    fn end_object_value<W: ?Sized + io::Write>(&mut self, writer: &mut W) -> io::Result<()> {
        self.0.end_object_value(writer)
    }
}

/// Parses `text` like pi's `JSON.parse(stripBom(text))`: a leading byte
/// order mark is ignored.
pub fn parse<T: DeserializeOwned>(text: &str) -> serde_json::Result<T> {
    serde_json::from_str(text.strip_prefix('\u{feff}').unwrap_or(text))
}

#[cfg(test)]
mod tests {
    use serde_json::{Value, json};

    use super::*;

    #[test]
    fn reads_numbers_like_javascript() {
        let number = |text| super::number(js_number(text));
        assert_eq!(number("8080"), json!(8080));
        assert_eq!(number(" 1.5e1 "), json!(15));
        assert_eq!(number(""), json!(0));
        assert_eq!(number("x"), Value::Null);
        assert_eq!(number("0x1F90"), json!(8080));
        assert_eq!(number(" 0o17 "), json!(15));
        assert_eq!(number("0B101"), json!(5));
        for nan in ["0x", "-0x10", "0x+1", "inf", "NaN", "1e", "1_000"] {
            assert!(js_number(nan).is_nan(), "{nan}");
        }
        assert_eq!(js_number("-Infinity"), f64::NEG_INFINITY);
        assert_eq!(number("Infinity"), Value::Null);
    }

    #[test]
    fn parses_past_a_byte_order_mark() {
        assert_eq!(
            parse::<Value>("\u{feff}{\"a\":1}").ok(),
            Some(json!({"a": 1}))
        );
        assert_eq!(parse::<Value>("[]").ok(), Some(json!([])));
        assert!(parse::<Value>("\u{feff}\u{feff}[]").is_err());
    }

    #[test]
    fn numbers_match_javascript() {
        // Expected strings are `String(n)` in JavaScript.
        let cases: [(f64, &str); 12] = [
            (0.0, "0"),
            (-0.0, "0"),
            (5.0, "5"),
            (-42.0, "-42"),
            (0.000003, "0.000003"),
            (1e-7, "1e-7"),
            (1e21, "1e+21"),
            (0.1 + 0.2, "0.30000000000000004"),
            (9007199254740992.0, "9007199254740992"),
            (123.456, "123.456"),
            (5e-324, "5e-324"),
            (f64::MAX, "1.7976931348623157e+308"),
        ];
        for (value, expected) in cases {
            assert_eq!(to_string(&value).unwrap(), expected, "{value:e}");
        }
    }

    #[test]
    fn large_integers_round_like_javascript() {
        // `JSON.stringify(JSON.parse(text))` in JavaScript.
        let value: Value = serde_json::from_str(
            "[9007199254740991,9007199254740993,12345678901234567890,-12345678901234567]",
        )
        .unwrap();
        assert_eq!(
            to_string(&value).unwrap(),
            "[9007199254740991,9007199254740992,12345678901234567000,-12345678901234568]"
        );
    }

    #[test]
    fn parsed_numbers_round_trip() {
        let text = r#"[0,0.000003,0.0037020000000000004,1e+21,1893456000000,-1.5e-7]"#;
        let value: Value = serde_json::from_str(text).unwrap();
        assert_eq!(to_string(&value).unwrap(), text);
    }

    #[test]
    fn pretty_matches_javascript_layout() {
        let value = json!({ "a": [], "b": {}, "c": [1, { "d": null }] });
        let expected = "{\n  \"a\": [],\n  \"b\": {},\n  \"c\": [\n    1,\n    {\n      \"d\": null\n    }\n  ]\n}";
        assert_eq!(to_string_pretty(&value, "  ").unwrap(), expected);
    }

    #[test]
    fn non_finite_numbers_are_null() {
        assert_eq!(
            to_string(&[f64::NAN, f64::INFINITY]).unwrap(),
            "[null,null]"
        );
    }
}