diff --git a/src/browser/dom/dom.zig b/src/browser/dom/dom.zig index 07e57c06..c0099a04 100644 --- a/src/browser/dom/dom.zig +++ b/src/browser/dom/dom.zig @@ -20,7 +20,7 @@ const DOMException = @import("exceptions.zig").DOMException; const EventTarget = @import("event_target.zig").EventTarget; const DOMImplementation = @import("implementation.zig").DOMImplementation; const NamedNodeMap = @import("namednodemap.zig").NamedNodeMap; -const DOMTokenList = @import("token_list.zig").DOMTokenList; +const DOMTokenList = @import("token_list.zig"); const NodeList = @import("nodelist.zig"); const Node = @import("node.zig"); const MutationObserver = @import("mutation_observer.zig"); @@ -30,7 +30,7 @@ pub const Interfaces = .{ EventTarget, DOMImplementation, NamedNodeMap, - DOMTokenList, + DOMTokenList.Interfaces, NodeList.Interfaces, Node.Node, Node.Interfaces, diff --git a/src/browser/dom/html_collection.zig b/src/browser/dom/html_collection.zig index 3a7aa8fa..812b6931 100644 --- a/src/browser/dom/html_collection.zig +++ b/src/browser/dom/html_collection.zig @@ -24,7 +24,7 @@ const utils = @import("utils.z"); const Element = @import("element.zig").Element; const Union = @import("element.zig").Union; -const JsObject = @import("../env.zig").JsObject; +const JsThis = @import("../env.zig").JsThis; const Walker = @import("walker.zig").Walker; const WalkerDepthFirst = @import("walker.zig").WalkerDepthFirst; @@ -443,15 +443,15 @@ pub const HTMLCollection = struct { return null; } - pub fn postAttach(self: *HTMLCollection, js_obj: JsObject) !void { + pub fn postAttach(self: *HTMLCollection, js_this: JsThis) !void { const len = try self.get_length(); for (0..len) |i| { const node = try self.item(@intCast(i)) orelse unreachable; const e = @as(*parser.Element, @ptrCast(node)); - try js_obj.setIndex(@intCast(i), e); + try js_this.setIndex(@intCast(i), e); if (try item_name(e)) |name| { - try js_obj.set(name, e); + try js_this.set(name, e); } } } diff --git a/src/browser/dom/mutation_observer.zig b/src/browser/dom/mutation_observer.zig index ae050fe5..c77be1d8 100644 --- a/src/browser/dom/mutation_observer.zig +++ b/src/browser/dom/mutation_observer.zig @@ -22,7 +22,7 @@ const parser = @import("../netsurf.zig"); const SessionState = @import("../env.zig").SessionState; const Env = @import("../env.zig").Env; -const JsObject = @import("../env.zig").JsObject; +const JsThis = @import("../env.zig").JsThis; const NodeList = @import("nodelist.zig").NodeList; pub const Interfaces = .{ @@ -184,9 +184,9 @@ pub const MutationRecords = struct { return null; }; } - pub fn postAttach(self: *const MutationRecords, js_obj: JsObject) !void { + pub fn postAttach(self: *const MutationRecords, js_this: JsThis) !void { if (self.first) |mr| { - try js_obj.set("0", mr); + try js_this.set("0", mr); } } }; diff --git a/src/browser/dom/nodelist.zig b/src/browser/dom/nodelist.zig index 2d27f4d0..37ef3b31 100644 --- a/src/browser/dom/nodelist.zig +++ b/src/browser/dom/nodelist.zig @@ -20,7 +20,7 @@ const std = @import("std"); const parser = @import("../netsurf.zig"); -const JsObject = @import("../env.zig").JsObject; +const JsThis = @import("../env.zig").JsThis; const Callback = @import("../env.zig").Callback; const SessionState = @import("../env.zig").SessionState; @@ -177,11 +177,11 @@ pub const NodeList = struct { } // TODO entries() https://developer.mozilla.org/en-US/docs/Web/API/NodeList/entries - pub fn postAttach(self: *NodeList, js_obj: JsObject) !void { + pub fn postAttach(self: *NodeList, js_this: JsThis) !void { const len = self.get_length(); for (0..len) |i| { const node = try self._item(@intCast(i)) orelse unreachable; - try js_obj.setIndex(i, node); + try js_this.setIndex(i, node); } } }; diff --git a/src/browser/dom/token_list.zig b/src/browser/dom/token_list.zig index 0472e81e..538c4156 100644 --- a/src/browser/dom/token_list.zig +++ b/src/browser/dom/token_list.zig @@ -19,9 +19,22 @@ const std = @import("std"); const parser = @import("../netsurf.zig"); +const iterator = @import("../iterator/iterator.zig"); +const Callback = @import("../env.zig").Callback; +const JsObject = @import("../env.zig").JsObject; +const SessionState = @import("../env.zig").SessionState; const DOMException = @import("exceptions.zig").DOMException; +const log = std.log.scoped(.token_list); + +pub const Interfaces = .{ + DOMTokenList, + DOMTokenListIterable, + TokenListEntriesIterator, + TokenListEntriesIterator.Iterable, +}; + // https://dom.spec.whatwg.org/#domtokenlist pub const DOMTokenList = struct { pub const Self = parser.TokenList; @@ -98,7 +111,60 @@ pub const DOMTokenList = struct { } pub fn get_value(self: *parser.TokenList) !?[]const u8 { - return try parser.tokenListGetValue(self); + return (try parser.tokenListGetValue(self)) orelse ""; + } + + pub fn set_value(self: *parser.TokenList, value: []const u8) !void { + return parser.tokenListSetValue(self, value); + } + + pub fn _toString(self: *parser.TokenList) ![]const u8 { + return (try get_value(self)) orelse ""; + } + + pub fn _keys(self: *parser.TokenList) !iterator.U32Iterator { + return .{ .length = try get_length(self) }; + } + + pub fn _values(self: *parser.TokenList) DOMTokenListIterable { + return DOMTokenListIterable.init(.{ .token_list = self }); + } + + pub fn _entries(self: *parser.TokenList) TokenListEntriesIterator { + return TokenListEntriesIterator.init(.{ .token_list = self }); + } + + pub fn _symbol_iterator(self: *parser.TokenList) DOMTokenListIterable { + return _values(self); + } + + // TODO handle thisArg + pub fn _forEach(self: *parser.TokenList, cbk: Callback, this_arg: JsObject) !void { + var entries = _entries(self); + while (try entries._next()) |entry| { + var result: Callback.Result = undefined; + cbk.tryCallWithThis(this_arg, .{ entry.@"1", entry.@"0", self }, &result) catch { + log.err("callback error: {s}", .{result.exception}); + log.debug("stack:\n{s}", .{result.stack orelse "???"}); + }; + } + } +}; + +const DOMTokenListIterable = iterator.Iterable(Iterator, "DOMTokenListIterable"); +const TokenListEntriesIterator = iterator.NumericEntries(Iterator, "TokenListEntriesIterator"); + +pub const Iterator = struct { + index: u32 = 0, + token_list: *parser.TokenList, + + // used when wrapped in an iterator.NumericEntries + pub const Error = parser.DOMError; + + pub fn _next(self: *Iterator) !?[]const u8 { + const index = self.index; + self.index = index + 1; + return DOMTokenList._item(self.token_list, index); } }; @@ -150,4 +216,29 @@ test "Browser.DOM.TokenList" { .{ "cl4.replace('nok', 'ok')", "true" }, .{ "cl4.value", "empty ok" }, }, .{}); + + try runner.testCases(&.{ + .{ "let cl5 = gs.classList", "undefined" }, + .{ "let keys = [...cl5.keys()]", "undefined" }, + .{ "keys.length", "2" }, + .{ "keys[0]", "0" }, + .{ "keys[1]", "1" }, + + .{ "let values = [...cl5.values()]", "undefined" }, + .{ "values.length", "2" }, + .{ "values[0]", "empty" }, + .{ "values[1]", "ok" }, + + .{ "let entries = [...cl5.entries()]", "undefined" }, + .{ "entries.length", "2" }, + .{ "entries[0]", "0,empty" }, + .{ "entries[1]", "1,ok" }, + }, .{}); + + try runner.testCases(&.{ + .{ "let cl6 = gs.classList", "undefined" }, + .{ "cl6.value = 'a b ccc'", "a b ccc" }, + .{ "cl6.value", "a b ccc" }, + .{ "cl6.toString()", "a b ccc" }, + }, .{}); } diff --git a/src/browser/env.zig b/src/browser/env.zig index 0fd4370d..67e7207e 100644 --- a/src/browser/env.zig +++ b/src/browser/env.zig @@ -21,6 +21,7 @@ const Interfaces = generate.Tuple(.{ @import("xmlserializer/xmlserializer.zig").Interfaces, }); +pub const JsThis = Env.JsThis; pub const JsObject = Env.JsObject; pub const Callback = Env.Callback; pub const Env = js.Env(*SessionState, Interfaces{}); diff --git a/src/browser/iterator/iterator.zig b/src/browser/iterator/iterator.zig index aa248509..cb7a1c04 100644 --- a/src/browser/iterator/iterator.zig +++ b/src/browser/iterator/iterator.zig @@ -28,24 +28,201 @@ pub const U32Iterator = struct { .done = false, }; } + + // Iterators should be iterable. There's a [JS] example on MDN that + // suggests this is the correct approach: + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_iterator_protocol + pub fn _symbol_iterator(self: *U32Iterator) *U32Iterator { + return self; + } }; -const testing = std.testing; -test "U32Iterator" { - const Return = U32Iterator.Return; +// A wrapper around an iterator that emits an Iterable result +// An iterable has a next() which emits a {done: bool, value: T} result +pub fn Iterable(comptime T: type, comptime JsName: []const u8) type { + // The inner iterator's return type. + // Maybe an error union. + // Definitely an optional + const RawValue = @typeInfo(@TypeOf(T._next)).@"fn".return_type.?; + const CanError = @typeInfo(RawValue) == .error_union; + const Value = blk: { + // Unwrap the RawValue + var V = RawValue; + if (CanError) { + V = @typeInfo(V).error_union.payload; + } + break :blk @typeInfo(V).optional.child; + }; + + const Result = struct { + done: bool, + // todo, technically, we should return undefined when done = true + // or even omit the value; + value: ?Value, + }; + + const ReturnType = if (CanError) T.Error!Result else Result; + + return struct { + // the inner value iterator + inner: T, + + // Generics don't generate clean names. Can't just take the resulting + // type name and use that as a the JS class name. So we always ask for + // an explicit JS class name + pub const js_name = JsName; + + const Self = @This(); + + pub fn init(inner: T) Self { + return .{ .inner = inner }; + } + + pub fn _next(self: *Self) ReturnType { + const value = if (comptime CanError) try self.inner._next() else self.inner._next(); + return .{ .done = value == null, .value = value }; + } + + pub fn _symbol_iterator(self: *Self) *Self { + return self; + } + }; +} + +// A wrapper around an iterator that emits integer/index keyed entries. +pub fn NumericEntries(comptime T: type, comptime JsName: []const u8) type { + // The inner iterator's return type. + // Maybe an error union. + // Definitely an optional + const RawValue = @typeInfo(@TypeOf(T._next)).@"fn".return_type.?; + const CanError = @typeInfo(RawValue) == .error_union; + + const Value = blk: { + // Unwrap the RawValue + var V = RawValue; + if (CanError) { + V = @typeInfo(V).error_union.payload; + } + break :blk @typeInfo(V).optional.child; + }; + + const ReturnType = if (CanError) T.Error!?struct { u32, Value } else ?struct { u32, Value }; + + // Avoid ambiguity. We want to expose a NumericEntries(T).Iterable, so we + // need a declartion inside here for an "Iterable", but that will conflict + // with the above Iterable generic function we have. + const BaseIterable = Iterable; + + return struct { + // the inner value iterator + inner: T, + index: u32, + + const Self = @This(); + + // Generics don't generate clean names. Can't just take the resulting + // type name and use that as a the JS class name. So we always ask for + // an explicit JS class name + pub const js_name = JsName; + + // re-exposed for when/if we compose this type into an Iterable + pub const Error = T.Error; + + // This iterator as an iterable + pub const Iterable = BaseIterable(Self, JsName ++ "Iterable"); + + pub fn init(inner: T) Self { + return .{ .inner = inner, .index = 0 }; + } + + pub fn _next(self: *Self) ReturnType { + const value_ = if (comptime CanError) try self.inner._next() else self.inner._next(); + const value = value_ orelse return null; + + const index = self.index; + self.index = index + 1; + return .{ index, value }; + } + + // make the iterator, iterable + pub fn _symbol_iterator(self: *Self) Self.Iterable { + return Self.Iterable.init(self.*); + } + }; +} + +const testing = @import("../../testing.zig"); +test "U32Iterator" { { var it = U32Iterator{ .length = 0 }; - try testing.expectEqual(Return{ .value = 0, .done = true }, it._next()); - try testing.expectEqual(Return{ .value = 0, .done = true }, it._next()); + try testing.expectEqual(.{ .value = 0, .done = true }, it._next()); + try testing.expectEqual(.{ .value = 0, .done = true }, it._next()); } { var it = U32Iterator{ .length = 3 }; - try testing.expectEqual(Return{ .value = 0, .done = false }, it._next()); - try testing.expectEqual(Return{ .value = 1, .done = false }, it._next()); - try testing.expectEqual(Return{ .value = 2, .done = false }, it._next()); - try testing.expectEqual(Return{ .value = 0, .done = true }, it._next()); - try testing.expectEqual(Return{ .value = 0, .done = true }, it._next()); + try testing.expectEqual(.{ .value = 0, .done = false }, it._next()); + try testing.expectEqual(.{ .value = 1, .done = false }, it._next()); + try testing.expectEqual(.{ .value = 2, .done = false }, it._next()); + try testing.expectEqual(.{ .value = 0, .done = true }, it._next()); + try testing.expectEqual(.{ .value = 0, .done = true }, it._next()); } } + +test "NumericEntries" { + const it = DummyIterator{}; + var entries = NumericEntries(DummyIterator, "DummyIterator").init(it); + + const v1 = entries._next().?; + try testing.expectEqual(0, v1.@"0"); + try testing.expectEqual("it's", v1.@"1"); + + const v2 = entries._next().?; + try testing.expectEqual(1, v2.@"0"); + try testing.expectEqual("over", v2.@"1"); + + const v3 = entries._next().?; + try testing.expectEqual(2, v3.@"0"); + try testing.expectEqual("9000!!", v3.@"1"); + + try testing.expectEqual(null, entries._next()); + try testing.expectEqual(null, entries._next()); + try testing.expectEqual(null, entries._next()); +} + +test "Iterable" { + const it = DummyIterator{}; + var entries = Iterable(DummyIterator, "DummyIterator").init(it); + + const v1 = entries._next(); + try testing.expectEqual(false, v1.done); + try testing.expectEqual("it's", v1.value.?); + + const v2 = entries._next(); + try testing.expectEqual(false, v2.done); + try testing.expectEqual("over", v2.value.?); + + const v3 = entries._next(); + try testing.expectEqual(false, v3.done); + try testing.expectEqual("9000!!", v3.value.?); + + try testing.expectEqual(true, entries._next().done); + try testing.expectEqual(true, entries._next().done); + try testing.expectEqual(true, entries._next().done); +} + +const DummyIterator = struct { + index: u32 = 0, + + pub fn _next(self: *DummyIterator) ?[]const u8 { + const index = self.index; + self.index = index + 1; + return switch (index) { + 0 => "it's", + 1 => "over", + 2 => "9000!!", + else => null, + }; + } +}; diff --git a/src/browser/netsurf.zig b/src/browser/netsurf.zig index f08a6b73..27b2c499 100644 --- a/src/browser/netsurf.zig +++ b/src/browser/netsurf.zig @@ -1697,6 +1697,11 @@ pub fn tokenListGetValue(l: *TokenList) !?[]const u8 { return strToData(res.?); } +pub fn tokenListSetValue(l: *TokenList, value: []const u8) !void { + const err = c.dom_tokenlist_set_value(l, try strFromData(value)); + try DOMErr(err); +} + // ElementHTML pub const ElementHTML = c.dom_html_element; diff --git a/src/runtime/js.zig b/src/runtime/js.zig index 4a267e75..9c723faa 100644 --- a/src/runtime/js.zig +++ b/src/runtime/js.zig @@ -690,6 +690,19 @@ pub fn Env(comptime S: type, comptime types: anytype) type { return value.func.toValue(); } + if (s.is_tuple) { + // return the tuple struct as an array + var js_arr = v8.Array.init(isolate, @intCast(s.fields.len)); + var js_obj = js_arr.castTo(v8.Object); + inline for (s.fields, 0..) |f, i| { + const js_val = try zigValueToJs(templates, isolate, context, @field(value, f.name)); + if (js_obj.setValueAtIndex(context, @intCast(i), js_val) == false) { + return error.FailedToCreateArray; + } + } + return js_obj.toValue(); + } + // return the struct as a JS object const js_obj = v8.Object.init(isolate); inline for (s.fields) |f| { @@ -888,6 +901,27 @@ pub fn Env(comptime S: type, comptime types: anytype) type { _ = self.scope_arena.reset(.{ .retain_with_limit = 1024 * 64 }); } + // Given an anytype, turns it into a v8.Object. The anytype could be: + // 1 - A V8.object already + // 2 - Our this JsObject wrapper around a V8.Object + // 3 - A zig instance that has previously been given to V8 + // (i.e., the value has to be known to the executor) + fn valueToExistingObject(self: *const Executor, value: anytype) !v8.Object { + if (@TypeOf(value) == v8.Object) { + return value; + } + + if (@TypeOf(value) == JsObject) { + return value.js_obj; + } + + const persistent_object = self.scope.?.identity_map.get(@intFromPtr(value)) orelse { + return error.InvalidThisForCallback; + }; + + return persistent_object.castToObject(); + } + // Wrap a v8.Value, largely so that we can provide a convenient // toString function fn createValue(self: *const Executor, value: v8.Value) Value { @@ -989,7 +1023,7 @@ pub fn Env(comptime S: type, comptime types: anytype) type { gop.value_ptr.* = js_persistent; if (@hasDecl(ptr.child, "postAttach")) { - const obj_wrap = JsObject{ .js_obj = js_obj, .executor = self }; + const obj_wrap = JsThis{ .obj = .{ .js_obj = js_obj, .executor = self } }; switch (@typeInfo(@TypeOf(ptr.child.postAttach)).@"fn".params.len) { 2 => try value.postAttach(obj_wrap), 3 => try value.postAttach(self.state, obj_wrap), @@ -1080,7 +1114,7 @@ pub fn Env(comptime S: type, comptime types: anytype) type { pub const Callback = struct { id: usize, executor: *Executor, - this: ?v8.Object = null, + _this: ?v8.Object = null, func: PersistentFunction, // We use this when mapping a JS value to a Zig object. We can't @@ -1096,22 +1130,23 @@ pub fn Env(comptime S: type, comptime types: anytype) type { }; pub fn setThis(self: *Callback, value: anytype) !void { - const persistent_object = self.executor.scope.?.identity_map.get(@intFromPtr(value)) orelse { - return error.InvalidThisForCallback; - }; - self.this = persistent_object.castToObject(); + self._this = try self.executor.valueToExistingObject(value); } pub fn call(self: *const Callback, args: anytype) !void { - return self.callWithThis(self.this orelse self.executor.context.getGlobal(), args); + return self.callWithThis(self.getThis(), args); } pub fn tryCall(self: *const Callback, args: anytype, result: *Result) !void { + return self.tryCallWithThis(self.getThis(), args, result); + } + + pub fn tryCallWithThis(self: *const Callback, this: anytype, args: anytype, result: *Result) !void { var try_catch: TryCatch = undefined; try_catch.init(self.executor); defer try_catch.deinit(); - self.call(args) catch |err| { + self.callWithThis(this, args) catch |err| { if (try_catch.hasCaught()) { const allocator = self.executor.scope.?.call_arena; result.stack = try_catch.stack(allocator) catch null; @@ -1124,9 +1159,11 @@ pub fn Env(comptime S: type, comptime types: anytype) type { }; } - fn callWithThis(self: *const @This(), js_this: v8.Object, args: anytype) !void { + pub fn callWithThis(self: *const Callback, this: anytype, args: anytype) !void { const executor = self.executor; + const js_this = try executor.valueToExistingObject(this); + const aargs = if (comptime @typeInfo(@TypeOf(args)) == .null) struct {}{} else args; const fields = @typeInfo(@TypeOf(aargs)).@"struct".fields; var js_args: [fields.len]v8.Value = undefined; @@ -1140,8 +1177,12 @@ pub fn Env(comptime S: type, comptime types: anytype) type { } } + fn getThis(self: *const Callback) v8.Object { + return self._this orelse self.executor.context.getGlobal(); + } + // debug/helper to print the source of the JS callback - fn printFunc(self: *const @This()) !void { + fn printFunc(self: Callback) !void { const executor = self.executor; const value = self.func.castToFunction().toValue(); const src = try valueToString(executor.call_arena.allocator(), value, executor.isolate, executor.context); @@ -1184,6 +1225,28 @@ pub fn Env(comptime S: type, comptime types: anytype) type { } }; + // This only exists so that we know whether a function wants the opaque + // JS argument (JsObject), or if it wants the receiver as an opaque + // value. + // JsObject is normally used when a method wants an opaque JS object + // that it'll pass into a callback. + // JsThis is used when the function wants to do advanced manipulation + // of the v8.Object bound to the instance. For example, postAttach is an + // example of using JsThis. + pub const JsThis = struct { + obj: JsObject, + + const _JSTHIS_ID_KLUDGE = true; + + pub fn setIndex(self: JsThis, index: usize, value: anytype) !void { + return self.obj.setIndex(index, value); + } + + pub fn set(self: JsThis, key: []const u8, value: anytype) !void { + return self.obj.set(key, value); + } + }; + pub const TryCatch = struct { inner: v8.TryCatch, executor: *const Executor, @@ -1747,14 +1810,14 @@ fn Caller(comptime E: type) type { break :blk params[0 .. params.len - 1]; } - // If the last parameter is a JsObject, set it, and exclude it + // If the last parameter is a special JsThis, set it, and exclude it // from our params slice, because we don't want to bind it to // a JS argument - if (comptime isJsObject(params[params.len - 1].type.?)) { - @field(args, std.fmt.comptimePrint("{d}", .{params.len - 1 + offset})) = .{ - .handle = info.getThis(), + if (comptime isJsThis(params[params.len - 1].type.?)) { + @field(args, std.fmt.comptimePrint("{d}", .{params.len - 1 + offset})) = .{ .obj = .{ + .js_obj = info.getThis(), .executor = self.executor, - }; + } }; // AND the 2nd last parameter is state if (params.len > 1 and comptime isState(params[params.len - 2].type.?)) { @@ -1813,9 +1876,9 @@ fn Caller(comptime E: type) type { } if (comptime isState(param.type.?)) { - @compileError("State must be the last parameter (or 2nd last if there's a JsObject): " ++ named_function.full_name); - } else if (comptime isJsObject(param.type.?)) { - @compileError("JsObject must be the last parameter: " ++ named_function.full_name); + @compileError("State must be the last parameter (or 2nd last if there's a JsThis): " ++ named_function.full_name); + } else if (comptime isJsThis(param.type.?)) { + @compileError("JsThis must be the last parameter: " ++ named_function.full_name); } else if (i >= js_parameter_count) { if (@typeInfo(param.type.?) != .optional) { return error.InvalidArgument; @@ -1915,9 +1978,20 @@ fn Caller(comptime E: type) type { if (!js_value.isObject()) { return error.InvalidArgument; } + + 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, + .executor = self.executor, + }; + } + const context = self.context; const isolate = self.isolate; - const js_obj = js_value.castTo(v8.Object); var value: T = undefined; inline for (s.fields) |field| { @@ -2003,6 +2077,10 @@ fn Caller(comptime E: type) type { fn isJsObject(comptime T: type) bool { return @typeInfo(T) == .@"struct" and @hasDecl(T, "_JSOBJECT_ID_KLUDGE"); } + + fn isJsThis(comptime T: type) bool { + return @typeInfo(T) == .@"struct" and @hasDecl(T, "_JSTHIS_ID_KLUDGE"); + } }; }