diff --git a/README.md b/README.md index 806dca9..4ed2346 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ Code paths that cannot accept or return DSL classes pass | `Number` | `number` | `toI32()`, `toF64()`, `assertI32()`, `from(anytype)` | | `String` | `string` | `toSlice(buf)`, `toOwnedSlice(alloc)`, `len()`, `from([]const u8)` | | `Boolean` | `boolean` | `toBool()`, `assertBool()`, `from(bool)` | -| `BigInt` | `bigint` | `toI64()`, `toU64()`, `toI128()`, `from(anytype)` | +| `BigInt` | `bigint` | `toI64()`, `toU64()`, `toI128()`, `to*LowBits()`, `from(anytype)`, `fromWords(sign, words)` | | `Date` | `Date` | `toTimestamp()`, `from(f64)` | | `Array` | `Array` | `get(i)`, `getNumber(i)`, `length()`, `set(i, val)` | | `Object(T)` | `object` | `get()`, `set(value)` — `T` fields must be DSL types | diff --git a/examples/js_dsl/mod.test.ts b/examples/js_dsl/mod.test.ts index 73cbc99..56ab659 100644 --- a/examples/js_dsl/mod.test.ts +++ b/examples/js_dsl/mod.test.ts @@ -67,7 +67,7 @@ describe("primitive types", () => { it("exactU32 rejects values outside the u32 range", () => { for (const value of [-1, 1.5, 2 ** 32, Number.MAX_SAFE_INTEGER, Infinity, -Infinity, NaN]) { - expect(() => mod.exactU32(value), `value ${value}`).toThrow("InvalidUnsignedInteger"); + expect(() => mod.exactU32(value), `value ${value}`).toThrow("LossyConversion"); } }); @@ -79,14 +79,14 @@ describe("primitive types", () => { it("safeInteger rejects values that are not non-negative safe integers", () => { for (const value of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, Infinity, -Infinity, NaN]) { - expect(() => mod.safeInteger(value), `value ${value}`).toThrow("InvalidUnsignedInteger"); + expect(() => mod.safeInteger(value), `value ${value}`).toThrow("LossyConversion"); } }); // 2^32 separates the two: in range for a safe integer, out of range for a u32. it("safeInteger and exactU32 differ exactly at the u32 boundary", () => { expect(mod.safeInteger(2 ** 32)).toEqual(2 ** 32); - expect(() => mod.exactU32(2 ** 32)).toThrow("InvalidUnsignedInteger"); + expect(() => mod.exactU32(2 ** 32)).toThrow("LossyConversion"); }); it("toggleBool", () => { @@ -156,11 +156,11 @@ describe("primitive types", () => { }); it("rejects BigInts outside the i128 range instead of crashing", () => { - expect(() => mod.bigIntToI128String(2n ** 127n)).toThrow(); - expect(() => mod.bigIntToI128String(-(2n ** 127n) - 1n)).toThrow(); - expect(() => mod.bigIntToI128String(2n ** 128n)).toThrow(); - expect(() => mod.bigIntToI128String(2n ** 200n)).toThrow(); - expect(() => mod.bigIntToI128String(-(2n ** 200n))).toThrow(); + expect(() => mod.bigIntToI128String(2n ** 127n)).toThrow("LossyConversion"); + expect(() => mod.bigIntToI128String(-(2n ** 127n) - 1n)).toThrow("LossyConversion"); + expect(() => mod.bigIntToI128String(2n ** 128n)).toThrow("LossyConversion"); + expect(() => mod.bigIntToI128String(2n ** 200n)).toThrow("LossyConversion"); + expect(() => mod.bigIntToI128String(-(2n ** 200n))).toThrow("LossyConversion"); }); }); diff --git a/examples/js_dsl/mod.zig b/examples/js_dsl/mod.zig index f01b73e..6f21db2 100644 --- a/examples/js_dsl/mod.zig +++ b/examples/js_dsl/mod.zig @@ -107,8 +107,7 @@ pub fn reverseString(s: String) !String { /// Double a BigInt value. pub fn doubleBigInt(n: BigInt) !BigInt { - var lossless: bool = false; - const val = try n.toI64(&lossless); + const val = try n.toI64(); return BigInt.from(val * 2); } diff --git a/src/Value.zig b/src/Value.zig index 5912f98..0473c68 100644 --- a/src/Value.zig +++ b/src/Value.zig @@ -274,7 +274,10 @@ pub fn getValueBigintUint64(self: Value, lossless: ?*bool) NapiError!u64 { /// /// Returns `error.Overflow` if the BigInt needs more words than `words` can hold: /// napi sets the out `word_count` to the *required* count, which may exceed the -/// buffer, so slicing by it unchecked would read out of bounds. +/// buffer, so slicing by it unchecked would read out of bounds. napi still fills +/// `words` with the low-order words and `sign_bit` is set before the error is +/// returned, so callers that want truncation semantics may catch `error.Overflow` +/// and read the buffer directly. /// /// NOTE: napi's C entry takes `int*` (4-byte aligned). Casting a u1 to int* is UB, since that /// is 4-bytes aligned. We use a local `c_int` for the napi call and narrow back to `u1` for the caller. @@ -286,9 +289,9 @@ pub fn getValueBigintWords(self: Value, sign_bit: ?*u1, words: []u64) (NapiError try status.check( c.napi_get_value_bigint_words(self.env, self.value, &raw_sign, &word_count, words.ptr), ); - if (word_count > words.len) return error.Overflow; // napi guarantees raw_sign ∈ {0, 1} if (sign_bit) |s| s.* = @intCast(raw_sign); + if (word_count > words.len) return error.Overflow; return words[0..word_count]; } diff --git a/src/js/bigint.zig b/src/js/bigint.zig index f27d0fd..acbb908 100644 --- a/src/js/bigint.zig +++ b/src/js/bigint.zig @@ -14,69 +14,138 @@ pub const BigInt = struct { if ((try val.typeof()) != .bigint) return error.TypeMismatch; } - /// Attempts to convert the JavaScript BigInt to a Zig `i64`. + /// Attempts to convert the JavaScript BigInt exactly to a Zig `i64`. /// - /// If `lossless` is provided, it will be set to `true` if the BigInt can be - /// represented exactly as an `i64`, and `false` otherwise (e.g., if the - /// BigInt is too large or too small for `i64`). - /// Returns an error if the conversion fails or the `napi_env` is invalid. - pub fn toI64(self: BigInt, lossless: ?*bool) !i64 { + /// Returns `error.LossyConversion` if the value is outside the `i64` range. Use + /// `toI64LowBits` when low-64-bit conversion is intended. + pub fn toI64(self: BigInt) !i64 { + var lossless = false; + const value = try self.toI64LowBits(&lossless); + if (!lossless) return error.LossyConversion; + return value; + } + + /// Converts the JavaScript BigInt to the low 64 bits as a Zig `i64`. + /// + /// If `lossless` is provided, it is set to whether the returned `i64` + /// represents the original BigInt exactly. Out-of-range values still return + /// their low 64 bits, matching N-API BigInt conversion semantics, but set + /// `lossless` to false. + pub fn toI64LowBits(self: BigInt, lossless: ?*bool) !i64 { return self.val.getValueBigintInt64(lossless); } - /// Attempts to convert the JavaScript BigInt to a Zig `u64`. + /// Attempts to convert the JavaScript BigInt exactly to a Zig `u64`. /// - /// If `lossless` is provided, it will be set to `true` if the BigInt can be - /// represented exactly as a `u64`, and `false` otherwise. - /// Returns an error if the conversion fails (e.g., BigInt is negative) or - /// the `napi_env` is invalid. - pub fn toU64(self: BigInt, lossless: ?*bool) !u64 { + /// Returns `error.LossyConversion` if the value is negative or outside the `u64` + /// range. Use `toU64LowBits` when low-64-bit conversion is intended. + pub fn toU64(self: BigInt) !u64 { + var lossless = false; + const value = try self.toU64LowBits(&lossless); + if (!lossless) return error.LossyConversion; + return value; + } + + /// Converts the JavaScript BigInt to the low 64 bits as a Zig `u64`. + /// + /// If `lossless` is provided, it is set to whether the returned `u64` + /// represents the original BigInt exactly. Out-of-range or negative values + /// still return their low 64 bits, matching N-API BigInt conversion + /// semantics, but set `lossless` to false. + pub fn toU64LowBits(self: BigInt, lossless: ?*bool) !u64 { return self.val.getValueBigintUint64(lossless); } - /// Attempts to convert the JavaScript BigInt to a Zig `i128`. + /// Attempts to convert the JavaScript BigInt exactly to a Zig `i128`. + /// + /// Returns `error.LossyConversion` if the value is outside `[-2^127, 2^127)`. + /// Use `toI128LowBits` when low-128-bit conversion is intended. /// - /// This function reads the BigInt as two 64-bit words and reconstructs it - /// into a Zig `i128`. It handles both positive and negative BigInts. - /// Returns `error.Overflow` if the value is outside the i128 range, and an - /// error if N-API operations fail. + /// Returns an error if N-API operations fail. pub fn toI128(self: BigInt) !i128 { var sign_bit: u1 = 0; var words: [2]u64 = .{ 0, 0 }; - const result = try self.val.getValueBigintWords(&sign_bit, &words); - const lo: u128 = if (result.len > 0) result[0] else 0; - const hi: u128 = if (result.len > 1) result[1] else 0; + // BigInts needing more than two words are always outside the i128 + // range; getValueBigintWords reports them as error.Overflow. + _ = self.val.getValueBigintWords(&sign_bit, &words) catch |err| switch (err) { + error.Overflow => return error.LossyConversion, + else => |e| return e, + }; + const lo: u128 = words[0]; + const hi: u128 = words[1]; const magnitude: u128 = (hi << 64) | lo; const max_positive: u128 = std.math.maxInt(i128); if (sign_bit == 1) { - if (magnitude > max_positive + 1) return error.Overflow; + if (magnitude > max_positive + 1) return error.LossyConversion; if (magnitude == max_positive + 1) return std.math.minInt(i128); return -@as(i128, @intCast(magnitude)); } - if (magnitude > max_positive) return error.Overflow; + if (magnitude > max_positive) return error.LossyConversion; return @intCast(magnitude); } + /// Converts the JavaScript BigInt to the low 128 bits as a Zig `i128`. + /// + /// This returns `BigInt.asIntN(128, b)`, i.e. the low 128 bits + /// of the magnitude reinterpreted as a signed i128. This matches the + /// ECMAScript `BigInt.asIntN` semantics defined at + /// https://tc39.es/ecma262/#sec-bigint.asintn. + /// + /// Returns an error if N-API operations fail. + pub fn toI128LowBits(self: BigInt) !i128 { + var sign_bit: u1 = 0; + // Pre-zeroed: NAPI writes only as many words as the BigInt has; unused + // words stay 0. When the value is 0n NAPI returns word_count == 0, so + // both words[0] and words[1] remain 0 — magnitude correctly becomes 0. + // When the BigInt exceeds 128 bits, getValueBigintWords returns + // error.Overflow but NAPI has still filled the two lower words, so + // swallowing the error gives BigInt.asIntN(128) truncation semantics. + var words: [2]u64 = .{ 0, 0 }; + _ = self.val.getValueBigintWords(&sign_bit, &words) catch |err| switch (err) { + error.Overflow => {}, + else => |e| return e, + }; + const lo: u128 = words[0]; + const hi: u128 = words[1]; + const magnitude: u128 = (hi << 64) | lo; + if (sign_bit == 1) { + // Negative: result = -magnitude interpreted as i128. + // Use wrapping subtraction + bitcast to handle both minInt(i128) + // (magnitude == 2^127, which doesn't fit in i128 as positive) and + // out-of-range values (magnitude > 2^127, truncated to low 128 bits). + // Guard: out-of-range negatives whose low 128 bits are all zero + // (e.g. -2^128n → words [0, 0]) give magnitude == 0. asIntN(128) + // of such values is 0n; return early rather than relying on the + // `0 -% 0 == 0` coincidence below. + if (magnitude == 0) return 0; + return @bitCast(0 -% magnitude); + } + // Positive: bitcast u128 → i128 gives BigInt.asIntN(128) semantics. + // In-range values have magnitude ≤ 2^127-1 so the sign bit is clear; + // out-of-range values have the sign bit set, matching JS asIntN(128). + return @bitCast(magnitude); + } + /// Converts the JavaScript BigInt to a Zig `i64`, panicking on failure. /// /// This is a convenience method for cases where the BigInt is guaranteed - /// to be representable as an `i64` without loss. + /// to be exactly representable as an `i64`. pub fn assertI64(self: BigInt) i64 { - return self.toI64(null) catch @panic("BigInt.assertI64 failed"); + return self.toI64() catch @panic("BigInt.assertI64 failed"); } /// Converts the JavaScript BigInt to a Zig `u64`, panicking on failure. /// /// This is a convenience method for cases where the BigInt is guaranteed - /// to be representable as a `u64` without loss. + /// to be exactly representable as a `u64`. pub fn assertU64(self: BigInt) u64 { - return self.toU64(null) catch @panic("BigInt.assertU64 failed"); + return self.toU64() catch @panic("BigInt.assertU64 failed"); } /// Converts the JavaScript BigInt to a Zig `i128`, panicking on failure. /// /// This is a convenience method for cases where the BigInt is guaranteed - /// to be representable as an `i128`. + /// to be exactly representable as an `i128`. pub fn assertI128(self: BigInt) i128 { return self.toI128() catch @panic("BigInt.assertI128 failed"); } @@ -84,8 +153,7 @@ pub const BigInt = struct { /// Creates a JavaScript `BigInt` from a Zig integer value. /// /// Accepts `i64`, `u64`, and `comptime_int` values within this range. - /// For `i128`/`u128` values, use `fromWords` (not currently exposed but - /// available at the `napi.Env` level). + /// For `i128`/`u128` values, use `fromWords`. /// /// Panics if N-API operations fail (e.g., invalid environment) or for Zig /// integer types larger than `u64` that cannot be converted to `i64`/`u64`. @@ -118,6 +186,23 @@ pub const BigInt = struct { return .{ .val = val }; } + /// Creates a JavaScript `BigInt` from a sign bit and word array. + /// + /// `sign_bit` is 0 for non-negative, 1 for negative. `words` is the + /// little-endian unsigned magnitude. The resulting BigInt equals + /// `(sign_bit == 1 ? -1 : 1) * sum(words[i] << (64 * i))`. + /// + /// Use this for constructing BigInts whose magnitude exceeds `u64` (e.g., + /// i128 or larger). For `i64`/`u64` values, prefer `BigInt.from`. + /// + /// Panics if N-API operations fail (e.g., invalid environment). + pub fn fromWords(sign_bit: u1, words: []const u64) BigInt { + const e = context.env(); + const val = e.createBigintWords(sign_bit, words) catch + @panic("BigInt.fromWords: createBigintWords failed"); + return .{ .val = val }; + } + /// Returns the underlying `napi.Value` representation of this JavaScript BigInt. pub fn toValue(self: BigInt) napi.Value { return self.val; diff --git a/src/js/number.zig b/src/js/number.zig index aca3b0b..9a4d358 100644 --- a/src/js/number.zig +++ b/src/js/number.zig @@ -27,19 +27,24 @@ pub const Number = struct { /// Attempts to convert the JavaScript number to a Zig `i32`. /// - /// Returns an error if the conversion fails (e.g., number is too large). + /// Follows N-API int32 conversion semantics: finite values outside the + /// 32-bit range are truncated to the equivalent low 32 bits, and non-finite + /// values (`NaN`, `+Infinity`, `-Infinity`) become 0. + /// + /// Returns an error if the underlying value is not a number or the N-API + /// call fails. pub fn toI32(self: Number) !i32 { return self.val.getValueInt32(); } /// Attempts to convert the JavaScript number to a Zig `u32`. /// - /// Safety: The underlying napi implementation does not reject numbers out of - /// the range [0, 2^32). The caller is responsible for bounds checking. - /// - /// Returns an error if the given `self.val` is not a number. + /// Follows N-API uint32 conversion semantics: finite values outside the + /// 32-bit range are truncated to the equivalent low 32 bits, and non-finite + /// values (`NaN`, `+Infinity`, `-Infinity`) become 0. /// - /// Source: https://tc39.es/ecma262/#sec-touint32 + /// Returns an error if the underlying value is not a number or the N-API + /// call fails. pub fn toU32(self: Number) !u32 { return self.val.getValueUint32(); } @@ -60,7 +65,7 @@ pub const Number = struct { value > max or @trunc(value) != value) { - return error.InvalidUnsignedInteger; + return error.LossyConversion; } return @intFromFloat(value); } @@ -69,7 +74,7 @@ pub const Number = struct { /// /// Exact across the whole `u32` range. /// - /// Returns `error.InvalidUnsignedInteger` if the number is negative, + /// Returns `error.LossyConversion` if the number is negative, /// fractional, non-finite, or greater than `std.math.maxInt(u32)`. pub fn toU32Exact(self: Number) !u32 { return self.toUnsignedExact(u32, std.math.maxInt(u32)); @@ -78,7 +83,7 @@ pub const Number = struct { /// Attempts to convert the JavaScript number to a non-negative JS safe /// integer, returned as a `u64`. /// - /// Returns `error.InvalidUnsignedInteger` if the number is negative, + /// Returns `error.LossyConversion` if the number is negative, /// fractional, non-finite, or greater than `Number.MAX_SAFE_INTEGER` /// (2^53 - 1). /// @@ -98,7 +103,13 @@ pub const Number = struct { /// Attempts to convert the JavaScript number to a Zig `i64`. /// - /// Returns an error if the conversion fails (e.g., number is too large). + /// Follows N-API int64 conversion semantics: non-finite values (`NaN`, + /// `+Infinity`, `-Infinity`) become 0. Values outside JavaScript's safe + /// integer range may lose precision, and finite values outside the `i64` + /// range are clamped by Node to `minInt(i64)`/`maxInt(i64)`. + /// + /// Returns an error if the underlying value is not a number or the N-API + /// call fails. pub fn toI64(self: Number) !i64 { return self.val.getValueInt64(); }