use alloc::string::String;
use alloc::vec::Vec;
use crate::error::{JsonError, JsonErrorKind};
use crate::value::{JsonMember, JsonValue};
use crate::write::write_escaped;
pub fn to_canonical_string(value: &JsonValue) -> Result<String, JsonError> {
let mut out = String::new();
write_canonical(&mut out, value)?;
Ok(out)
}
pub fn to_canonical_vec(value: &JsonValue) -> Result<Vec<u8>, JsonError> {
Ok(to_canonical_string(value)?.into_bytes())
}
fn write_canonical(out: &mut String, value: &JsonValue) -> Result<(), JsonError> {
match value {
JsonValue::Null => out.push_str("null"),
JsonValue::Bool(true) => out.push_str("true"),
JsonValue::Bool(false) => out.push_str("false"),
JsonValue::Number(number) => {
let x = match number.as_str().parse::<f64>() {
Ok(value) if value.is_finite() => value,
_ => return Err(JsonError::serialization(JsonErrorKind::NonFiniteNumber)),
};
out.push_str(&ecmascript_number_to_string(x));
}
JsonValue::String(string) => write_escaped(out, string),
JsonValue::Array(items) => {
out.push('[');
for (index, item) in items.iter().enumerate() {
if index > 0 {
out.push(',');
}
write_canonical(out, item)?;
}
out.push(']');
}
JsonValue::Object(object) => {
out.push('{');
let mut members: Vec<&JsonMember> = object.iter().collect();
members.sort_by(|a, b| a.key().encode_utf16().cmp(b.key().encode_utf16()));
for (index, member) in members.iter().enumerate() {
if index > 0 {
out.push(',');
}
write_escaped(out, member.key());
out.push(':');
write_canonical(out, member.value())?;
}
out.push('}');
}
}
Ok(())
}
fn ecmascript_number_to_string(x: f64) -> String {
if x == 0.0 {
return String::from("0");
}
let negative = x < 0.0;
let abs = if negative { -x } else { x };
let sci = alloc::format!("{abs:e}");
let (mantissa, exp_str) = match sci.split_once('e') {
Some(parts) => parts,
None => return sci, };
let exp: i32 = exp_str.parse().unwrap_or(0);
let mut digits = String::with_capacity(mantissa.len());
for c in mantissa.chars() {
if c != '.' {
digits.push(c);
}
}
let k = digits.len() as i32;
let n = exp + 1;
let mut out = String::new();
if negative {
out.push('-');
}
if k <= n && n <= 21 {
out.push_str(&digits);
for _ in 0..(n - k) {
out.push('0');
}
} else if 0 < n && n <= 21 {
out.push_str(&digits[..n as usize]);
out.push('.');
out.push_str(&digits[n as usize..]);
} else if -6 < n && n <= 0 {
out.push_str("0.");
for _ in 0..(-n) {
out.push('0');
}
out.push_str(&digits);
} else {
out.push_str(&digits[..1]);
if k > 1 {
out.push('.');
out.push_str(&digits[1..]);
}
out.push('e');
let e = n - 1;
out.push(if e >= 0 { '+' } else { '-' });
let magnitude = if e >= 0 { e } else { -e };
out.push_str(&alloc::format!("{magnitude}"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse_str;
#[test]
fn number_formatting_matches_ecmascript() {
let cases: &[(f64, &str)] = &[
(0.0, "0"),
(-0.0, "0"),
(1.0, "1"),
(-1.0, "-1"),
(1.5, "1.5"),
(-1.5, "-1.5"),
(0.5, "0.5"),
(0.05, "0.05"),
(10.0, "10"),
(100.0, "100"),
(123.456, "123.456"),
(1234.5, "1234.5"),
(123456789.0, "123456789"),
(0.1, "0.1"),
(1e-5, "0.00001"),
(0.0001, "0.0001"),
(1e-6, "0.000001"),
(1e-7, "1e-7"),
(1e20, "100000000000000000000"),
(1e21, "1e+21"),
(1e22, "1e+22"),
(9007199254740992.0, "9007199254740992"),
(5e-324, "5e-324"),
(1.7976931348623157e308, "1.7976931348623157e+308"),
(2.220446049250313e-16, "2.220446049250313e-16"),
];
for &(input, expected) in cases {
assert_eq!(
ecmascript_number_to_string(input),
expected,
"for {input:?}"
);
}
}
#[test]
fn rfc8785_number_examples() {
let cases: &[(&str, &str)] = &[
("333333333.33333329", "333333333.3333333"),
("1E30", "1e+30"),
("4.50", "4.5"),
("2e-3", "0.002"),
("0.000000000000000000000000001", "1e-27"),
("-0", "0"),
];
for &(input, expected) in cases {
let value = parse_str(input).unwrap();
assert_eq!(
to_canonical_string(&value).unwrap(),
expected,
"for {input}"
);
}
}
#[test]
fn numbers_round_trip_and_never_panic() {
let mut state: u64 = 0x9E3779B97F4A7C15;
let mut next = || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
let mut checked: u64 = 0;
for _ in 0..300_000 {
let x = f64::from_bits(next());
if !x.is_finite() {
continue;
}
let s = ecmascript_number_to_string(x);
if x == 0.0 {
assert_eq!(s, "0");
} else {
let back: f64 = s.parse().expect("canonical number must reparse");
assert_eq!(
back.to_bits(),
x.to_bits(),
"round-trip failed: {x:?} -> {s}"
);
assert_eq!(ecmascript_number_to_string(back), s);
}
checked += 1;
}
assert!(checked > 100_000, "expected a large finite sample");
}
#[test]
fn canonicalizes_numbers_from_json() {
let value = parse_str("[1.0, 1.50, 100, 0.1, 1e2]").unwrap();
assert_eq!(to_canonical_string(&value).unwrap(), "[1,1.5,100,0.1,100]");
}
#[test]
fn sorts_object_keys_by_utf16_code_units() {
let value = parse_str("{\"\u{FB00}\":1,\"\u{1F600}\":2,\"a\":3}").unwrap();
let canonical = to_canonical_string(&value).unwrap();
assert_eq!(canonical, "{\"a\":3,\"\u{1F600}\":2,\"\u{FB00}\":1}");
}
#[test]
fn strips_whitespace_and_sorts_nested() {
let value = parse_str(r#"{ "b" : [ 3 , 2 ] , "a" : { "y" : 1 , "x" : 2 } }"#).unwrap();
assert_eq!(
to_canonical_string(&value).unwrap(),
r#"{"a":{"x":2,"y":1},"b":[3,2]}"#
);
}
#[test]
fn string_escaping_is_minimal() {
let value = parse_str("{\"k\":\"a\\tb\\nā¬\"}").unwrap();
assert_eq!(
to_canonical_string(&value).unwrap(),
"{\"k\":\"a\\tb\\nā¬\"}"
);
}
#[test]
fn is_idempotent() {
let value = parse_str(r#"{"b":1,"a":1.0,"c":[2.50,3]}"#).unwrap();
let once = to_canonical_string(&value).unwrap();
let reparsed = parse_str(&once).unwrap();
assert_eq!(to_canonical_string(&reparsed).unwrap(), once);
assert_eq!(once, r#"{"a":1,"b":1,"c":[2.5,3]}"#);
}
#[test]
fn to_vec_matches_string() {
let value = parse_str(r#"{"a":1}"#).unwrap();
let s = to_canonical_string(&value).unwrap();
assert_eq!(to_canonical_vec(&value).unwrap(), s.into_bytes());
}
#[test]
fn rejects_numbers_that_overflow_f64() {
let value = parse_str("1e400").unwrap(); let err = to_canonical_string(&value).unwrap_err();
assert_eq!(err.kind(), &JsonErrorKind::NonFiniteNumber);
let shown = alloc::format!("{err}");
assert_eq!(shown, "number is not representable as a finite f64");
assert!(!shown.contains("byte"));
}
}