From a94e4dbf002413d4b7d87fa138a69003292f2ebb Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Thu, 26 Mar 2026 21:38:52 +0100 Subject: [PATCH] Implement DOM-JS bridge: document object and element access Add the bridge between the JS engine and the DOM crate (Phase 11, issue 2 of 8). - DomBridge struct holds shared Document and wrapper identity cache - Vm::attach_document() registers document global with structural properties - document.documentElement, .head, .body, .title properties - document.getElementById, getElementsByTagName, getElementsByClassName - document.querySelector/querySelectorAll (reuses css/style selector matching) - document.createElement, createTextNode factory methods - Element wrappers with tagName, nodeName, nodeType, id, className - Wrapper identity: same NodeId always returns same JS object - NodeId::from_index added to dom crate - Parser::parse_selectors added to css crate - 20 tests covering all acceptance criteria Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 3 + crates/css/src/parser.rs | 7 + crates/dom/src/lib.rs | 5 + crates/js/Cargo.toml | 5 + crates/js/src/builtins.rs | 9 +- crates/js/src/dom_bridge.rs | 765 ++++++++++++++++++++++++++++++++++++ crates/js/src/lib.rs | 1 + crates/js/src/vm.rs | 30 ++ 8 files changed, 823 insertions(+), 2 deletions(-) create mode 100644 crates/js/src/dom_bridge.rs diff --git a/Cargo.lock b/Cargo.lock index d54ece9..67794d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,7 +53,10 @@ version = "0.1.0" name = "we-js" version = "0.1.0" dependencies = [ + "we-css", "we-dom", + "we-html", + "we-style", ] [[package]] diff --git a/crates/css/src/parser.rs b/crates/css/src/parser.rs index d80cea3..8fdbfc2 100644 --- a/crates/css/src/parser.rs +++ b/crates/css/src/parser.rs @@ -165,6 +165,13 @@ impl Parser { parser.parse_declaration_list() } + /// Parse a selector list from a string (for `querySelector`/`querySelectorAll`). + pub fn parse_selectors(input: &str) -> SelectorList { + let tokens = Tokenizer::tokenize(input); + let mut parser = Self { tokens, pos: 0 }; + parser.parse_selector_list() + } + // -- Token access ------------------------------------------------------- fn peek(&self) -> &Token { diff --git a/crates/dom/src/lib.rs b/crates/dom/src/lib.rs index e8a6ff9..013f047 100644 --- a/crates/dom/src/lib.rs +++ b/crates/dom/src/lib.rs @@ -14,6 +14,11 @@ impl NodeId { pub fn index(self) -> usize { self.0 } + + /// Create a `NodeId` from a raw index. + pub fn from_index(index: usize) -> Self { + NodeId(index) + } } /// An HTML/XML attribute (name-value pair). diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml index c560263..e1eee7b 100644 --- a/crates/js/Cargo.toml +++ b/crates/js/Cargo.toml @@ -9,3 +9,8 @@ path = "src/lib.rs" [dependencies] we-dom = { path = "../dom" } +we-css = { path = "../css" } +we-style = { path = "../style" } + +[dev-dependencies] +we-html = { path = "../html" } diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 21e0238..e6ad0a6 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -20,7 +20,7 @@ type NativeMethod = ( // ── Helpers ────────────────────────────────────────────────── /// Create a native function GcRef. -fn make_native( +pub fn make_native( gc: &mut Gc, name: &str, callback: fn(&[Value], &mut NativeContext) -> Result, @@ -35,7 +35,7 @@ fn make_native( } /// Set a non-enumerable property on an object. -fn set_builtin_prop(gc: &mut Gc, obj: GcRef, key: &str, val: Value) { +pub fn set_builtin_prop(gc: &mut Gc, obj: GcRef, key: &str, val: Value) { if let Some(HeapObject::Object(data)) = gc.get_mut(obj) { data.properties .insert(key.to_string(), Property::builtin(val)); @@ -1286,6 +1286,7 @@ fn array_to_string(_args: &[Value], ctx: &mut NativeContext) -> Result Result, ) -> Result { match gc.get(func_ref) { Some(HeapObject::Function(fdata)) => match &fdata.kind { @@ -4619,6 +4622,7 @@ fn call_native_callback( gc, this: Value::Undefined, console_output, + dom_bridge, }; cb(args, &mut ctx) } @@ -4760,6 +4764,7 @@ fn set_proto_for_each(args: &[Value], ctx: &mut NativeContext) -> Result Result, +); + +// ── Internal property key for storing NodeId on wrapper objects ────── + +const NODE_ID_KEY: &str = "__node_id__"; + +// ── Node wrapper creation ─────────────────────────────────────────── + +/// Get or create a JS wrapper object for a DOM node. Returns the same +/// `GcRef` for the same `NodeId` (wrapper identity). +fn get_or_create_wrapper( + node_id: NodeId, + gc: &mut Gc, + bridge: &DomBridge, + object_proto: Option, +) -> GcRef { + let idx = node_id.index(); + if let Some(&existing) = bridge.node_wrappers.borrow().get(&idx) { + // Verify the GcRef is still alive. + if gc.get(existing).is_some() { + return existing; + } + } + + let doc = bridge.document.borrow(); + let mut data = ObjectData::new(); + if let Some(proto) = object_proto { + data.prototype = Some(proto); + } + + // Store the NodeId index as an internal property. + data.properties.insert( + NODE_ID_KEY.to_string(), + Property::builtin(Value::Number(idx as f64)), + ); + + // Populate properties based on node type. + match doc.node_data(node_id) { + NodeData::Element { + tag_name, + attributes, + .. + } => { + let upper_tag = tag_name.to_ascii_uppercase(); + data.properties.insert( + "tagName".to_string(), + Property::builtin(Value::String(upper_tag.clone())), + ); + data.properties.insert( + "nodeName".to_string(), + Property::builtin(Value::String(upper_tag)), + ); + data.properties.insert( + "nodeType".to_string(), + Property::builtin(Value::Number(1.0)), + ); + + // id attribute + let id_val = attributes + .iter() + .find(|a| a.name == "id") + .map(|a| Value::String(a.value.clone())) + .unwrap_or(Value::String(String::new())); + data.properties + .insert("id".to_string(), Property::builtin(id_val)); + + // className attribute + let class_val = attributes + .iter() + .find(|a| a.name == "class") + .map(|a| Value::String(a.value.clone())) + .unwrap_or(Value::String(String::new())); + data.properties + .insert("className".to_string(), Property::builtin(class_val)); + } + NodeData::Text { .. } => { + data.properties.insert( + "nodeName".to_string(), + Property::builtin(Value::String("#text".to_string())), + ); + data.properties.insert( + "nodeType".to_string(), + Property::builtin(Value::Number(3.0)), + ); + } + NodeData::Comment { .. } => { + data.properties.insert( + "nodeName".to_string(), + Property::builtin(Value::String("#comment".to_string())), + ); + data.properties.insert( + "nodeType".to_string(), + Property::builtin(Value::Number(8.0)), + ); + } + NodeData::Document => { + data.properties.insert( + "nodeName".to_string(), + Property::builtin(Value::String("#document".to_string())), + ); + data.properties.insert( + "nodeType".to_string(), + Property::builtin(Value::Number(9.0)), + ); + } + } + + let gc_ref = gc.alloc(HeapObject::Object(data)); + bridge.node_wrappers.borrow_mut().insert(idx, gc_ref); + gc_ref +} + +// ── Helper: walk DOM tree ─────────────────────────────────────────── + +fn walk_tree(doc: &Document, root: NodeId, visitor: &mut dyn FnMut(NodeId) -> bool) { + let mut stack = vec![root]; + while let Some(node) = stack.pop() { + if visitor(node) { + return; + } + // Push children in reverse order so first child is visited first. + let mut children: Vec = doc.children(node).collect(); + children.reverse(); + stack.extend(children); + } +} + +// ── Helper: make an array of wrapper Values ───────────────────────── + +fn make_wrapper_array( + nodes: &[NodeId], + gc: &mut Gc, + bridge: &DomBridge, + object_proto: Option, +) -> Value { + let mut obj = ObjectData::new(); + for (i, &nid) in nodes.iter().enumerate() { + let wrapper = get_or_create_wrapper(nid, gc, bridge, object_proto); + obj.properties + .insert(i.to_string(), Property::data(Value::Object(wrapper))); + } + obj.properties.insert( + "length".to_string(), + Property { + value: Value::Number(nodes.len() as f64), + writable: true, + enumerable: false, + configurable: false, + }, + ); + Value::Object(gc.alloc(HeapObject::Object(obj))) +} + +// ── Document global setup ─────────────────────────────────────────── + +/// Register the `document` global object on the VM. Called from +/// `Vm::attach_document`. +pub fn init_document_object(vm: &mut Vm) { + let mut data = ObjectData::new(); + if let Some(proto) = vm.object_prototype { + data.prototype = Some(proto); + } + + // nodeType 9 = Document + data.properties.insert( + "nodeType".to_string(), + Property::builtin(Value::Number(9.0)), + ); + data.properties.insert( + "nodeName".to_string(), + Property::builtin(Value::String("#document".to_string())), + ); + + // Set document.title from the DOM. + if let Some(bridge) = &vm.dom_bridge { + let doc = bridge.document.borrow(); + let title = find_title_text(&doc); + data.properties + .insert("title".to_string(), Property::builtin(Value::String(title))); + } + + let doc_ref = vm.gc.alloc(HeapObject::Object(data)); + + // Set documentElement, head, body as wrapper properties. + if let Some(bridge) = &vm.dom_bridge { + let (html_id, head_id, body_id) = { + let doc = bridge.document.borrow(); + find_structural_elements(&doc) + }; + if let Some(html) = html_id { + let wrapper = get_or_create_wrapper(html, &mut vm.gc, bridge, vm.object_prototype); + set_builtin_prop( + &mut vm.gc, + doc_ref, + "documentElement", + Value::Object(wrapper), + ); + } + if let Some(head) = head_id { + let wrapper = get_or_create_wrapper(head, &mut vm.gc, bridge, vm.object_prototype); + set_builtin_prop(&mut vm.gc, doc_ref, "head", Value::Object(wrapper)); + } + if let Some(body) = body_id { + let wrapper = get_or_create_wrapper(body, &mut vm.gc, bridge, vm.object_prototype); + set_builtin_prop(&mut vm.gc, doc_ref, "body", Value::Object(wrapper)); + } + } + + // Register methods on the document object. + let methods: &[NativeMethod] = &[ + ("getElementById", doc_get_element_by_id), + ("getElementsByTagName", doc_get_elements_by_tag_name), + ("getElementsByClassName", doc_get_elements_by_class_name), + ("querySelector", doc_query_selector), + ("querySelectorAll", doc_query_selector_all), + ("createElement", doc_create_element), + ("createTextNode", doc_create_text_node), + ]; + for &(name, callback) in methods { + let func = make_native(&mut vm.gc, name, callback); + set_builtin_prop(&mut vm.gc, doc_ref, name, Value::Function(func)); + } + + vm.set_global("document", Value::Object(doc_ref)); +} + +/// Find ``, ``, and `` elements in the document. +fn find_structural_elements(doc: &Document) -> (Option, Option, Option) { + let mut html = None; + let mut head = None; + let mut body = None; + + for child in doc.children(doc.root()) { + if let NodeData::Element { tag_name, .. } = doc.node_data(child) { + if tag_name.eq_ignore_ascii_case("html") { + html = Some(child); + for inner in doc.children(child) { + if let NodeData::Element { tag_name, .. } = doc.node_data(inner) { + if tag_name.eq_ignore_ascii_case("head") { + head = Some(inner); + } else if tag_name.eq_ignore_ascii_case("body") { + body = Some(inner); + } + } + } + break; + } + } + } + + (html, head, body) +} + +/// Extract the text content of `` from the document. +fn find_title_text(doc: &Document) -> String { + let mut result = String::new(); + walk_tree(doc, doc.root(), &mut |node| { + if let NodeData::Element { tag_name, .. } = doc.node_data(node) { + if tag_name.eq_ignore_ascii_case("title") { + // Collect text children. + for child in doc.children(node) { + if let Some(text) = doc.text_content(child) { + result.push_str(text); + } + } + return true; // stop walking + } + } + false + }); + result +} + +// ── Document methods ──────────────────────────────────────────────── + +fn doc_get_element_by_id(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { + let id = args + .first() + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + + let bridge = match ctx.dom_bridge { + Some(b) => b, + None => return Ok(Value::Null), + }; + + let found = { + let doc = bridge.document.borrow(); + let mut found = None; + walk_tree(&doc, doc.root(), &mut |node| { + if let Some(attr_val) = doc.get_attribute(node, "id") { + if attr_val == id { + found = Some(node); + return true; + } + } + false + }); + found + }; + + match found { + Some(node_id) => { + let wrapper = get_or_create_wrapper(node_id, ctx.gc, bridge, None); + Ok(Value::Object(wrapper)) + } + None => Ok(Value::Null), + } +} + +fn doc_get_elements_by_tag_name( + args: &[Value], + ctx: &mut NativeContext, +) -> Result<Value, RuntimeError> { + let tag = args + .first() + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + + let bridge = match ctx.dom_bridge { + Some(b) => b, + None => return Ok(make_empty_array(ctx.gc)), + }; + + let matches = { + let doc = bridge.document.borrow(); + let mut matches = Vec::new(); + walk_tree(&doc, doc.root(), &mut |node| { + if let NodeData::Element { tag_name: tn, .. } = doc.node_data(node) { + if tn.eq_ignore_ascii_case(&tag) || tag == "*" { + matches.push(node); + } + } + false + }); + matches + }; + + Ok(make_wrapper_array(&matches, ctx.gc, bridge, None)) +} + +fn doc_get_elements_by_class_name( + args: &[Value], + ctx: &mut NativeContext, +) -> Result<Value, RuntimeError> { + let class_name = args + .first() + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + + let bridge = match ctx.dom_bridge { + Some(b) => b, + None => return Ok(make_empty_array(ctx.gc)), + }; + + let matches = { + let doc = bridge.document.borrow(); + let mut matches = Vec::new(); + walk_tree(&doc, doc.root(), &mut |node| { + if let Some(cls) = doc.get_attribute(node, "class") { + if cls.split_whitespace().any(|c| c == class_name) { + matches.push(node); + } + } + false + }); + matches + }; + + Ok(make_wrapper_array(&matches, ctx.gc, bridge, None)) +} + +fn doc_query_selector(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { + let selector_str = args + .first() + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + + let bridge = match ctx.dom_bridge { + Some(b) => b, + None => return Ok(Value::Null), + }; + + let selector_list = CssParser::parse_selectors(&selector_str); + if selector_list.selectors.is_empty() { + return Ok(Value::Null); + } + + let found = { + let doc = bridge.document.borrow(); + let mut found = None; + walk_tree(&doc, doc.root(), &mut |node| { + if matches!(doc.node_data(node), NodeData::Element { .. }) + && matches_selector_list(&doc, node, &selector_list) + { + found = Some(node); + return true; + } + false + }); + found + }; + + match found { + Some(node_id) => { + let wrapper = get_or_create_wrapper(node_id, ctx.gc, bridge, None); + Ok(Value::Object(wrapper)) + } + None => Ok(Value::Null), + } +} + +fn doc_query_selector_all(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { + let selector_str = args + .first() + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + + let bridge = match ctx.dom_bridge { + Some(b) => b, + None => return Ok(make_empty_array(ctx.gc)), + }; + + let selector_list = CssParser::parse_selectors(&selector_str); + + let matches = { + let doc = bridge.document.borrow(); + let mut matches = Vec::new(); + walk_tree(&doc, doc.root(), &mut |node| { + if matches!(doc.node_data(node), NodeData::Element { .. }) + && matches_selector_list(&doc, node, &selector_list) + { + matches.push(node); + } + false + }); + matches + }; + + Ok(make_wrapper_array(&matches, ctx.gc, bridge, None)) +} + +fn doc_create_element(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { + let tag = args + .first() + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + + let bridge = match ctx.dom_bridge { + Some(b) => b, + None => return Err(RuntimeError::type_error("no document attached")), + }; + + let node_id = bridge.document.borrow_mut().create_element(&tag); + let wrapper = get_or_create_wrapper(node_id, ctx.gc, bridge, None); + Ok(Value::Object(wrapper)) +} + +fn doc_create_text_node(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { + let text = args + .first() + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + + let bridge = match ctx.dom_bridge { + Some(b) => b, + None => return Err(RuntimeError::type_error("no document attached")), + }; + + let node_id = bridge.document.borrow_mut().create_text(&text); + let wrapper = get_or_create_wrapper(node_id, ctx.gc, bridge, None); + Ok(Value::Object(wrapper)) +} + +/// Create an empty JS array. +fn make_empty_array(gc: &mut Gc<HeapObject>) -> Value { + let mut obj = ObjectData::new(); + obj.properties.insert( + "length".to_string(), + Property { + value: Value::Number(0.0), + writable: true, + enumerable: false, + configurable: false, + }, + ); + Value::Object(gc.alloc(HeapObject::Object(obj))) +} + +// ── Tests ─────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::compiler; + use crate::parser::Parser; + use we_html::parse_html; + + /// Build a Document from HTML source by parsing it with the HTML parser. + fn doc_from_html(html: &str) -> we_dom::Document { + parse_html(html) + } + + /// Evaluate JS with a DOM document attached. + fn eval_with_doc(html: &str, js: &str) -> Result<Value, RuntimeError> { + let doc = doc_from_html(html); + let program = Parser::parse(js).expect("parse failed"); + let func = compiler::compile(&program).expect("compile failed"); + let mut vm = Vm::new(); + vm.attach_document(doc); + vm.execute(&func) + } + + #[test] + fn test_document_is_global() { + let result = eval_with_doc("<html><body></body></html>", "typeof document").unwrap(); + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "object"); + } + + #[test] + fn test_document_node_type() { + let result = eval_with_doc("<html><body></body></html>", "document.nodeType").unwrap(); + match result { + Value::Number(n) => assert_eq!(n, 9.0), + v => panic!("expected 9, got {v:?}"), + } + } + + #[test] + fn test_document_body() { + let result = eval_with_doc("<html><body></body></html>", "document.body.tagName").unwrap(); + match result { + Value::String(s) => assert_eq!(s, "BODY"), + v => panic!("expected 'BODY', got {v:?}"), + } + } + + #[test] + fn test_document_head() { + let result = eval_with_doc( + "<html><head></head><body></body></html>", + "document.head.tagName", + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "HEAD"), + v => panic!("expected 'HEAD', got {v:?}"), + } + } + + #[test] + fn test_document_document_element() { + let result = eval_with_doc( + "<html><body></body></html>", + "document.documentElement.tagName", + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "HTML"), + v => panic!("expected 'HTML', got {v:?}"), + } + } + + #[test] + fn test_get_element_by_id_found() { + let result = eval_with_doc( + r#"<html><body><div id="foo">hello</div></body></html>"#, + r#"document.getElementById("foo").tagName"#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "DIV"), + v => panic!("expected 'DIV', got {v:?}"), + } + } + + #[test] + fn test_get_element_by_id_not_found() { + let result = eval_with_doc( + "<html><body></body></html>", + r#"document.getElementById("nope")"#, + ) + .unwrap(); + assert!(matches!(result, Value::Null)); + } + + #[test] + fn test_get_element_by_id_returns_correct_id() { + let result = eval_with_doc( + r#"<html><body><div id="myid"></div></body></html>"#, + r#"document.getElementById("myid").id"#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "myid"), + v => panic!("expected 'myid', got {v:?}"), + } + } + + #[test] + fn test_create_element() { + let result = eval_with_doc( + "<html><body></body></html>", + r#"document.createElement("div").tagName"#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "DIV"), + v => panic!("expected 'DIV', got {v:?}"), + } + } + + #[test] + fn test_create_text_node() { + let result = eval_with_doc( + "<html><body></body></html>", + r#"document.createTextNode("hello").nodeType"#, + ) + .unwrap(); + match result { + Value::Number(n) => assert_eq!(n, 3.0), + v => panic!("expected 3, got {v:?}"), + } + } + + #[test] + fn test_element_node_type() { + let result = eval_with_doc("<html><body></body></html>", "document.body.nodeType").unwrap(); + match result { + Value::Number(n) => assert_eq!(n, 1.0), + v => panic!("expected 1, got {v:?}"), + } + } + + #[test] + fn test_query_selector_by_class() { + let result = eval_with_doc( + r#"<html><body><p class="intro">hi</p></body></html>"#, + r#"document.querySelector(".intro").tagName"#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "P"), + v => panic!("expected 'P', got {v:?}"), + } + } + + #[test] + fn test_query_selector_not_found() { + let result = eval_with_doc( + "<html><body></body></html>", + r#"document.querySelector(".missing")"#, + ) + .unwrap(); + assert!(matches!(result, Value::Null)); + } + + #[test] + fn test_query_selector_all() { + let result = eval_with_doc( + r#"<html><body><p>a</p><p>b</p><p>c</p></body></html>"#, + r#"document.querySelectorAll("p").length"#, + ) + .unwrap(); + match result { + Value::Number(n) => assert_eq!(n, 3.0), + v => panic!("expected 3, got {v:?}"), + } + } + + #[test] + fn test_get_elements_by_tag_name() { + let result = eval_with_doc( + r#"<html><body><div>a</div><div>b</div></body></html>"#, + r#"document.getElementsByTagName("div").length"#, + ) + .unwrap(); + match result { + Value::Number(n) => assert_eq!(n, 2.0), + v => panic!("expected 2, got {v:?}"), + } + } + + #[test] + fn test_get_elements_by_class_name() { + let result = eval_with_doc( + r#"<html><body><span class="x">a</span><span class="x">b</span></body></html>"#, + r#"document.getElementsByClassName("x").length"#, + ) + .unwrap(); + match result { + Value::Number(n) => assert_eq!(n, 2.0), + v => panic!("expected 2, got {v:?}"), + } + } + + #[test] + fn test_wrapper_identity() { + let result = eval_with_doc( + r#"<html><body><div id="x"></div></body></html>"#, + r#"document.getElementById("x") === document.getElementById("x")"#, + ) + .unwrap(); + match result { + Value::Boolean(b) => assert!(b, "same node should return same wrapper object"), + v => panic!("expected true, got {v:?}"), + } + } + + #[test] + fn test_document_title() { + let result = eval_with_doc( + "<html><head><title>Test Page", + "document.title", + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "Test Page"), + v => panic!("expected 'Test Page', got {v:?}"), + } + } + + #[test] + fn test_element_class_name() { + let result = eval_with_doc( + r#"
"#, + r#"document.getElementById("d").className"#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "foo bar"), + v => panic!("expected 'foo bar', got {v:?}"), + } + } + + #[test] + fn test_missing_element_returns_null() { + let result = eval_with_doc( + "", + r#"document.getElementById("nope") === null"#, + ) + .unwrap(); + match result { + Value::Boolean(b) => assert!(b), + v => panic!("expected true, got {v:?}"), + } + } +} diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 5f99a76..9ee25a2 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -4,6 +4,7 @@ pub mod ast; pub mod builtins; pub mod bytecode; pub mod compiler; +pub mod dom_bridge; pub mod gc; pub mod lexer; pub mod parser; diff --git a/crates/js/src/vm.rs b/crates/js/src/vm.rs index 424d052..eee3a41 100644 --- a/crates/js/src/vm.rs +++ b/crates/js/src/vm.rs @@ -7,8 +7,11 @@ use crate::bytecode::{Constant, Function, Op, Reg}; use crate::gc::{Gc, GcRef, Traceable}; +use std::cell::RefCell; use std::collections::HashMap; use std::fmt; +use std::rc::Rc; +use we_dom::Document; // ── Heap objects (GC-managed) ──────────────────────────────── @@ -218,11 +221,20 @@ impl ConsoleOutput for StdConsoleOutput { } } +/// Bridge between JS and the DOM. Holds a shared document and a cache +/// mapping `NodeId` indices to their JS wrapper `GcRef` so that the same +/// DOM node always returns the same JS object (identity). +pub struct DomBridge { + pub document: RefCell, + pub node_wrappers: RefCell>, +} + /// Context passed to native functions, providing GC access and `this` binding. pub struct NativeContext<'a> { pub gc: &'a mut Gc, pub this: Value, pub console_output: &'a dyn ConsoleOutput, + pub dom_bridge: Option<&'a DomBridge>, } // ── JS Value ────────────────────────────────────────────────── @@ -773,6 +785,8 @@ pub struct Vm { pub promise_prototype: Option, /// Console output sink (configurable for dev tools or testing). console_output: Box, + /// DOM bridge for JS-DOM interop (set via `attach_document`). + pub(crate) dom_bridge: Option>, } /// Maximum register file size. @@ -798,6 +812,7 @@ impl Vm { regexp_prototype: None, promise_prototype: None, console_output: Box::new(StdConsoleOutput), + dom_bridge: None, }; crate::builtins::init_builtins(&mut vm); vm @@ -808,6 +823,17 @@ impl Vm { self.console_output = output; } + /// Attach a DOM document to this VM, registering the `document` global + /// and enabling DOM-JS interop. + pub fn attach_document(&mut self, doc: Document) { + let bridge = Rc::new(DomBridge { + document: RefCell::new(doc), + node_wrappers: RefCell::new(HashMap::new()), + }); + self.dom_bridge = Some(bridge); + crate::dom_bridge::init_document_object(self); + } + /// Set an instruction limit. The VM will return a RuntimeError after /// executing this many instructions. pub fn set_instruction_limit(&mut self, limit: u64) { @@ -861,10 +887,12 @@ impl Vm { .get("this") .cloned() .unwrap_or(Value::Undefined); + let dom_ref = self.dom_bridge.as_deref(); let mut ctx = NativeContext { gc: &mut self.gc, this, console_output: &*self.console_output, + dom_bridge: dom_ref, }; let result = (native.callback)(args, &mut ctx)?; @@ -2386,10 +2414,12 @@ impl Vm { .get("this") .cloned() .unwrap_or(Value::Undefined); + let dom_ref = self.dom_bridge.as_deref(); let mut ctx = NativeContext { gc: &mut self.gc, this, console_output: &*self.console_output, + dom_bridge: dom_ref, }; match callback(&args, &mut ctx) { Ok(val) => { -- 2.51.2