Support union parameters

There's ambiguity in mapping due to the flexible nature of JavaScript. Hopefully
most types are unambiguous, like a string or am *parser.Node.

We need to "probe" each field to see if it's a possible candidate for the JS
value. On a perfect match, we stop probing and set the appropriate union field.
There are 2 levels of possible matches: candidate and coerce. A "candidate"
match has higher precedence. This is necessary because, in JavaScript, a lot
of things can be coerced to a lot of other, seemingly wrong, things.

For example, say we have this union:

a: i32,
b: bool,

Field `a` is a perfect match for the value 123. And field b is a coerce match
(because, yes, 123 can be coerced to a boolean). So we map it to `a`.

Field `a` is a candidate match for the value 34.2, because float -> int are both
"Numbers" in JavaScript. And field b is a coerce match. So we map it to `a`.

Both field `a` and field `b` are coerce matches for "hello". So we map it to `a`
because it's declared first (this relies on how Zig currently works, but I don't
think the ordering of type declarations is guaranteed, so that's an issue).
This commit is contained in:
Karl Seguin
2025-04-30 18:48:22 +08:00
parent 312189fbde
commit 20e4261aa7
5 changed files with 305 additions and 57 deletions

View File

@@ -17,7 +17,7 @@ inputs:
zig-v8:
description: 'zig v8 version to install'
required: false
default: 'v0.1.19'
default: 'v0.1.20'
v8:
description: 'v8 version to install'
required: false

View File

@@ -5,7 +5,7 @@ ARG ZIG=0.14.0
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG ARCH=x86_64
ARG V8=11.1.134
ARG ZIG_V8=v0.1.19
ARG ZIG_V8=v0.1.20
RUN apt-get update -yq && \
apt-get install -yq xz-utils \

View File

@@ -13,8 +13,8 @@
.hash = "tigerbeetle_io-0.0.0-ViLgxpyRBAB5BMfIcj3KMXfbJzwARs9uSl8aRy2OXULd",
},
.v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/363e2899e6d782ad999edbfae048228871230467.tar.gz",
.hash = "v8-0.0.0-xddH6wHzIAARDy1uFvPqqBpTXzhlnEGDTuX9IAUQz3oU",
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/f0c7eaaffe39f2f1a224fbe97e550daca0ca1801.tar.gz",
.hash = "v8-0.0.0-xddH62T4IADchAHFgo4nx79w1VedNDhIVErtSNgup-Tk",
},
//.v8 = .{ .path = "../zig-v8-fork" },
//.tigerbeetle_io = .{ .path = "../tigerbeetle-io" },

View File

@@ -2075,7 +2075,7 @@ fn Caller(comptime E: type) type {
fn jsValueToZig(self: *const Self, comptime named_function: anytype, comptime T: type, js_value: v8.Value) !T {
switch (@typeInfo(T)) {
.optional => |o| {
if (js_value.isNull() or js_value.isUndefined()) {
if (js_value.isNullOrUndefined()) {
return null;
}
return try self.jsValueToZig(named_function, o.child, js_value);
@@ -2093,11 +2093,8 @@ fn Caller(comptime E: type) type {
return error.InvalidArgument;
}
if (@hasField(TypeLookup, @typeName(ptr.child))) {
const obj = js_value.castTo(v8.Object);
if (obj.internalFieldCount() == 0) {
return error.InvalidArgument;
}
return E.typeTaggedAnyOpaque(named_function, *Receiver(ptr.child), obj);
const js_obj = js_value.castTo(v8.Object);
return E.typeTaggedAnyOpaque(named_function, *Receiver(ptr.child), js_obj);
}
},
.slice => {
@@ -2193,58 +2190,50 @@ fn Caller(comptime E: type) type {
},
else => {},
},
.@"struct" => |s| {
if (@hasDecl(T, "_CALLBACK_ID_KLUDGE")) {
if (!js_value.isFunction()) {
return error.InvalidArgument;
}
const func = v8.Persistent(v8.Function).init(self.isolate, js_value.castTo(v8.Function));
const scope = self.scope;
try scope.trackCallback(func);
return .{
.func = func,
.scope = scope,
.id = js_value.castTo(v8.Object).getIdentityHash(),
};
}
const js_obj = js_value.castTo(v8.Object);
if (comptime isJsObject(T)) {
// Caller wants an opaque JsObject. Probably a parameter
// that it needs to pass back into a callback
return E.JsObject{
.js_obj = js_obj,
.scope = self.scope,
};
}
if (!js_value.isObject()) {
.@"struct" => {
return try (self.jsValueToStruct(named_function, T, js_value)) orelse {
return error.InvalidArgument;
}
};
},
.@"union" => |u| {
// see probeJsValueToZig for some explanation of what we're
// trying to do
const context = self.context;
const isolate = self.isolate;
// the first field that we find which the js_value could be
// coerced to.
var coerce_index: ?usize = null;
var value: T = undefined;
inline for (s.fields) |field| {
const name = field.name;
const key = v8.String.initUtf8(isolate, name);
if (js_obj.has(context, key.toValue())) {
@field(value, name) = try self.jsValueToZig(named_function, field.type, try js_obj.getValue(context, key));
} else if (@typeInfo(field.type) == .optional) {
@field(value, name) = null;
} else {
if (field.defaultValue()) |dflt| {
@field(value, name) = dflt;
} else {
return error.JSWrongObject;
}
// the first field that we find which the js_Value is
// compatible with. A compatible field has higher precedence
// than a coercible, but still isn't a perfect match.
var compatible_index: ?usize = null;
inline for (u.fields, 0..) |field, i| {
switch (try self.probeJsValueToZig(named_function, field.type, js_value)) {
.value => |v| return @unionInit(T, field.name, v),
.ok => {
// a perfect match like above case, except the probing
// didn't get the value for us.
return @unionInit(T, field.name, try self.jsValueToZig(named_function, field.type, js_value));
},
.coerce => if (coerce_index == null) {
coerce_index = i;
},
.compatible => if (compatible_index == null) {
compatible_index = i;
},
.invalid => {},
}
}
return value;
// We didn't find a perfect match.
const closest = compatible_index orelse coerce_index orelse return error.InvalidArgument;
inline for (u.fields, 0..) |field, i| {
if (i == closest) {
return @unionInit(T, field.name, try self.jsValueToZig(named_function, field.type, js_value));
}
}
unreachable;
},
else => {},
}
@@ -2299,6 +2288,230 @@ fn Caller(comptime E: type) type {
return error.InvalidArgument;
}
// Extracted so that it can be used in both jsValueToZig and in
// probeJsValueToZig. Avoids having to duplicate this logic when probing.
fn jsValueToStruct(self: *const Self, comptime named_function: anytype, comptime T: type, js_value: v8.Value) !?T {
if (@hasDecl(T, "_CALLBACK_ID_KLUDGE")) {
if (!js_value.isFunction()) {
return error.InvalidArgument;
}
const func = v8.Persistent(v8.Function).init(self.isolate, js_value.castTo(v8.Function));
const scope = self.scope;
try scope.trackCallback(func);
return .{
.func = func,
.scope = scope,
.id = js_value.castTo(v8.Object).getIdentityHash(),
};
}
const js_obj = js_value.castTo(v8.Object);
if (comptime isJsObject(T)) {
// Caller wants an opaque JsObject. Probably a parameter
// that it needs to pass back into a callback
return E.JsObject{
.js_obj = js_obj,
.scope = self.scope,
};
}
if (!js_value.isObject()) {
return null;
}
const context = self.context;
const isolate = self.isolate;
var value: T = undefined;
inline for (@typeInfo(T).@"struct".fields) |field| {
const name = field.name;
const key = v8.String.initUtf8(isolate, name);
if (js_obj.has(context, key.toValue())) {
@field(value, name) = try self.jsValueToZig(named_function, field.type, try js_obj.getValue(context, key));
} else if (@typeInfo(field.type) == .optional) {
@field(value, name) = null;
} else {
const dflt = field.defaultValue() orelse return null;
@field(value, name) = dflt;
}
}
return value;
}
// Probing is part of trying to map a JS value to a Zig union. There's
// a lot of ambiguity in this process, in part because some JS values
// can almost always be coerced. For example, anything can be coerced
// into an integer (it just becomes 0), or a float (becomes NaN) or a
// string.
//
// The way we'll do this is that, if there's a direct match, we'll use it
// If there's a potential match, we'll keep looking for a direct match
// and only use the (first) potential match as a fallback.
//
// Finally, I considered adding this probing directly into jsValueToZig
// but I decided doing this separately was better. However, the goal is
// obviously that probing is consistent with jsValueToZig.
fn ProbeResult(comptime T: type) type {
return union(enum) {
// The js_value maps directly to T
value: T,
// The value is a T. This is almost the same as returning value: T,
// but the caller still has to get T by calling jsValueToZig.
// We prefer returning .{.ok => {}}, to avoid reducing duplication
// with jsValueToZig, but in some cases where probing has a cost
// AND yields the value anyways, we'll use .{.value = T}.
ok: void,
// the js_value is compatible with T (i.e. a int -> float),
compatible: void,
// the js_value can be coerced to T (this is a lower precedence
// than compatible)
coerce: void,
// the js_value cannot be turned into T
invalid: void,
};
}
fn probeJsValueToZig(self: *const Self, comptime named_function: anytype, comptime T: type, js_value: v8.Value) !ProbeResult(T) {
switch (@typeInfo(T)) {
.optional => |o| {
if (js_value.isNullOrUndefined()) {
return .{ .value = null };
}
return self.probeJsValueToZig(named_function, o.child, js_value);
},
.float => {
if (js_value.isNumber() or js_value.isNumberObject()) {
if (js_value.isInt32() or js_value.isUint32() or js_value.isBigInt() or js_value.isBigIntObject()) {
// int => float is a reasonable match
return .{ .compatible = {} };
}
return .{ .ok = {} };
}
// anything can be coerced into a float, it becomes NaN
return .{ .coerce = {} };
},
.int => {
if (js_value.isNumber() or js_value.isNumberObject()) {
if (js_value.isInt32() or js_value.isUint32() or js_value.isBigInt() or js_value.isBigIntObject()) {
return .{ .ok = {} };
}
// float => int is kind of reasonable, I guess
return .{ .compatible = {} };
}
// anything can be coerced into a int, it becomes 0
return .{ .coerce = {} };
},
.bool => {
if (js_value.isBoolean() or js_value.isBooleanObject()) {
return .{ .ok = {} };
}
// anything can be coerced into a boolean, it will become
// true or false based on..some complex rules I don't know.
return .{ .coerce = {} };
},
.pointer => |ptr| switch (ptr.size) {
.one => {
if (!js_value.isObject()) {
return .{ .invalid = {} };
}
if (@hasField(TypeLookup, @typeName(ptr.child))) {
const js_obj = js_value.castTo(v8.Object);
// There's a bit of overhead in doing this, so instead
// of having a version of typeTaggedAnyOpaque which
// returns a boolean or an optional, we rely on the
// main implementation and just handle the error.
const attempt = E.typeTaggedAnyOpaque(named_function, *Receiver(ptr.child), js_obj);
if (attempt) |value| {
return .{ .value = value };
} else |_| {
return .{ .invalid = {} };
}
}
// probably an error, but not for us to deal with
return .{ .invalid = {} };
},
.slice => {
if (js_value.isTypedArray()) {
switch (ptr.child) {
u8 => if (ptr.sentinel() == null) {
if (js_value.isUint8Array() or js_value.isUint8ClampedArray()) {
return .{ .ok = {} };
}
},
i8 => if (js_value.isInt8Array()) {
return .{ .ok = {} };
},
u16 => if (js_value.isUint16Array()) {
return .{ .ok = {} };
},
i16 => if (js_value.isInt16Array()) {
return .{ .ok = {} };
},
u32 => if (js_value.isUint32Array()) {
return .{ .ok = {} };
},
i32 => if (js_value.isInt32Array()) {
return .{ .ok = {} };
},
u64 => if (js_value.isBigUint64Array()) {
return .{ .ok = {} };
},
i64 => if (js_value.isBigInt64Array()) {
return .{ .ok = {} };
},
else => {},
}
return .{ .invalid = {} };
}
if (ptr.child == u8) {
if (js_value.isString()) {
return .{ .ok = {} };
}
// anything can be coerced into a string
return .{ .coerce = {} };
}
if (!js_value.isArray()) {
return error.InvalidArgument;
}
// This can get tricky.
const js_arr = js_value.castTo(v8.Array);
if (js_arr.length() == 0) {
// not so tricky in this case.
return .{ .value = &.{} };
}
// We settle for just probing the first value. Ok, actually
// not tricky in this case either.
const context = self.contxt;
const js_obj = js_arr.castTo(v8.Object);
return self.probeJsValueToZig(named_function, ptr.child, try js_obj.getAtIndex(context, 0));
},
else => {},
},
.@"struct" => {
// We don't want to duplicate the code for this, so we call
// the actual coversion function.
const value = (try self.jsValueToStruct(named_function, T, js_value)) orelse {
return .{ .invalid = {} };
};
return .{ .value = value };
},
else => {},
}
return .{ .invalid = {} };
}
fn zigValueToJs(self: *const Self, value: anytype) !v8.Value {
return self.scope.zigValueToJs(value);
}

View File

@@ -163,6 +163,30 @@ const MyTypeWithException = struct {
}
};
const MyUnionType = struct {
pub const Choices = union(enum) {
color: []const u8,
number: usize,
boolean: bool,
obj1: *MyList,
obj2: MyUnionType,
};
pub fn constructor() MyUnionType {
return .{};
}
pub fn _choices(_: *const MyUnionType, u: Choices) Choices {
return switch (u) {
.color => .{ .color = "nice" },
.number => |n| .{ .number = n + 10 },
.boolean => |b| .{ .boolean = !b },
.obj1 => |l| .{ .number = l.items.len },
.obj2 => .{ .color = "meta" },
};
}
};
const IterableU8 = Iterable(u8);
pub fn Iterable(comptime T: type) type {
@@ -209,6 +233,7 @@ test "JS: complex types" {
MyErrorUnion,
MyException,
MyTypeWithException,
MyUnionType,
}).init(.{ .arena = arena.allocator() }, {});
defer runner.deinit();
@@ -257,4 +282,14 @@ test "JS: complex types" {
.{ "var mySuperError = ''; try {myTypeWithException.superSetError()} catch (error) {mySuperError = error}", "MyCustomError: Some custom message." },
.{ "var oomError = ''; try {myTypeWithException.outOfMemory()} catch (error) {oomError = error}; oomError", "Error: out of memory" },
}, .{});
try runner.testCases(&.{
.{ "var mut = new MyUnionType()", "undefined" },
.{ "mut.choices(3)", "13" },
.{ "mut.choices('blue')", "nice" },
.{ "mut.choices(true)", "false" },
.{ "mut.choices(false)", "true" },
.{ "mut.choices(mut)", "meta" },
.{ "mut.choices(myList)", "3" },
}, .{});
}