Add float formatting functions and update serializer for improved precision

- Introduced `trim_float`, `format_f32`, and `format_f64` functions to handle float formatting, ensuring consistent representation of numeric values.
- Updated the `serialize_f32` and `serialize_f64` methods in `CompactJson5Serializer` and `Json5ValueSerializer` to utilize the new formatting functions, enhancing output accuracy.
- Adjusted the handling of float values in `UnresolvedValue` to improve data consistency and precision during serialization.
- Added unit tests for the new formatting functions to validate their behavior and ensure correctness.

These changes enhance the precision of float serialization in JSON5, addressing previous inconsistencies and improving overall data integrity.
This commit is contained in:
Joe 2026-02-18 09:45:44 -06:00
parent 39c8bd41f4
commit 3c026aea40
2 changed files with 92 additions and 120 deletions

View file

@ -126,6 +126,24 @@ pub fn from_slice_with_context<T: DeserializeOwned>(
from_str_with_context(text, context) from_str_with_context(text, context)
} }
fn trim_float(s: &str) -> String {
if !s.contains('.') { return s.to_string(); }
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".to_string() } else { s.to_string() }
}
fn format_f32(v: f32) -> String {
if v.is_nan() { return "NaN".to_string(); }
if v.is_infinite() { return if v > 0.0 { "Infinity" } else { "-Infinity" }.to_string(); }
trim_float(&format!("{:.6}", v))
}
fn format_f64(v: f64) -> String {
if v.is_nan() { return "NaN".to_string(); }
if v.is_infinite() { return if v > 0.0 { "Infinity" } else { "-Infinity" }.to_string(); }
trim_float(&format!("{:.15}", v))
}
/// A JSON5 value that uses BTreeMap for sorted keys and supports NaN/Infinity. /// A JSON5 value that uses BTreeMap for sorted keys and supports NaN/Infinity.
/// Uses String for numbers to preserve exact representation (including scientific notation). /// Uses String for numbers to preserve exact representation (including scientific notation).
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@ -154,7 +172,7 @@ impl Json5Value {
Json5Value::Array(arr) => { Json5Value::Array(arr) => {
if arr.is_empty() { if arr.is_empty() {
output.push_str("[]"); output.push_str("[]");
} else if arr.len() < 12 } else if arr.len() <= 20
&& arr.iter().all(|v| { && arr.iter().all(|v| {
matches!( matches!(
v, v,
@ -360,27 +378,12 @@ impl<'a> serde::Serializer for &mut CompactJson5Serializer<'a> {
} }
fn serialize_f32(self, v: f32) -> Result<(), Self::Error> { fn serialize_f32(self, v: f32) -> Result<(), Self::Error> {
self.serialize_f64(v as f64) self.output.push_str(&format_f32(v));
Ok(())
} }
fn serialize_f64(self, v: f64) -> Result<(), Self::Error> { fn serialize_f64(self, v: f64) -> Result<(), Self::Error> {
if v.is_nan() { self.output.push_str(&format_f64(v));
self.output.push_str("NaN");
} else if v.is_infinite() {
if v.is_sign_positive() {
self.output.push_str("Infinity");
} else {
self.output.push_str("-Infinity");
}
} else {
// Use scientific notation for very large or very small numbers
let abs = v.abs();
if abs != 0.0 && !(1e-6..1e15).contains(&abs) {
self.output.push_str(&format!("{:e}", v));
} else {
self.output.push_str(&v.to_string());
}
}
Ok(()) Ok(())
} }
@ -708,29 +711,11 @@ impl serde::Serializer for Json5ValueSerializer {
} }
fn serialize_f32(self, v: f32) -> Result<Self::Ok, Self::Error> { fn serialize_f32(self, v: f32) -> Result<Self::Ok, Self::Error> {
self.serialize_f64(v as f64) Ok(Json5Value::Number(format_f32(v)))
} }
fn serialize_f64(self, v: f64) -> Result<Self::Ok, Self::Error> { fn serialize_f64(self, v: f64) -> Result<Self::Ok, Self::Error> {
let s = if v.is_nan() { Ok(Json5Value::Number(format_f64(v)))
"NaN".to_string()
} else if v.is_infinite() {
if v.is_sign_positive() {
"Infinity".to_string()
} else {
"-Infinity".to_string()
}
} else {
// Use scientific notation for very large or very small numbers
// to ensure they can be parsed back by json5
let abs = v.abs();
if abs != 0.0 && !(1e-6..1e15).contains(&abs) {
format!("{:e}", v)
} else {
v.to_string()
}
};
Ok(Json5Value::Number(s))
} }
fn serialize_char(self, v: char) -> Result<Self::Ok, Self::Error> { fn serialize_char(self, v: char) -> Result<Self::Ok, Self::Error> {
@ -1029,6 +1014,45 @@ mod tests {
use super::*; use super::*;
use serde::Deserialize; use serde::Deserialize;
#[test]
fn test_trim_float() {
assert_eq!(trim_float("6.670000"), "6.67");
assert_eq!(trim_float("6.000000"), "6");
assert_eq!(trim_float("-0.000000"), "0");
assert_eq!(trim_float("0.000000"), "0");
assert_eq!(trim_float("1.500000"), "1.5");
assert_eq!(trim_float("-3.140000"), "-3.14");
assert_eq!(trim_float("100"), "100");
}
#[test]
fn test_format_f32() {
assert_eq!(format_f32(6.67), "6.67");
assert_eq!(format_f32(6.0), "6");
assert_eq!(format_f32(0.0), "0");
assert_eq!(format_f32(-0.0), "0");
assert_eq!(format_f32(0.5), "0.5");
assert_eq!(format_f32(-3.14), "-3.14");
assert_eq!(format_f32(1.0 / 3.0), "0.333333");
assert_eq!(format_f32(f32::NAN), "NaN");
assert_eq!(format_f32(f32::INFINITY), "Infinity");
assert_eq!(format_f32(f32::NEG_INFINITY), "-Infinity");
}
#[test]
fn test_format_f64() {
assert_eq!(format_f64(6.67), "6.67");
assert_eq!(format_f64(6.0), "6");
assert_eq!(format_f64(0.0), "0");
assert_eq!(format_f64(-0.0), "0");
assert_eq!(format_f64(0.5), "0.5");
assert_eq!(format_f64(-3.14), "-3.14");
assert_eq!(format_f64(1.0 / 3.0), "0.333333333333333");
assert_eq!(format_f64(f64::NAN), "NaN");
assert_eq!(format_f64(f64::INFINITY), "Infinity");
assert_eq!(format_f64(f64::NEG_INFINITY), "-Infinity");
}
#[test] #[test]
fn test_parse_value() { fn test_parse_value() {
let value = parse_value(r#"{"foo": "bar"}"#).unwrap(); let value = parse_value(r#"{"foo": "bar"}"#).unwrap();

View file

@ -10,65 +10,6 @@ use serde::{Deserialize, Serialize};
use crate::REF_PATH_ATTRIBUTE_PREFIX; use crate::REF_PATH_ATTRIBUTE_PREFIX;
/// Rounds float components of Variant types to 6 decimal places.
/// Uses format-and-reparse for exact decimal representation in json5 output.
fn truncate_variant(v: Variant) -> Variant {
fn t(v: f32) -> f32 {
format!("{:.6}", v).parse::<f32>().unwrap_or(v)
}
match v {
Variant::CFrame(cf) => Variant::CFrame(CFrame {
position: Vector3::new(t(cf.position.x), t(cf.position.y), t(cf.position.z)),
orientation: Matrix3::new(
Vector3::new(
t(cf.orientation.x.x),
t(cf.orientation.x.y),
t(cf.orientation.x.z),
),
Vector3::new(
t(cf.orientation.y.x),
t(cf.orientation.y.y),
t(cf.orientation.y.z),
),
Vector3::new(
t(cf.orientation.z.x),
t(cf.orientation.z.y),
t(cf.orientation.z.z),
),
),
}),
Variant::OptionalCFrame(Some(cf)) => Variant::OptionalCFrame(Some(CFrame {
position: Vector3::new(t(cf.position.x), t(cf.position.y), t(cf.position.z)),
orientation: Matrix3::new(
Vector3::new(
t(cf.orientation.x.x),
t(cf.orientation.x.y),
t(cf.orientation.x.z),
),
Vector3::new(
t(cf.orientation.y.x),
t(cf.orientation.y.y),
t(cf.orientation.y.z),
),
Vector3::new(
t(cf.orientation.z.x),
t(cf.orientation.z.y),
t(cf.orientation.z.z),
),
),
})),
Variant::Vector3(v) => Variant::Vector3(Vector3::new(t(v.x), t(v.y), t(v.z))),
Variant::Vector2(v) => Variant::Vector2(Vector2::new(t(v.x), t(v.y))),
Variant::UDim(u) => Variant::UDim(rbx_dom_weak::types::UDim::new(t(u.scale), u.offset)),
Variant::UDim2(u) => Variant::UDim2(rbx_dom_weak::types::UDim2::new(
rbx_dom_weak::types::UDim::new(t(u.x.scale), u.x.offset),
rbx_dom_weak::types::UDim::new(t(u.y.scale), u.y.offset),
)),
other => other,
}
}
/// A user-friendly version of `Variant` that supports specifying ambiguous /// A user-friendly version of `Variant` that supports specifying ambiguous
/// values. Ambiguous values need a reflection database to be resolved to a /// values. Ambiguous values need a reflection database to be resolved to a
/// usable value. /// usable value.
@ -100,7 +41,6 @@ impl UnresolvedValue {
/// Creates an `UnresolvedValue` from a variant, using a class and property /// Creates an `UnresolvedValue` from a variant, using a class and property
/// name to potentially allow for ambiguous Enum variants. /// name to potentially allow for ambiguous Enum variants.
pub fn from_variant(variant: Variant, class_name: &str, prop_name: &str) -> Self { pub fn from_variant(variant: Variant, class_name: &str, prop_name: &str) -> Self {
let variant = truncate_variant(variant);
let descriptor = find_descriptor(class_name, prop_name); let descriptor = find_descriptor(class_name, prop_name);
if descriptor.is_some() { if descriptor.is_some() {
// We can only use an ambiguous syntax if the property is known // We can only use an ambiguous syntax if the property is known
@ -124,7 +64,7 @@ impl UnresolvedValue {
return Self::FullyQualified(variant); return Self::FullyQualified(variant);
} }
Variant::Bool(bool) => AmbiguousValue::Bool(bool), Variant::Bool(bool) => AmbiguousValue::Bool(bool),
Variant::Float32(n) => AmbiguousValue::Number(n as f64), Variant::Float32(n) => AmbiguousValue::Number32(n),
Variant::Float64(n) => AmbiguousValue::Number(n), Variant::Float64(n) => AmbiguousValue::Number(n),
Variant::Int32(n) => AmbiguousValue::Number(n as f64), Variant::Int32(n) => AmbiguousValue::Number(n as f64),
Variant::Int64(n) => AmbiguousValue::Number(n as f64), Variant::Int64(n) => AmbiguousValue::Number(n as f64),
@ -139,27 +79,27 @@ impl UnresolvedValue {
}, },
Variant::ContentId(content) => AmbiguousValue::String(content.into_string()), Variant::ContentId(content) => AmbiguousValue::String(content.into_string()),
Variant::Vector2(vector) => { Variant::Vector2(vector) => {
AmbiguousValue::Array2([vector.x as f64, vector.y as f64]) AmbiguousValue::Array2([vector.x, vector.y])
} }
Variant::Vector3(vector) => { Variant::Vector3(vector) => {
AmbiguousValue::Array3([vector.x as f64, vector.y as f64, vector.z as f64]) AmbiguousValue::Array3([vector.x, vector.y, vector.z])
} }
Variant::Color3(color) => { Variant::Color3(color) => {
AmbiguousValue::Array3([color.r as f64, color.g as f64, color.b as f64]) AmbiguousValue::Array3([color.r, color.g, color.b])
} }
Variant::CFrame(cf) => AmbiguousValue::Array12([ Variant::CFrame(cf) => AmbiguousValue::Array12([
cf.position.x as f64, cf.position.x,
cf.position.y as f64, cf.position.y,
cf.position.z as f64, cf.position.z,
cf.orientation.x.x as f64, cf.orientation.x.x,
cf.orientation.x.y as f64, cf.orientation.x.y,
cf.orientation.x.z as f64, cf.orientation.x.z,
cf.orientation.y.x as f64, cf.orientation.y.x,
cf.orientation.y.y as f64, cf.orientation.y.y,
cf.orientation.y.z as f64, cf.orientation.y.z,
cf.orientation.z.x as f64, cf.orientation.z.x,
cf.orientation.z.y as f64, cf.orientation.z.y,
cf.orientation.z.z as f64, cf.orientation.z.z,
]), ]),
Variant::Attributes(attr) => AmbiguousValue::Attributes(attr), Variant::Attributes(attr) => AmbiguousValue::Attributes(attr),
Variant::Font(font) => AmbiguousValue::Font(font), Variant::Font(font) => AmbiguousValue::Font(font),
@ -176,7 +116,6 @@ impl UnresolvedValue {
/// Creates an `UnresolvedValue` from a variant, only returning ambiguous /// Creates an `UnresolvedValue` from a variant, only returning ambiguous
/// values if they're able to be resolved in a context-free environment. /// values if they're able to be resolved in a context-free environment.
pub fn from_variant_unambiguous(variant: Variant) -> Self { pub fn from_variant_unambiguous(variant: Variant) -> Self {
let variant = truncate_variant(variant);
match variant { match variant {
Variant::String(str) => Self::Ambiguous(AmbiguousValue::String(str)), Variant::String(str) => Self::Ambiguous(AmbiguousValue::String(str)),
Variant::Float64(number) => Self::Ambiguous(AmbiguousValue::Number(number)), Variant::Float64(number) => Self::Ambiguous(AmbiguousValue::Number(number)),
@ -199,10 +138,11 @@ pub enum AmbiguousValue {
String(String), String(String),
StringArray(Vec<String>), StringArray(Vec<String>),
Number(f64), Number(f64),
Array2([f64; 2]), Number32(f32),
Array3([f64; 3]), Array2([f32; 2]),
Array4([f64; 4]), Array3([f32; 3]),
Array12([f64; 12]), Array4([f32; 4]),
Array12([f32; 12]),
Attributes(Attributes), Attributes(Attributes),
Font(Font), Font(Font),
MaterialColors(MaterialColors), MaterialColors(MaterialColors),
@ -257,6 +197,7 @@ impl AmbiguousValue {
DataType::Value(variant_ty) => match (variant_ty, self) { DataType::Value(variant_ty) => match (variant_ty, self) {
(VariantType::Bool, AmbiguousValue::Bool(value)) => Ok(value.into()), (VariantType::Bool, AmbiguousValue::Bool(value)) => Ok(value.into()),
(VariantType::Float32, AmbiguousValue::Number32(value)) => Ok((value as f32).into()),
(VariantType::Float32, AmbiguousValue::Number(value)) => Ok((value as f32).into()), (VariantType::Float32, AmbiguousValue::Number(value)) => Ok((value as f32).into()),
(VariantType::Float64, AmbiguousValue::Number(value)) => Ok(value.into()), (VariantType::Float64, AmbiguousValue::Number(value)) => Ok(value.into()),
(VariantType::Int32, AmbiguousValue::Number(value)) => Ok((value as i32).into()), (VariantType::Int32, AmbiguousValue::Number(value)) => Ok((value as i32).into()),
@ -341,6 +282,7 @@ impl AmbiguousValue {
AmbiguousValue::String(_) => "a string", AmbiguousValue::String(_) => "a string",
AmbiguousValue::StringArray(_) => "an array of strings", AmbiguousValue::StringArray(_) => "an array of strings",
AmbiguousValue::Number(_) => "a number", AmbiguousValue::Number(_) => "a number",
AmbiguousValue::Number32(_) => "a number",
AmbiguousValue::Array2(_) => "an array of two numbers", AmbiguousValue::Array2(_) => "an array of two numbers",
AmbiguousValue::Array3(_) => "an array of three numbers", AmbiguousValue::Array3(_) => "an array of three numbers",
AmbiguousValue::Array4(_) => "an array of four numbers", AmbiguousValue::Array4(_) => "an array of four numbers",
@ -476,6 +418,12 @@ mod test {
assert_eq!(resolve("Part", "Transparency", "1"), Variant::Float32(1.0)); assert_eq!(resolve("Part", "Transparency", "1"), Variant::Float32(1.0));
assert_eq!(resolve("NumberValue", "Value", "1"), Variant::Float64(1.0)); assert_eq!(resolve("NumberValue", "Value", "1"), Variant::Float64(1.0));
assert_eq!(
UnresolvedValue::from_variant(Variant::Float32(0.125), "Part", "Transparency")
.resolve("Part", "Transparency")
.unwrap(),
Variant::Float32(0.125),
);
assert_eq!(resolve_unambiguous("12.5"), Variant::Float64(12.5)); assert_eq!(resolve_unambiguous("12.5"), Variant::Float64(12.5));
} }