Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f52e1c9
chore(deps): add fast-check and test:fuzz script
nazarhussain May 28, 2026
ba7cca7
chore(tests): scaffold fuzz_numeric addon module
nazarhussain May 28, 2026
5838156
test(fuzz): add rtNumberF64 round-trip fuzz target
nazarhussain May 28, 2026
189ed3a
test(fuzz): add Number i32/u32/i64 round-trip properties
nazarhussain May 28, 2026
0f1fa56
test(fuzz): add BigInt i64/u64 round-trip properties
nazarhussain May 28, 2026
76df5ca
test(fuzz): add lossless-flag introspection for toI64/toU64
nazarhussain May 28, 2026
6c2cfed
fix(napi): clamp getValueBigintWords return slice to buffer size
nazarhussain May 28, 2026
6f9bf72
fix(dsl): make BigInt.toI128 total over all BigInts
nazarhussain May 28, 2026
c3e12a1
feat(dsl): add BigInt.fromWords for word-level construction
nazarhussain May 28, 2026
7f7d26e
test(fuzz): add rtBigIntI128 round-trip target
nazarhussain May 28, 2026
34db241
chore: apply zig fmt reorder to @ptrCast(@alignCast)
nazarhussain May 28, 2026
3fd5552
test(fuzz): add BigInt word-level round-trip
nazarhussain May 28, 2026
13456e0
ci: add fuzz tests job
nazarhussain May 28, 2026
b3fcea4
docs(fuzz): document seeds workflow and dev loop
nazarhussain May 28, 2026
e597b91
docs(fuzz): correct seeds README — drop unimplemented __special reviver
nazarhussain May 29, 2026
9ccafd2
chore: restructure oracle code
nazarhussain May 29, 2026
5fb5841
doc: update doc reference for practically accurate doc comments.
nazarhussain May 29, 2026
e472e7f
fix: add to*LowBits interface for lossy values to keep the safety as …
nazarhussain May 29, 2026
558f7c3
test: add uint8array fuzz tests
nazarhussain May 29, 2026
82a3e1b
chore: restructure tests files
nazarhussain May 29, 2026
23674b4
chore: rename fuzz tests script
nazarhussain May 29, 2026
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
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ jobs:
- name: Test
run: pnpm test:js

test-fuzz:
name: Fuzz Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: ./.github/actions/setup-env
- name: Build
run: pnpm build
- name: Fuzz
run: pnpm test:fuzz:round_trip
env:
FUZZ_RUNS: "1000"

lint:
name: Lint
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ c.count; // 1 (getter, not a method call)
| `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
11 changes: 11 additions & 0 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@
.imports = .{.zapi},
.link_libc = true,
},
.test_fuzz_numeric = .{
.root_source_file = "tests/fuzz/mod.zig",
.imports = .{.zapi},
.link_libc = true,
},
},
.libraries = .{
.example_hello_world = .{
Expand All @@ -69,6 +74,12 @@
.linker_allow_shlib_undefined = true,
.dest_sub_path = "example_js_dsl.node",
},
.test_fuzz_numeric = .{
.root_module = .test_fuzz_numeric,
.linkage = .dynamic,
.linker_allow_shlib_undefined = true,
.dest_sub_path = "test_fuzz_numeric.node",
},
},
.tests = .{
.napi = .{ .root_module = .napi },
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 @@ -95,8 +95,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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"build:zig": "zig build",
"test:zig": "zig build test:zapi",
"test:js": "vitest run examples/**/*.test.ts",
"test:fuzz:round_trip": "pnpm build:zig && vitest run tests/**/*.test.ts",
"test": "pnpm test:zig && pnpm test:js",
"lint:zig": "zig fmt src",
"lint:js": "pnpm biome check",
Expand All @@ -34,6 +35,7 @@
"@biomejs/biome": "^2.4.6",
"@chainsafe/biomejs-config": "^1.0.0",
"@types/node": "^24.1.0",
"fast-check": "^4.8.0",
"typescript": "^5.8.3",
"vitest": "^4.0.18"
},
Expand Down
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion src/Value.zig
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,11 @@ pub fn getValueBigintWords(self: Value, sign_bit: ?*u1, words: []u64) NapiError!
);
// napi guarantees raw_sign ∈ {0, 1}
if (sign_bit) |s| s.* = @intCast(raw_sign);
return words[0..word_count];
// `word_count` is set by NAPI to the actual number of 64-bit words in the
// BigInt, which may exceed `words.len` when the value is larger than the
// buffer. NAPI fills `words` up to `words.len`; clamp the returned slice
// so callers get only the words that were actually written.
return words[0..@min(word_count, words.len)];
}

/// https://nodejs.org/api/n-api.html#napi_get_value_external
Expand Down
2 changes: 1 addition & 1 deletion src/create_callback.zig
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ pub fn createCallback(
const is_value_arg = arg_hint == .value or (arg_hint == .auto and param_type == Value);

if (arg_hint == .data) {
args[i] = @alignCast(@ptrCast(info.data));
args[i] = @ptrCast(@alignCast(info.data));
} else if (is_env_arg) {
args[i] = env;
} else if (is_value_arg) {
Expand Down
140 changes: 113 additions & 27 deletions src/js/bigint.zig
Original file line number Diff line number Diff line change
Expand Up @@ -14,75 +14,144 @@ 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.InvalidArg` 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.InvalidArg;
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`.
///
/// Returns `error.InvalidArg` 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.InvalidArg;
return value;
}

/// Converts the JavaScript BigInt to the low 64 bits as 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 {
/// 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.InvalidArg` 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 an error if N-API operations fail.
pub fn toI128(self: BigInt) !i128 {
var sign_bit: u1 = 0;
var words: [2]u64 = .{ 0, 0 };
// Three words are enough to distinguish every in-range i128 from any
// oversized value, even when the lower 128 bits are zero.
var words: [3]u64 = .{ 0, 0, 0 };
const result = try self.val.getValueBigintWords(&sign_bit, &words);
const lo: u128 = result[0];
const hi: u128 = if (result.len > 1) result[1] else 0;
if (result.len > 2) return error.InvalidArg;

const lo: u128 = words[0];
const hi: u128 = words[1];
const magnitude: u128 = (hi << 64) | lo;
if (sign_bit == 1) {
// Negative: negate the magnitude
if (magnitude == 0) return 0;
return -@as(i128, @intCast(magnitude));
if (magnitude > (@as(u128, 1) << 127)) return error.InvalidArg;
return @bitCast(0 -% magnitude);
}

if (magnitude > @as(u128, @intCast(std.math.maxInt(i128)))) {
return error.InvalidArg;
}
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 fills only the
// two lower words (truncation to low 128 bits), giving BigInt.asIntN(128)
// semantics for out-of-range values.
var words: [2]u64 = .{ 0, 0 };
_ = try self.val.getValueBigintWords(&sign_bit, &words);
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 @@ -115,6 +184,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
25 changes: 18 additions & 7 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 @@ -53,7 +58,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
Loading