Skip to content

Commit 76188a6

Browse files
bobzhangclaude
andcommitted
feat(bigint): decode BigInt from JSON numbers
`Json` keeps the source text of a number literal in `Number(_, repr~)`, but nothing ever read it back: `stringify` wrote it and every `FromJson` impl ignored it. `BigInt::from_json` accepted only the JSON string that `BigInt::to_json` emits, so a document from another producer carrying `12345678901234567890123` could not be decoded without the caller destructuring `Number` by hand — and going through the `Double` would have changed the last six digits. `BigInt::from_json` now also accepts a JSON number that denotes an integer: - with `repr` present it is authoritative and must spell a plain integer literal, so an exponent-form literal such as `1e400` is rejected rather than decoded from the infinity it rounded to; - with `repr` absent the `Double` must be finite and integral, and is converted exactly through its significand and exponent — an `Int64` detour would cap the range at 2^63, well short of the integers a double holds, so `1e300` decodes to the 301-digit integer that double is. `Json::number(123)` previously failed to decode; the error test in json/from_json_test.mbt now covers `true` and `1.5` instead. Mutation-verified: ignoring `repr` fails 4 tests (the doc test decodes 12345678901234567741440), and routing through `Int64` fails 2 (2^63 saturates, 1e300 comes out 19 digits). All 7533 tests pass, and bigint plus json pass on all four backends — the js one matters here because it uses the host `BigInt`. Reviewed with Codex CLI: "The revised comments match the lexer, and the new boundary assertions match the arithmetic. No blocking issues." It also cross-checked the bit surgery against 47,499 integral doubles. Signed-off-by: Codex CLI <codex@openai.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VubGDsJHzgC6ykiq7t4hrY
1 parent 9d086a3 commit 76188a6

4 files changed

Lines changed: 267 additions & 10 deletions

File tree

bigint/bigint.mbt

Lines changed: 99 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,20 +51,112 @@ pub impl Show for BigInt with fn to_string(self) {
5151
}
5252

5353
///|
54+
/// Decodes a `BigInt` from JSON.
55+
///
56+
/// Accepts the string form `BigInt::to_json` produces, and also a JSON number
57+
/// that denotes an integer. The parser keeps the source text of every integer
58+
/// literal that runs past the exact-integer range of a `Double`, so a document
59+
/// written by some other producer decodes without losing digits:
60+
///
61+
/// ```mbt check
62+
/// test {
63+
/// let big : @bigint.BigInt = @json.from_json(
64+
/// @json.parse("12345678901234567890123"),
65+
/// )
66+
/// inspect(big, content="12345678901234567890123")
67+
/// let small : @bigint.BigInt = @json.from_json(Json::number(42))
68+
/// inspect(small, content="42")
69+
/// }
70+
/// ```
71+
///
72+
/// Where that text is present it is authoritative and must spell a plain
73+
/// integer, so an exponent-form literal such as `1e400` is rejected rather
74+
/// than decoded from the infinity it rounds to. A number carrying no text
75+
/// decodes to the exact integer its `Double` holds, and so must be finite and
76+
/// integral: `1.5` and `NaN` are errors, while `1e300` names the 301-digit
77+
/// integer that double actually is.
5478
pub impl @json.FromJson for BigInt with fn from_json(json, path) {
55-
guard json is String(s) else {
56-
raise JsonDecodeError(
57-
(path, "BigInt::from_json: expected number in string representation"),
58-
)
59-
}
60-
parse_bigint(s.view()) catch {
79+
match json {
80+
String(s) =>
81+
parse_bigint(s.view()) catch {
82+
_ =>
83+
raise JsonDecodeError(
84+
(path, "BigInt::from_json: invalid number in string representation"),
85+
)
86+
}
87+
Number(value, repr~) => bigint_of_json_number(value, repr, path)
6188
_ =>
6289
raise JsonDecodeError(
63-
(path, "BigInt::from_json: invalid number in string representation"),
90+
(
91+
path, "BigInt::from_json: expected a number or its string representation",
92+
),
6493
)
6594
}
6695
}
6796

97+
///|
98+
/// Decodes the payload of a JSON `Number` into the exact integer it denotes.
99+
///
100+
/// A `Number` holds the value as a `Double` and, for the literals the parser
101+
/// singles out — integers past the exact-integer range, and anything
102+
/// overflowing to an infinity — the source text as well. The text wins where
103+
/// it exists: `12345678901234567890123` only reaches the double after rounding
104+
/// to `1.2345678901234568e22`, and decoding that would silently change the
105+
/// last six digits.
106+
fn bigint_of_json_number(
107+
value : Double,
108+
repr : String?,
109+
path : @json.JsonPath,
110+
) -> BigInt raise @json.JsonDecodeError {
111+
if repr is Some(text) {
112+
return parse_bigint(text.view()) catch {
113+
_ =>
114+
raise JsonDecodeError(
115+
(path, "BigInt::from_json: `\{text}` is not a plain integer literal"),
116+
)
117+
}
118+
}
119+
guard !value.is_nan() && !value.is_inf() else {
120+
raise JsonDecodeError((path, "BigInt::from_json: number is not finite"))
121+
}
122+
guard value.trunc() == value else {
123+
raise JsonDecodeError((path, "BigInt::from_json: number is not an integer"))
124+
}
125+
bigint_of_integral_double(value)
126+
}
127+
128+
///|
129+
/// Converts a `Double` already known to be finite and integral into that exact
130+
/// integer.
131+
///
132+
/// A finite double is `significand * 2^exponent` with a 53-bit significand, so
133+
/// the integer it names is that product and no rounding is involved — going
134+
/// through `Int64` instead would cap the range at 2^63, well short of the
135+
/// integers a double can hold.
136+
fn bigint_of_integral_double(value : Double) -> BigInt {
137+
if value == 0.0 {
138+
return zero
139+
}
140+
let bits = value.reinterpret_as_uint64()
141+
let biased_exponent = ((bits >> 52) & 0x7FFUL).to_int()
142+
// The leading significand bit is implicit for a normal double. A subnormal
143+
// has none, but every non-zero subnormal is a proper fraction, so the only
144+
// integral one is the zero already returned above.
145+
let significand = (bits & 0xF_FFFF_FFFF_FFFFUL) | (1UL << 52)
146+
let exponent = biased_exponent - 1075
147+
let magnitude = if exponent >= 0 {
148+
BigInt::from_uint64(significand) << exponent
149+
} else {
150+
// `value` is integral, so every bit dropped here is already zero.
151+
BigInt::from_uint64(significand >> -exponent)
152+
}
153+
if bits >> 63 != 0 {
154+
-magnitude
155+
} else {
156+
magnitude
157+
}
158+
}
159+
68160
///|
69161
/// Returns the default value `0` for `BigInt`
70162
pub impl Default for BigInt with fn default() {

bigint/from_json_number_test.mbt

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright 2026 International Digital Economy Academy
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
///|
16+
#callsite(autofill(loc))
17+
fn decode(json : Json, loc~ : SourceLoc) -> @bigint.BigInt raise Failure {
18+
@json.from_json(json) catch {
19+
err => fail("unexpected decode failure: \{err}", loc~)
20+
}
21+
}
22+
23+
///|
24+
#callsite(autofill(loc))
25+
fn decode_error(json : Json, loc~ : SourceLoc) -> String raise Failure {
26+
let err = try ignore((@json.from_json(json) : @bigint.BigInt)) catch {
27+
err => err
28+
} noraise {
29+
_ => fail("expected a decode failure", loc~)
30+
}
31+
let JsonDecodeError((_, message)) = err
32+
message
33+
}
34+
35+
///|
36+
test "BigInt from_json keeps every digit of a big integer literal" {
37+
// The literal outruns the exact-integer range of a double, so the parser
38+
// preserves its text; decoding the rounded double instead would lose the
39+
// last six digits.
40+
let json = @json.parse("12345678901234567890123")
41+
assert_true(json is Number(1.2345678901234568e22, repr=Some(_)))
42+
inspect(decode(json), content="12345678901234567890123")
43+
inspect(
44+
decode(@json.parse("-98765432109876543210987")),
45+
content="-98765432109876543210987",
46+
)
47+
}
48+
49+
///|
50+
test "BigInt from_json decodes plain numbers exactly" {
51+
inspect(decode(Json::number(0)), content="0")
52+
inspect(decode(Json::number(-0.0)), content="0")
53+
inspect(decode(Json::number(42)), content="42")
54+
inspect(decode(Json::number(-42)), content="-42")
55+
inspect(decode(@json.parse("9007199254740992")), content="9007199254740992")
56+
inspect(decode(@json.parse("9007199254740993")), content="9007199254740993")
57+
// 2^63 is one past the top of Int64 (though its negation still fits), so
58+
// decoding has to go through the significand and exponent rather than a
59+
// fixed-width integer.
60+
inspect(
61+
decode(Json::number(9223372036854775808.0)),
62+
content="9223372036854775808",
63+
)
64+
inspect(
65+
decode(Json::number(-9223372036854775808.0)),
66+
content="-9223372036854775808",
67+
)
68+
inspect(
69+
decode(Json::number(18446744073709551616.0)),
70+
content="18446744073709551616",
71+
)
72+
// 2^52 + 1: the first integer needing all 53 significand bits.
73+
inspect(decode(Json::number(4503599627370497.0)), content="4503599627370497")
74+
// The largest finite double is (2^53 - 1) * 2^971, the widest shift the
75+
// conversion ever performs.
76+
let max_double = decode(Json::number(@double.max_value))
77+
assert_eq(max_double, (2N.pow(53N) - 1N) << 971)
78+
inspect(max_double.to_string().length(), content="309")
79+
}
80+
81+
///|
82+
test "BigInt from_json decodes a double no fixed-width integer can hold" {
83+
// 1e300 is neither an integer literal nor out of the range of a double, so
84+
// the parser keeps no text for it and the double itself is decoded. That
85+
// double is not exactly 10^300 but the nearest double to it, and that is the
86+
// integer that comes out.
87+
let json = @json.parse("1e300")
88+
assert_true(json is Number(1.0e300, repr=None))
89+
let decoded = decode(json)
90+
inspect(decoded.to_string().length(), content="301")
91+
inspect(
92+
decoded.to_string().view(end_offset=20),
93+
content="10000000000000000525",
94+
)
95+
assert_eq(decoded % 2N, 0N)
96+
}
97+
98+
///|
99+
test "BigInt from_json round trips its own encoding" {
100+
let values = [0N, 1N, -1N, 12345678901234567890N, -12345678901234567890N]
101+
for value in values {
102+
assert_eq(decode(value.to_json()), value)
103+
}
104+
}
105+
106+
///|
107+
test "BigInt from_json rejects numbers that are not integers" {
108+
inspect(
109+
decode_error(Json::number(1.5)),
110+
content="BigInt::from_json: number is not an integer",
111+
)
112+
inspect(
113+
decode_error(Json::number(@double.not_a_number)),
114+
content="BigInt::from_json: number is not finite",
115+
)
116+
// Every non-zero subnormal is a proper fraction, so none of them decode.
117+
inspect(
118+
decode_error(Json::number(@double.min_positive / 2.0)),
119+
content="BigInt::from_json: number is not an integer",
120+
)
121+
inspect(
122+
decode_error(Json::number(@double.infinity)),
123+
content="BigInt::from_json: number is not finite",
124+
)
125+
// An exponent-form literal beyond the range of a double keeps its text, and
126+
// that text is authoritative: it is not a plain integer literal, so it is
127+
// rejected rather than decoded from the infinity it rounded to.
128+
inspect(
129+
decode_error(@json.parse("1e400")),
130+
content=(
131+
#|BigInt::from_json: `1e400` is not a plain integer literal
132+
),
133+
)
134+
inspect(
135+
decode_error(Json::number(1, repr="oops")),
136+
content=(
137+
#|BigInt::from_json: `oops` is not a plain integer literal
138+
),
139+
)
140+
inspect(
141+
decode_error(Json("not-a-number")),
142+
content="BigInt::from_json: invalid number in string representation",
143+
)
144+
inspect(
145+
decode_error(Json(true)),
146+
content="BigInt::from_json: expected a number or its string representation",
147+
)
148+
}

bigint/moon.pkg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111

1212
import {
1313
"moonbitlang/core/bench",
14+
"moonbitlang/core/double",
1415
"moonbitlang/core/quickcheck",
1516
"moonbitlang/core/test",
1617
} for "test"

json/from_json_test.mbt

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -785,15 +785,31 @@ test "Char from_json error handling" {
785785

786786
///|
787787
test "BigInt from_json error handling" {
788-
let json_number = Json::number(123)
788+
let json_bool : Json = true
789789
let err = expect_json_decode_error(
790-
() => ignore((@json.from_json(json_number) : BigInt)),
790+
() => ignore((@json.from_json(json_bool) : BigInt)),
791791
"expected BigInt decode failure",
792792
)
793793
debug_inspect(
794794
err,
795795
content=(
796-
#|JsonDecodeError((Root, "BigInt::from_json: expected number in string representation"))
796+
#|JsonDecodeError(
797+
#| (
798+
#| Root,
799+
#| "BigInt::from_json: expected a number or its string representation",
800+
#| ),
801+
#|)
802+
),
803+
)
804+
let json_fraction = Json::number(1.5)
805+
let err = expect_json_decode_error(
806+
() => ignore((@json.from_json(json_fraction) : BigInt)),
807+
"expected BigInt decode failure on a fraction",
808+
)
809+
debug_inspect(
810+
err,
811+
content=(
812+
#|JsonDecodeError((Root, "BigInt::from_json: number is not an integer"))
797813
),
798814
)
799815
let json_invalid_number = Json("not-a-number")

0 commit comments

Comments
 (0)