From b93a00e18f8250699483ca43015c4214cbbba4e9 Mon Sep 17 00:00:00 2001 From: Hongbo Zhang Date: Sat, 15 Aug 2026 10:19:46 +0800 Subject: [PATCH] fix(json): preserve the sign of -0 in the integer fast path Fixes #4053. parse("-0") returned +0.0 while parse("-0.0") / parse("-0e0") returned -0.0: the safe-integer fast path in lex_number_end negated the mantissa as an Int64 (where -0 == 0) before converting to Double. Negate after the conversion so every spelling of negative zero keeps the IEEE-754 sign bit, per RFC 8259 number semantics. The conversion-then-negation is exact for the whole safe-integer range. Deterministic regression tests in lex_number_test.mbt cover every spelling of negative zero, including values that underflow to zero (which already preserved the sign). Co-Authored-By: Claude Fable 5 --- json/lex_number.mbt | 10 +++++++--- json/lex_number_test.mbt | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/json/lex_number.mbt b/json/lex_number.mbt index 1155b74c8..aa03bc365 100644 --- a/json/lex_number.mbt +++ b/json/lex_number.mbt @@ -399,12 +399,16 @@ fn ParseContext::lex_number_end( // returned Double lossless. `reinterpret_as_uint64` / `reinterpret_as_int64` // are value-preserving here because both operands sit in [0, 2^53), well // inside the overlap of Int64+ and UInt64. + // + // The sign is applied after the Int64 -> Double conversion so that `-0` + // parses to the IEEE-754 negative zero (`-(0L)` is still `0L`, but + // `-(0.0)` is `-0.0`), matching the `-0.0` / `-0e0` paths below. if !scan.many_digits && scan.exponent == 0L && scan.mantissa <= SAFE_INTEGER_LIMIT.reinterpret_as_uint64() { - let v = scan.mantissa.reinterpret_as_int64() - let signed = if scan.negative { -v } else { v } - return { value: signed.to_double(), repr: None } + let v = scan.mantissa.reinterpret_as_int64().to_double() + let value = if scan.negative { -v } else { v } + return { value, repr: None } } return ctx.lex_integer_end(start, end) } diff --git a/json/lex_number_test.mbt b/json/lex_number_test.mbt index a0e261103..8d14be2a7 100644 --- a/json/lex_number_test.mbt +++ b/json/lex_number_test.mbt @@ -170,3 +170,27 @@ test "parse number with huge exponent" { ), ) } + +///| +/// Every spelling of a negative zero — including values that underflow to +/// zero — must keep the IEEE-754 sign bit. The integer spelling `-0` used to +/// lose it: the integer fast path negated an `Int64` (where `-0 == 0`) +/// before converting to `Double`. +test "parse preserves the sign of negative zero" { + fn is_negative(text : String) -> Bool raise { + guard @json.parse(text) is Number(n, ..) else { fail("not a number") } + n.reinterpret_as_int64() < 0L + } + + // Zero literals. + assert_true(is_negative("-0")) + assert_true(is_negative("-0.0")) + assert_true(is_negative("-0e0")) + assert_true(is_negative("-0.00E-7")) + assert_false(is_negative("0")) + assert_false(is_negative("0.0")) + // Negative values that underflow to zero. + assert_true(is_negative("-1e-400")) + assert_true(is_negative("-4.9e-325")) + assert_true(is_negative("-1e-999999999999999999999999999999999999")) +}