Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 99 additions & 7 deletions bigint/bigint.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -51,20 +51,112 @@ pub impl Show for BigInt with fn to_string(self) {
}

///|
/// Decodes a `BigInt` from JSON.
///
/// Accepts the string form `BigInt::to_json` produces, and also a JSON number
/// that denotes an integer. The parser keeps the source text of every integer
/// literal that runs past the exact-integer range of a `Double`, so a document
/// written by some other producer decodes without losing digits:
///
/// ```mbt check
/// test {
/// let big : @bigint.BigInt = @json.from_json(
/// @json.parse("12345678901234567890123"),
/// )
/// inspect(big, content="12345678901234567890123")
/// let small : @bigint.BigInt = @json.from_json(Json::number(42))
/// inspect(small, content="42")
/// }
/// ```
///
/// Where that text is present it is authoritative and must spell a plain
/// integer, so an exponent-form literal such as `1e400` is rejected rather
/// than decoded from the infinity it rounds to. A number carrying no text
/// decodes to the exact integer its `Double` holds, and so must be finite and
/// integral: `1.5` and `NaN` are errors, while `1e300` names the 301-digit
/// integer that double actually is.
pub impl @json.FromJson for BigInt with fn from_json(json, path) {
guard json is String(s) else {
raise JsonDecodeError(
(path, "BigInt::from_json: expected number in string representation"),
)
}
parse_bigint(s.view()) catch {
match json {
String(s) =>
parse_bigint(s.view()) catch {
_ =>
raise JsonDecodeError(
(path, "BigInt::from_json: invalid number in string representation"),
)
}
Number(value, repr~) => bigint_of_json_number(value, repr, path)
_ =>
raise JsonDecodeError(
(path, "BigInt::from_json: invalid number in string representation"),
(
path, "BigInt::from_json: expected a number or its string representation",
),
)
}
}

///|
/// Decodes the payload of a JSON `Number` into the exact integer it denotes.
///
/// A `Number` holds the value as a `Double` and, for the literals the parser
/// singles out — integers past the exact-integer range, and anything
/// overflowing to an infinity — the source text as well. The text wins where
/// it exists: `12345678901234567890123` only reaches the double after rounding
/// to `1.2345678901234568e22`, and decoding that would silently change the
/// last six digits.
fn bigint_of_json_number(
value : Double,
repr : String?,
path : @json.JsonPath,
) -> BigInt raise @json.JsonDecodeError {
if repr is Some(text) {
return parse_bigint(text.view()) catch {
_ =>
raise JsonDecodeError(
(path, "BigInt::from_json: `\{text}` is not a plain integer literal"),
)
}
}
guard !value.is_nan() && !value.is_inf() else {
raise JsonDecodeError((path, "BigInt::from_json: number is not finite"))
}
guard value.trunc() == value else {
raise JsonDecodeError((path, "BigInt::from_json: number is not an integer"))
}
bigint_of_integral_double(value)
}

///|
/// Converts a `Double` already known to be finite and integral into that exact
/// integer.
///
/// A finite double is `significand * 2^exponent` with a 53-bit significand, so
/// the integer it names is that product and no rounding is involved — going
/// through `Int64` instead would cap the range at 2^63, well short of the
/// integers a double can hold.
fn bigint_of_integral_double(value : Double) -> BigInt {
if value == 0.0 {
return zero
}
let bits = value.reinterpret_as_uint64()
let biased_exponent = ((bits >> 52) & 0x7FFUL).to_int()
// The leading significand bit is implicit for a normal double. A subnormal
// has none, but every non-zero subnormal is a proper fraction, so the only
// integral one is the zero already returned above.
let significand = (bits & 0xF_FFFF_FFFF_FFFFUL) | (1UL << 52)
let exponent = biased_exponent - 1075
let magnitude = if exponent >= 0 {
BigInt::from_uint64(significand) << exponent
} else {
// `value` is integral, so every bit dropped here is already zero.
BigInt::from_uint64(significand >> -exponent)
}
if bits >> 63 != 0 {
-magnitude
} else {
magnitude
}
}

///|
/// Returns the default value `0` for `BigInt`
pub impl Default for BigInt with fn default() {
Expand Down
148 changes: 148 additions & 0 deletions bigint/from_json_number_test.mbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright 2026 International Digital Economy Academy
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

///|
#callsite(autofill(loc))
fn decode(json : Json, loc~ : SourceLoc) -> @bigint.BigInt raise Failure {
@json.from_json(json) catch {
err => fail("unexpected decode failure: \{err}", loc~)
}
}

///|
#callsite(autofill(loc))
fn decode_error(json : Json, loc~ : SourceLoc) -> String raise Failure {
let err = try ignore((@json.from_json(json) : @bigint.BigInt)) catch {
err => err
} noraise {
_ => fail("expected a decode failure", loc~)
}
let JsonDecodeError((_, message)) = err
message
}

///|
test "BigInt from_json keeps every digit of a big integer literal" {
// The literal outruns the exact-integer range of a double, so the parser
// preserves its text; decoding the rounded double instead would lose the
// last six digits.
let json = @json.parse("12345678901234567890123")
assert_true(json is Number(1.2345678901234568e22, repr=Some(_)))
inspect(decode(json), content="12345678901234567890123")
inspect(
decode(@json.parse("-98765432109876543210987")),
content="-98765432109876543210987",
)
}

///|
test "BigInt from_json decodes plain numbers exactly" {
inspect(decode(Json::number(0)), content="0")
inspect(decode(Json::number(-0.0)), content="0")
inspect(decode(Json::number(42)), content="42")
inspect(decode(Json::number(-42)), content="-42")
inspect(decode(@json.parse("9007199254740992")), content="9007199254740992")
inspect(decode(@json.parse("9007199254740993")), content="9007199254740993")
// 2^63 is one past the top of Int64 (though its negation still fits), so
// decoding has to go through the significand and exponent rather than a
// fixed-width integer.
inspect(
decode(Json::number(9223372036854775808.0)),
content="9223372036854775808",
)
inspect(
decode(Json::number(-9223372036854775808.0)),
content="-9223372036854775808",
)
inspect(
decode(Json::number(18446744073709551616.0)),
content="18446744073709551616",
)
// 2^52 + 1: the first integer needing all 53 significand bits.
inspect(decode(Json::number(4503599627370497.0)), content="4503599627370497")
// The largest finite double is (2^53 - 1) * 2^971, the widest shift the
// conversion ever performs.
let max_double = decode(Json::number(@double.max_value))
assert_eq(max_double, (2N.pow(53N) - 1N) << 971)
inspect(max_double.to_string().length(), content="309")
}

///|
test "BigInt from_json decodes a double no fixed-width integer can hold" {
// 1e300 is neither an integer literal nor out of the range of a double, so
// the parser keeps no text for it and the double itself is decoded. That
// double is not exactly 10^300 but the nearest double to it, and that is the
// integer that comes out.
let json = @json.parse("1e300")
assert_true(json is Number(1.0e300, repr=None))
let decoded = decode(json)
inspect(decoded.to_string().length(), content="301")
inspect(
decoded.to_string().view(end_offset=20),
content="10000000000000000525",
)
assert_eq(decoded % 2N, 0N)
}

///|
test "BigInt from_json round trips its own encoding" {
let values = [0N, 1N, -1N, 12345678901234567890N, -12345678901234567890N]
for value in values {
assert_eq(decode(value.to_json()), value)
}
}

///|
test "BigInt from_json rejects numbers that are not integers" {
inspect(
decode_error(Json::number(1.5)),
content="BigInt::from_json: number is not an integer",
)
inspect(
decode_error(Json::number(@double.not_a_number)),
content="BigInt::from_json: number is not finite",
)
// Every non-zero subnormal is a proper fraction, so none of them decode.
inspect(
decode_error(Json::number(@double.min_positive / 2.0)),
content="BigInt::from_json: number is not an integer",
)
inspect(
decode_error(Json::number(@double.infinity)),
content="BigInt::from_json: number is not finite",
)
// An exponent-form literal beyond the range of a double keeps its text, and
// that text is authoritative: it is not a plain integer literal, so it is
// rejected rather than decoded from the infinity it rounded to.
inspect(
decode_error(@json.parse("1e400")),
content=(
#|BigInt::from_json: `1e400` is not a plain integer literal
),
)
inspect(
decode_error(Json::number(1, repr="oops")),
content=(
#|BigInt::from_json: `oops` is not a plain integer literal
),
)
inspect(
decode_error(Json("not-a-number")),
content="BigInt::from_json: invalid number in string representation",
)
inspect(
decode_error(Json(true)),
content="BigInt::from_json: expected a number or its string representation",
)
}
1 change: 1 addition & 0 deletions bigint/moon.pkg
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {

import {
"moonbitlang/core/bench",
"moonbitlang/core/double",
"moonbitlang/core/quickcheck",
"moonbitlang/core/test",
} for "test"
Expand Down
22 changes: 19 additions & 3 deletions json/from_json_test.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -785,15 +785,31 @@ test "Char from_json error handling" {

///|
test "BigInt from_json error handling" {
let json_number = Json::number(123)
let json_bool : Json = true
let err = expect_json_decode_error(
() => ignore((@json.from_json(json_number) : BigInt)),
() => ignore((@json.from_json(json_bool) : BigInt)),
"expected BigInt decode failure",
)
debug_inspect(
err,
content=(
#|JsonDecodeError((Root, "BigInt::from_json: expected number in string representation"))
#|JsonDecodeError(
#| (
#| Root,
#| "BigInt::from_json: expected a number or its string representation",
#| ),
#|)
),
)
let json_fraction = Json::number(1.5)
let err = expect_json_decode_error(
() => ignore((@json.from_json(json_fraction) : BigInt)),
"expected BigInt decode failure on a fraction",
)
debug_inspect(
err,
content=(
#|JsonDecodeError((Root, "BigInt::from_json: number is not an integer"))
),
)
let json_invalid_number = Json("not-a-number")
Expand Down
Loading