Skip to content
Draft
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
16 changes: 8 additions & 8 deletions examples/js_dsl/mod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
});

Expand All @@ -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", () => {
Expand Down Expand Up @@ -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");
});
});

Expand Down
3 changes: 1 addition & 2 deletions examples/js_dsl/mod.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
7 changes: 5 additions & 2 deletions src/Value.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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];
}

Expand Down
143 changes: 114 additions & 29 deletions src/js/bigint.zig
Original file line number Diff line number Diff line change
Expand Up @@ -14,78 +14,146 @@ 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");
}

/// 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`.
Expand Down Expand Up @@ -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;
Expand Down
31 changes: 21 additions & 10 deletions src/js/number.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -60,7 +65,7 @@ pub const Number = struct {
value > max or
@trunc(value) != value)
{
return error.InvalidUnsignedInteger;
return error.LossyConversion;
}
return @intFromFloat(value);
}
Expand All @@ -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));
Expand All @@ -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).
///
Expand All @@ -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();
}
Expand Down
Loading