From 165213f6ea44b61040c28f645525228e9ce4f7ff Mon Sep 17 00:00:00 2001 From: Hongbo Zhang Date: Tue, 8 Sep 2026 11:55:27 +0800 Subject: [PATCH] fix(json): encode non-finite numbers as strings in stringify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Json::stringify` wrote a `Number` holding NaN or an infinity as a bare `NaN` / `Infinity` / `-Infinity` literal, which is not JSON: no RFC 8259 parser reads it back. Encode those three the way `Double::to_json` already does, as the strings "NaN", "Infinity" and "-Infinity" — exactly what `Double::from_json` accepts — so the value survives a round trip. Only a directly constructed `Number` reaches this path: the parser records the source text in `repr` for every literal that overflows to an infinity, and that branch writes `repr` verbatim without consulting the double. The guard is a tiny entry with the non-finite tail outlined, so inlining it into the `stringify` loop stays cheap for ordinary numbers. Also document the `repr` contract this relies on: when present, `repr` must be valid JSON number syntax denoting the accompanying `Double`, since `stringify` writes it in place of the double; which literals the parser attaches it to (every integer past the exact-integer range, whether or not it lands on one, plus everything overflowing to an infinity); and that equality compares only the numeric value, so two equal `Json` values can stringify differently. Reverting the `write_number` call fails exactly the two tests added here (the `Json::number` doc test and `stringify non-finite number`); with it, 7529 pass on wasm-gc and json+builtin pass on all four backends. Reviewed with Codex CLI: "No blocking issues in the two revised passages. They correctly distinguish invalid JSON, a different JSON type, and a mismatched numeric value." Signed-off-by: Codex CLI Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VubGDsJHzgC6ykiq7t4hrY --- builtin/json.mbt | 44 +++++++++++++++++++++++++++++++++++++++++++- json/json.mbt | 34 +++++++++++++++++++++++++++++++++- json/json_test.mbt | 45 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) diff --git a/builtin/json.mbt b/builtin/json.mbt index 888836ad4..67c6e0898 100644 --- a/builtin/json.mbt +++ b/builtin/json.mbt @@ -15,6 +15,34 @@ ///| /// JSON value type used by core serialization APIs. /// +/// `Number` carries the numeric value as a `Double` together with an optional +/// `repr`, the exact source text of the literal. `stringify` writes `repr` out +/// verbatim in place of the double, so it must be valid JSON number syntax and +/// must denote the accompanying `Double`: text that is not a JSON number makes +/// the output invalid JSON, or turns the value into a different JSON type +/// altogether (`repr="null"` emits `null`), and text denoting some other value +/// silently changes what the document says. +/// +/// The parser attaches a `repr` to every integer literal whose digits run past +/// the exact-integer range of a `Double` — whether or not the value happens to +/// land on one, so `9007199254740992` keeps its text too — and to every literal +/// that overflows to an infinity. It leaves `repr` as `None` everywhere else, +/// including decimals that round (`0.1`) and those that underflow to zero +/// (`1e-400`). +/// +/// Equality compares numeric values and ignores `repr`, so two equal `Json` +/// values may stringify differently: +/// +/// ```mbt check +/// test { +/// let one : Json = Json::number(1) +/// let padded : Json = Json::number(1, repr="1.000") +/// inspect(one == padded, content="true") +/// inspect(one.stringify(), content="1") +/// inspect(padded.stringify(), content="1.000") +/// } +/// ``` +/// /// Example: /// /// ```mbt check @@ -27,7 +55,7 @@ pub enum Json { Null True False - Number(Double, repr~ : String?) // 1.0000000000000000000e100 + Number(Double, repr~ : String?) // 1.0000000000000000000e100 String(String) Array(Array[Json]) Object(Map[String, Json]) @@ -80,9 +108,22 @@ pub fn Json::empty_object() -> Json { /// /// * `value` : A double-precision floating-point number to be converted to a /// JSON number. +/// * `repr` : The exact source text of the literal, if it is worth preserving. +/// `stringify` writes it verbatim in place of `value`, so it must be valid JSON +/// number syntax and must denote `value` itself: text that is not a JSON number +/// makes the output invalid JSON or gives it a different JSON type, and text +/// denoting some other value makes the document disagree with the `Double` +/// every consumer reads. Supply it only for literals a `Double` cannot render +/// back as written, such as integers beyond the exact-integer range or +/// magnitudes beyond the range of a `Double` altogether. /// /// Returns a JSON value representing the given number. /// +/// `NaN` and the infinities have no JSON syntax. Passing one without a `repr` +/// is accepted and stringifies to the strings `"NaN"`, `"Infinity"` and +/// `"-Infinity"`, matching how `Double::to_json` encodes them and what +/// `Double::from_json` accepts back. +/// /// Example: /// /// ```mbt check @@ -92,6 +133,7 @@ pub fn Json::empty_object() -> Json { /// Json::number(@double.infinity, repr="1e9999999999999999999999999999999").stringify(), /// content="1e9999999999999999999999999999999", /// ) +/// inspect(Json::number(@double.infinity).stringify(), content="\"Infinity\"") /// } /// ``` #owned(repr) diff --git a/json/json.mbt b/json/json.mbt index d0af0bab1..33ebd7c30 100644 --- a/json/json.mbt +++ b/json/json.mbt @@ -112,6 +112,38 @@ fn write_indent( buf.write_string(cache[level]) } +///| +#inline +fn write_number(buf : StringBuilder, number : Double) -> Unit { + // The guard is kept tiny and the non-finite tail outlined so that inlining + // this into the `stringify` loop stays cheap on every ordinary number. + if number.is_nan() || number.is_inf() { + write_non_finite_number(buf, number) + } else { + buf.write_object(number) + } +} + +///| +fn write_non_finite_number(buf : StringBuilder, number : Double) -> Unit { + // JSON has no syntax for NaN or the infinities, so a `Number` holding one + // cannot be written as a bare literal. Encode them the way `Double::to_json` + // does — as the strings "NaN", "Infinity" and "-Infinity" — since + // `Double::from_json` accepts exactly those three, so the value still + // survives a round trip. + // + // Only a directly constructed `Number` reaches this: the parser stores the + // source text in `repr` for every literal that overflows to an infinity, and + // that branch writes `repr` verbatim without consulting the double. + if number.is_nan() { + buf.write_string("\"NaN\"") + } else if number.is_pos_inf() { + buf.write_string("\"Infinity\"") + } else { + buf.write_string("\"-Infinity\"") + } +} + ///| /// Internal stack frame used by iterative stringify to avoid recursion priv enum WriteFrame { @@ -325,7 +357,7 @@ pub fn Json::stringify( } Number(n, repr~) => match repr { - None => buf.write_object(n) + None => write_number(buf, n) Some(r) => buf.write_string(r) } True => buf.write_string("true") diff --git a/json/json_test.mbt b/json/json_test.mbt index c7f9f4b75..1b745e3b0 100644 --- a/json/json_test.mbt +++ b/json/json_test.mbt @@ -394,6 +394,51 @@ test "stringify number" { debug_inspect(err, content="[]") } +///| +test "stringify non-finite number" { + // A directly constructed `Number` can hold a value JSON has no syntax for. + // It is written the way `Double::to_json` encodes one, so the output stays + // valid JSON and `Double::from_json` reads it back. + let nan = Json::number(@double.not_a_number) + let inf = Json::number(@double.infinity) + let neg_inf = Json::number(@double.neg_infinity) + inspect( + nan.stringify(), + content=( + #|"NaN" + ), + ) + inspect( + inf.stringify(), + content=( + #|"Infinity" + ), + ) + inspect( + neg_inf.stringify(), + content=( + #|"-Infinity" + ), + ) + inspect( + [nan, inf, neg_inf].to_json().stringify(), + content=( + #|["NaN","Infinity","-Infinity"] + ), + ) + let back : Double = @json.from_json(@json.parse(inf.stringify())) + inspect(back == @double.infinity, content="true") + let back : Double = @json.from_json(@json.parse(neg_inf.stringify())) + inspect(back == @double.neg_infinity, content="true") + let back : Double = @json.from_json(@json.parse(nan.stringify())) + inspect(back.is_nan(), content="true") + // `repr` still takes precedence over the double it accompanies. + inspect( + Json::number(@double.infinity, repr="1e999999999").stringify(), + content="1e999999999", + ) +} + ///| test "stringify with indent" { let json : Json = {