mirror of
https://github.com/lightpanda-io/browser.git
synced 2025-10-29 07:03:29 +00:00
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:
2
.github/actions/install/action.yml
vendored
2
.github/actions/install/action.yml
vendored
@@ -17,7 +17,7 @@ inputs:
|
|||||||
zig-v8:
|
zig-v8:
|
||||||
description: 'zig v8 version to install'
|
description: 'zig v8 version to install'
|
||||||
required: false
|
required: false
|
||||||
default: 'v0.1.19'
|
default: 'v0.1.20'
|
||||||
v8:
|
v8:
|
||||||
description: 'v8 version to install'
|
description: 'v8 version to install'
|
||||||
required: false
|
required: false
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ ARG ZIG=0.14.0
|
|||||||
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
|
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
|
||||||
ARG ARCH=x86_64
|
ARG ARCH=x86_64
|
||||||
ARG V8=11.1.134
|
ARG V8=11.1.134
|
||||||
ARG ZIG_V8=v0.1.19
|
ARG ZIG_V8=v0.1.20
|
||||||
|
|
||||||
RUN apt-get update -yq && \
|
RUN apt-get update -yq && \
|
||||||
apt-get install -yq xz-utils \
|
apt-get install -yq xz-utils \
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
.hash = "tigerbeetle_io-0.0.0-ViLgxpyRBAB5BMfIcj3KMXfbJzwARs9uSl8aRy2OXULd",
|
.hash = "tigerbeetle_io-0.0.0-ViLgxpyRBAB5BMfIcj3KMXfbJzwARs9uSl8aRy2OXULd",
|
||||||
},
|
},
|
||||||
.v8 = .{
|
.v8 = .{
|
||||||
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/363e2899e6d782ad999edbfae048228871230467.tar.gz",
|
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/f0c7eaaffe39f2f1a224fbe97e550daca0ca1801.tar.gz",
|
||||||
.hash = "v8-0.0.0-xddH6wHzIAARDy1uFvPqqBpTXzhlnEGDTuX9IAUQz3oU",
|
.hash = "v8-0.0.0-xddH62T4IADchAHFgo4nx79w1VedNDhIVErtSNgup-Tk",
|
||||||
},
|
},
|
||||||
//.v8 = .{ .path = "../zig-v8-fork" },
|
//.v8 = .{ .path = "../zig-v8-fork" },
|
||||||
//.tigerbeetle_io = .{ .path = "../tigerbeetle-io" },
|
//.tigerbeetle_io = .{ .path = "../tigerbeetle-io" },
|
||||||
|
|||||||
@@ -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 {
|
fn jsValueToZig(self: *const Self, comptime named_function: anytype, comptime T: type, js_value: v8.Value) !T {
|
||||||
switch (@typeInfo(T)) {
|
switch (@typeInfo(T)) {
|
||||||
.optional => |o| {
|
.optional => |o| {
|
||||||
if (js_value.isNull() or js_value.isUndefined()) {
|
if (js_value.isNullOrUndefined()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return try self.jsValueToZig(named_function, o.child, js_value);
|
return try self.jsValueToZig(named_function, o.child, js_value);
|
||||||
@@ -2093,11 +2093,8 @@ fn Caller(comptime E: type) type {
|
|||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
}
|
}
|
||||||
if (@hasField(TypeLookup, @typeName(ptr.child))) {
|
if (@hasField(TypeLookup, @typeName(ptr.child))) {
|
||||||
const obj = js_value.castTo(v8.Object);
|
const js_obj = js_value.castTo(v8.Object);
|
||||||
if (obj.internalFieldCount() == 0) {
|
return E.typeTaggedAnyOpaque(named_function, *Receiver(ptr.child), js_obj);
|
||||||
return error.InvalidArgument;
|
|
||||||
}
|
|
||||||
return E.typeTaggedAnyOpaque(named_function, *Receiver(ptr.child), obj);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.slice => {
|
.slice => {
|
||||||
@@ -2193,58 +2190,50 @@ fn Caller(comptime E: type) type {
|
|||||||
},
|
},
|
||||||
else => {},
|
else => {},
|
||||||
},
|
},
|
||||||
.@"struct" => |s| {
|
.@"struct" => {
|
||||||
if (@hasDecl(T, "_CALLBACK_ID_KLUDGE")) {
|
return try (self.jsValueToStruct(named_function, T, js_value)) orelse {
|
||||||
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 error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
}
|
};
|
||||||
|
},
|
||||||
|
.@"union" => |u| {
|
||||||
|
// see probeJsValueToZig for some explanation of what we're
|
||||||
|
// trying to do
|
||||||
|
|
||||||
const context = self.context;
|
// the first field that we find which the js_value could be
|
||||||
const isolate = self.isolate;
|
// coerced to.
|
||||||
|
var coerce_index: ?usize = null;
|
||||||
|
|
||||||
var value: T = undefined;
|
// the first field that we find which the js_Value is
|
||||||
inline for (s.fields) |field| {
|
// compatible with. A compatible field has higher precedence
|
||||||
const name = field.name;
|
// than a coercible, but still isn't a perfect match.
|
||||||
const key = v8.String.initUtf8(isolate, name);
|
var compatible_index: ?usize = null;
|
||||||
if (js_obj.has(context, key.toValue())) {
|
|
||||||
@field(value, name) = try self.jsValueToZig(named_function, field.type, try js_obj.getValue(context, key));
|
inline for (u.fields, 0..) |field, i| {
|
||||||
} else if (@typeInfo(field.type) == .optional) {
|
switch (try self.probeJsValueToZig(named_function, field.type, js_value)) {
|
||||||
@field(value, name) = null;
|
.value => |v| return @unionInit(T, field.name, v),
|
||||||
} else {
|
.ok => {
|
||||||
if (field.defaultValue()) |dflt| {
|
// a perfect match like above case, except the probing
|
||||||
@field(value, name) = dflt;
|
// didn't get the value for us.
|
||||||
} else {
|
return @unionInit(T, field.name, try self.jsValueToZig(named_function, field.type, js_value));
|
||||||
return error.JSWrongObject;
|
},
|
||||||
}
|
.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 => {},
|
else => {},
|
||||||
}
|
}
|
||||||
@@ -2299,6 +2288,230 @@ fn Caller(comptime E: type) type {
|
|||||||
return error.InvalidArgument;
|
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 {
|
fn zigValueToJs(self: *const Self, value: anytype) !v8.Value {
|
||||||
return self.scope.zigValueToJs(value);
|
return self.scope.zigValueToJs(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
const IterableU8 = Iterable(u8);
|
||||||
|
|
||||||
pub fn Iterable(comptime T: type) type {
|
pub fn Iterable(comptime T: type) type {
|
||||||
@@ -209,6 +233,7 @@ test "JS: complex types" {
|
|||||||
MyErrorUnion,
|
MyErrorUnion,
|
||||||
MyException,
|
MyException,
|
||||||
MyTypeWithException,
|
MyTypeWithException,
|
||||||
|
MyUnionType,
|
||||||
}).init(.{ .arena = arena.allocator() }, {});
|
}).init(.{ .arena = arena.allocator() }, {});
|
||||||
|
|
||||||
defer runner.deinit();
|
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 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" },
|
.{ "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" },
|
||||||
|
}, .{});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user