use serde_json::{Map, Value};
pub const MAX_SAFE_INT: i64 = (1 << 53) - 1;
pub fn is_decimal(s: &str) -> bool {
let b = s.as_bytes();
let mut i = usize::from(b.first() == Some(&b'-'));
let digits = |i: &mut usize| {
let start = *i;
while b.get(*i).is_some_and(u8::is_ascii_digit) {
*i += 1;
}
*i > start
};
if !digits(&mut i) {
return false;
}
if b.get(i) == Some(&b'.') {
i += 1;
if !digits(&mut i) {
return false;
}
}
if matches!(b.get(i), Some(b'e' | b'E')) {
i += 1;
if matches!(b.get(i), Some(b'+' | b'-')) {
i += 1;
}
if !digits(&mut i) {
return false;
}
}
i == b.len()
}
fn safe_int(v: &Value) -> bool {
v.as_i64()
.is_some_and(|n| (-MAX_SAFE_INT..=MAX_SAFE_INT).contains(&n))
}
pub fn is_num(v: &Value) -> bool {
safe_int(v) || v.as_str().is_some_and(is_decimal)
}
pub fn canonicalize(v: &mut Value, path: &str) -> Result<(), String> {
match v {
Value::Number(n) => {
if n.is_i64() || n.is_u64() {
let ok = n
.as_i64()
.is_some_and(|i| (-MAX_SAFE_INT..=MAX_SAFE_INT).contains(&i));
if !ok {
return Err(format!(
"{path}: the integer {n} is outside ±{MAX_SAFE_INT} \
(send it as a decimal string)"
));
}
return Ok(());
}
let f = n.as_f64().unwrap_or(f64::NAN);
if !f.is_finite() {
return Err(format!("{path}: {f} isn't a finite number"));
}
*v = if f.fract() == 0.0 && f.abs() <= MAX_SAFE_INT as f64 {
Value::from(f as i64)
} else {
Value::String(format!("{f}"))
};
Ok(())
}
Value::Array(a) => a
.iter_mut()
.enumerate()
.try_for_each(|(i, x)| canonicalize(x, &format!("{path}[{i}]"))),
Value::Object(m) => canonicalize_map(m, path),
_ => Ok(()),
}
}
pub fn canonicalize_map(m: &mut Map<String, Value>, path: &str) -> Result<(), String> {
m.iter_mut()
.try_for_each(|(k, x)| canonicalize(x, &format!("{path}.{k}")))
}
pub fn non_canonical(v: &Value, path: &str) -> Option<String> {
match v {
Value::Number(_) if !safe_int(v) => Some(path.to_owned()),
Value::Array(a) => a
.iter()
.enumerate()
.find_map(|(i, x)| non_canonical(x, &format!("{path}[{i}]"))),
Value::Object(m) => non_canonical_map(m, path),
_ => None,
}
}
pub fn non_canonical_map(m: &Map<String, Value>, path: &str) -> Option<String> {
m.iter()
.find_map(|(k, x)| non_canonical(x, &format!("{path}.{k}")))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn decimal_strings() {
for ok in ["0", "-1", "62.99", "1e3", "-2.5E-4", "007"] {
assert!(is_decimal(ok), "{ok}");
}
for bad in [
"", "-", ".5", "5.", "1e", "1e+", "+1", " 1", "1 ", "NaN", "1,000", "0x10",
] {
assert!(!is_decimal(bad), "{bad:?}");
}
assert!(is_num(&json!(5)) && is_num(&json!("5.5")) && is_num(&json!(-MAX_SAFE_INT)));
assert!(!is_num(&json!(5.5)) && !is_num(&json!(MAX_SAFE_INT + 1)) && !is_num(&json!("x")));
}
#[test]
fn floats_become_decimal_strings() {
let mut v = json!({"price": 62.99, "n": 3.0, "neg": -0.5, "tiny": 1e-7, "big": 1e300,
"i": 7, "list": [1.5, null, 2], "s": "x"});
canonicalize(&mut v, "b").unwrap();
assert_eq!(
v,
json!({"price": "62.99", "n": 3, "neg": "-0.5", "tiny": "0.0000001",
"big": format!("{}", 1e300), "i": 7, "list": ["1.5", null, 2], "s": "x"})
);
assert!(is_decimal(v["big"].as_str().unwrap()));
assert_eq!(non_canonical(&v, "b"), None);
}
#[test]
fn out_of_range_integers_are_refused() {
let mut v = json!({"a": [u64::MAX]});
let e = canonicalize(&mut v, "b").unwrap_err();
assert!(e.contains("b.a[0]"), "{e}");
assert_eq!(
non_canonical(&json!({"x": [1, 2.5]}), "b").as_deref(),
Some("b.x[1]")
);
}
}