Skip to main content

rdom_parser/
dom_ext.rs

1//! `NodeMutHtml` — author-facing HTML setters on `NodeMut`.
2//!
3//! These methods need the parser, so they can't live on `NodeMut`
4//! in `rdom-core` (the dependency direction is parser → core).
5//! Authors `use rdom_parser::NodeMutHtml;` to bring them in. The
6//! `rdom_tui::prelude` (M4b step 32) re-exports the trait so a
7//! TUI consumer gets the full M4 surface with one import.
8//!
9//! ## Error policy
10//!
11//! Strict: malformed input propagates `rdom_parser::ParseError`.
12//! No recovery. If a real consumer needs spec-faithful recovery
13//! (insert what can be parsed, drop the rest), that lands as a
14//! polish item.
15//!
16//! Non-parse failures (e.g. `set_outer_html` called on a
17//! parentless receiver) surface as a synthesized `ParseError`
18//! carrying a diagnostic message. The trait's single error type
19//! keeps call sites simple — the docstring on each method lists
20//! the non-parser failure modes.
21//!
22//! ## Resource hygiene
23//!
24//! `set_inner_html` drops the existing subtree of the receiver
25//! (frees arena slots, removes attached listeners). The §14.2 row
26//! promise: an AbortSignal-managed listener registered on a
27//! removed child is gone after `set_inner_html` replaces the
28//! subtree.
29
30use rdom_core::{AdjacentPosition, NodeId, NodeMut};
31
32use crate::error::{ParseError, Result};
33use crate::parser::parse_into;
34
35/// HTML setters on `NodeMut`. Brings the `innerHTML` /
36/// `outerHTML` / `insertAdjacentHTML` ergonomics that `rdom-core`
37/// can't expose directly (since it can't depend on the parser).
38///
39/// Implemented for any `NodeMut<'_, Ext>` whose `Ext` is
40/// `Default + 'static` (the parser's bound for materializing new
41/// elements).
42pub trait NodeMutHtml<'a, Ext>
43where
44    Ext: Default + 'static,
45{
46    /// Parse `html` and use it as the receiver's children,
47    /// replacing whatever was there. DOM `Element.innerHTML`
48    /// setter.
49    ///
50    /// The existing subtree is dropped — arena slots are freed and
51    /// listeners (including AbortSignal-controlled ones) are
52    /// released.
53    ///
54    /// **Errors:** any `ParseError` from the underlying parser.
55    fn set_inner_html(&mut self, html: &str) -> Result<()>;
56
57    /// Replace the receiver in its parent with the parsed top-
58    /// level nodes from `html`. DOM `Element.outerHTML` setter.
59    ///
60    /// **Consumes `self`** — the receiver is detached and dropped,
61    /// so the handle is no longer usable.
62    ///
63    /// Returns the `NodeId` of the **first** new top-level node;
64    /// remaining top-level nodes are spliced in at the same
65    /// position.
66    ///
67    /// **Errors:**
68    /// - `ParseError` from the underlying parser.
69    /// - `ParseError` (synthesized) if the receiver has no parent
70    ///   — outer-HTML replacement requires an attached element.
71    /// - `ParseError` (synthesized) if the parsed fragment is
72    ///   empty.
73    ///
74    /// ```compile_fail
75    /// use rdom_core::Dom;
76    /// use rdom_parser::NodeMutHtml;
77    /// let mut dom: Dom = Dom::new();
78    /// let root = dom.root();
79    /// let el = dom.create_element("div");
80    /// dom.append_child(root, el).unwrap();
81    /// let nm = dom.node_mut(el);
82    /// nm.set_outer_html("<p>x</p>").unwrap();
83    /// let _ = nm.id();  // ERROR: nm was consumed
84    /// ```
85    fn set_outer_html(self, html: &str) -> Result<NodeId>;
86
87    /// Parse `html` and insert the resulting nodes at `position`
88    /// relative to the receiver. DOM
89    /// `Element.insertAdjacentHTML(position, html)`.
90    ///
91    /// `BeforeBegin` and `AfterEnd` require a parent — both fail
92    /// with a synthesized `ParseError` if the receiver is
93    /// parentless. `AfterBegin` and `BeforeEnd` always succeed
94    /// when the parse succeeds.
95    ///
96    /// **Errors:** any `ParseError` from the underlying parser,
97    /// plus a synthesized one for the parent-required positions.
98    fn insert_adjacent_html(&mut self, position: AdjacentPosition, html: &str) -> Result<()>;
99}
100
101impl<'a, Ext> NodeMutHtml<'a, Ext> for NodeMut<'a, Ext>
102where
103    Ext: Default + 'static,
104{
105    fn set_inner_html(&mut self, html: &str) -> Result<()> {
106        let id = self.id();
107        let dom = self.dom_mut();
108        while let Some(first) = dom.node(id).first_child().map(|n| n.id()) {
109            let _ = dom.drop_subtree(first);
110        }
111        parse_into(dom, html, id)?;
112        Ok(())
113    }
114
115    fn set_outer_html(self, html: &str) -> Result<NodeId> {
116        let id = self.id();
117        let dom = self.into_dom_mut();
118
119        let parent = match dom.node(id).parent_node() {
120            Some(p) => p.id(),
121            None => return Err(synth_error("set_outer_html: receiver has no parent")),
122        };
123
124        // Parse into a fresh staging fragment in the same arena so
125        // the parsed nodes are adoptable into `parent` without
126        // copying.
127        let staging = dom.create_document_fragment();
128        let parsed = parse_into(dom, html, staging)?;
129        if parsed.is_empty() {
130            let _ = dom.drop_subtree(staging);
131            return Err(synth_error("set_outer_html: parsed fragment is empty"));
132        }
133
134        // Splice each parsed top-level node into the parent at
135        // self's position, in document order.
136        for &child in &parsed {
137            dom.insert_before(parent, child, Some(id))
138                .map_err(|e| synth_error(&format!("set_outer_html: {e}")))?;
139        }
140
141        // Staging fragment is now empty; the original receiver
142        // subtree is dropped (frees listeners + arena slots).
143        let _ = dom.drop_subtree(staging);
144        let _ = dom.drop_subtree(id);
145
146        Ok(parsed[0])
147    }
148
149    fn insert_adjacent_html(&mut self, position: AdjacentPosition, html: &str) -> Result<()> {
150        let id = self.id();
151        let dom = self.dom_mut();
152        let parent_of_id = dom.node(id).parent_node().map(|n| n.id());
153        let needs_parent = matches!(
154            position,
155            AdjacentPosition::BeforeBegin | AdjacentPosition::AfterEnd
156        );
157        if needs_parent && parent_of_id.is_none() {
158            return Err(synth_error(
159                "insert_adjacent_html: BeforeBegin/AfterEnd require a parent",
160            ));
161        }
162
163        // Compute a fixed (insertion_parent, reference) pair so a
164        // single insert_before loop preserves parse order across
165        // all four positions. Spec semantics fall out of the
166        // reference choice — see below.
167        let (insertion_parent, reference) = match position {
168            AdjacentPosition::BeforeBegin => (parent_of_id.unwrap(), Some(id)),
169            AdjacentPosition::AfterBegin => (id, dom.node(id).first_child().map(|n| n.id())),
170            AdjacentPosition::BeforeEnd => (id, None),
171            AdjacentPosition::AfterEnd => (
172                parent_of_id.unwrap(),
173                dom.node(id).next_sibling().map(|n| n.id()),
174            ),
175        };
176
177        let staging = dom.create_document_fragment();
178        let parsed = parse_into(dom, html, staging)?;
179
180        for child in parsed {
181            dom.insert_before(insertion_parent, child, reference)
182                .map_err(|e| synth_error(&format!("insert_adjacent_html: {e}")))?;
183        }
184
185        let _ = dom.drop_subtree(staging);
186        Ok(())
187    }
188}
189
190/// Synthesize a `ParseError` for non-parse failures. Position is
191/// 1,1,0 since there's no source-text location to point at.
192fn synth_error(msg: &str) -> ParseError {
193    ParseError::new(msg.to_string(), 1, 1, 0)
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use rdom_core::{Dom, ListenerOptions};
200
201    fn child_tags(dom: &Dom, parent: NodeId) -> Vec<String> {
202        dom.node(parent)
203            .child_nodes()
204            .map(|n| n.tag_name().unwrap_or(n.node_name()).to_string())
205            .collect()
206    }
207
208    fn s(t: &str) -> String {
209        t.to_string()
210    }
211
212    // ── set_inner_html ────────────────────────────────────────────────
213
214    #[test]
215    fn set_inner_html_replaces_children() {
216        let mut dom: Dom = Dom::new();
217        let parent = dom.create_element("div");
218        let old = dom.create_element("span");
219        dom.append_child(parent, old).unwrap();
220        dom.node_mut(parent)
221            .set_inner_html("<p>hi</p><em>bye</em>")
222            .unwrap();
223        let tags = child_tags(&dom, parent);
224        assert_eq!(tags, vec![s("p"), s("em")]);
225        // Note: `old`'s arena slot may have been recycled by alloc;
226        // dom.contains(old) is not a reliable post-condition. The
227        // listener-drop test below covers the resource-hygiene
228        // guarantee that matters.
229    }
230
231    #[test]
232    fn set_inner_html_clears_when_input_empty() {
233        let mut dom: Dom = Dom::new();
234        let parent = dom.create_element("div");
235        let old = dom.create_element("span");
236        dom.append_child(parent, old).unwrap();
237        dom.node_mut(parent).set_inner_html("").unwrap();
238        assert!(!dom.node(parent).has_child_nodes());
239        // No re-alloc happens (empty parse), so the slot is still
240        // freed.
241        assert!(!dom.contains(old));
242    }
243
244    #[test]
245    fn set_inner_html_drops_abortsignal_listeners_on_removed_children() {
246        // §14.2 step 17 row promise: listeners on removed children
247        // are released when set_inner_html replaces the subtree.
248        //
249        // Resource-hygiene check via listener_count: when a node is
250        // freed, its listener entry is removed from the store. Even
251        // if the arena slot is later recycled by alloc, the
252        // recycled-slot's NodeId has no listener entry until a new
253        // listener is added against it.
254        let mut dom: Dom = Dom::new();
255        let parent = dom.create_element("div");
256        let child = dom.create_element("button");
257        dom.append_child(parent, child).unwrap();
258        dom.add_event_listener(child, "click", ListenerOptions::default(), |_| {})
259            .unwrap();
260        assert_eq!(dom.listener_count(child), 1);
261        dom.node_mut(parent).set_inner_html("<p>new</p>").unwrap();
262        // The original listener is gone. (If the arena slot was
263        // recycled for the new <p>, that new node has no listeners.)
264        assert_eq!(dom.listener_count(child), 0);
265    }
266
267    #[test]
268    fn set_inner_html_propagates_parse_error_strictly() {
269        let mut dom: Dom = Dom::new();
270        let el = dom.create_element("div");
271        let err = dom
272            .node_mut(el)
273            .set_inner_html("<div><span></p></div>")
274            .unwrap_err();
275        // Real parser error, not a synthesized one.
276        assert!(err.msg.to_lowercase().contains("mismatch"));
277    }
278
279    // ── set_outer_html ────────────────────────────────────────────────
280
281    #[test]
282    fn set_outer_html_replaces_self_in_parent_and_returns_first_id() {
283        let mut dom: Dom = Dom::new();
284        let root = dom.root();
285        let target = dom.create_element("span");
286        let sibling = dom.create_element("span");
287        dom.append_child(root, target).unwrap();
288        dom.append_child(root, sibling).unwrap();
289        let new_id = dom
290            .node_mut(target)
291            .set_outer_html("<p>a</p><em>b</em>")
292            .unwrap();
293        // Returned id is the first parsed top-level.
294        assert_eq!(dom.node(new_id).tag_name(), Some("p"));
295        let tags = child_tags(&dom, root);
296        assert_eq!(
297            tags,
298            vec!["p".to_string(), "em".to_string(), "span".to_string()]
299        );
300        assert!(!dom.contains(target));
301    }
302
303    #[test]
304    fn set_outer_html_errors_when_receiver_has_no_parent() {
305        let mut dom: Dom = Dom::new();
306        let detached = dom.create_element("div");
307        let err = dom
308            .node_mut(detached)
309            .set_outer_html("<p>x</p>")
310            .unwrap_err();
311        assert!(err.msg.contains("no parent"));
312        // Receiver was not consumed by the impl on the error path? It
313        // *was* consumed by Rust's move semantics. We just verify the
314        // node is still in the arena.
315        assert!(dom.contains(detached));
316    }
317
318    #[test]
319    fn set_outer_html_errors_on_empty_parsed_fragment() {
320        let mut dom: Dom = Dom::new();
321        let root = dom.root();
322        let target = dom.create_element("div");
323        dom.append_child(root, target).unwrap();
324        let err = dom.node_mut(target).set_outer_html("").unwrap_err();
325        assert!(err.msg.contains("empty"));
326        // Receiver stays attached on error.
327        assert!(dom.contains(target));
328        assert_eq!(dom.node(target).parent_node().map(|p| p.id()), Some(root));
329    }
330
331    #[test]
332    fn set_outer_html_propagates_parse_error_strictly() {
333        let mut dom: Dom = Dom::new();
334        let root = dom.root();
335        let target = dom.create_element("div");
336        dom.append_child(root, target).unwrap();
337        let err = dom
338            .node_mut(target)
339            .set_outer_html("<a><b></a>")
340            .unwrap_err();
341        assert!(err.msg.to_lowercase().contains("mismatch"));
342        // Receiver stays attached on parse error.
343        assert!(dom.contains(target));
344    }
345
346    // ── insert_adjacent_html ──────────────────────────────────────────
347
348    #[test]
349    fn insert_adjacent_html_before_begin() {
350        let mut dom: Dom = Dom::new();
351        let root = dom.root();
352        let target = dom.create_element("span");
353        dom.append_child(root, target).unwrap();
354        dom.node_mut(target)
355            .insert_adjacent_html(AdjacentPosition::BeforeBegin, "<a></a><b></b>")
356            .unwrap();
357        let tags = child_tags(&dom, root);
358        assert_eq!(tags, vec![s("a"), s("b"), s("span")]);
359    }
360
361    #[test]
362    fn insert_adjacent_html_after_begin() {
363        let mut dom: Dom = Dom::new();
364        let parent = dom.create_element("div");
365        let existing = dom.create_element("span");
366        dom.append_child(parent, existing).unwrap();
367        dom.node_mut(parent)
368            .insert_adjacent_html(AdjacentPosition::AfterBegin, "<a></a><b></b>")
369            .unwrap();
370        let tags = child_tags(&dom, parent);
371        assert_eq!(tags, vec![s("a"), s("b"), s("span")]);
372    }
373
374    #[test]
375    fn insert_adjacent_html_before_end() {
376        let mut dom: Dom = Dom::new();
377        let parent = dom.create_element("div");
378        let existing = dom.create_element("span");
379        dom.append_child(parent, existing).unwrap();
380        dom.node_mut(parent)
381            .insert_adjacent_html(AdjacentPosition::BeforeEnd, "<a></a><b></b>")
382            .unwrap();
383        let tags = child_tags(&dom, parent);
384        assert_eq!(tags, vec![s("span"), s("a"), s("b")]);
385    }
386
387    #[test]
388    fn insert_adjacent_html_after_end() {
389        let mut dom: Dom = Dom::new();
390        let root = dom.root();
391        let target = dom.create_element("span");
392        let tail = dom.create_element("span");
393        dom.append_child(root, target).unwrap();
394        dom.append_child(root, tail).unwrap();
395        dom.node_mut(target)
396            .insert_adjacent_html(AdjacentPosition::AfterEnd, "<a></a><b></b>")
397            .unwrap();
398        let tags = child_tags(&dom, root);
399        assert_eq!(tags, vec![s("span"), s("a"), s("b"), s("span")]);
400    }
401
402    #[test]
403    fn insert_adjacent_html_before_begin_errors_without_parent() {
404        let mut dom: Dom = Dom::new();
405        let detached = dom.create_element("div");
406        let err = dom
407            .node_mut(detached)
408            .insert_adjacent_html(AdjacentPosition::BeforeBegin, "<p/>")
409            .unwrap_err();
410        assert!(err.msg.contains("parent"));
411    }
412
413    #[test]
414    fn insert_adjacent_html_after_end_errors_without_parent() {
415        let mut dom: Dom = Dom::new();
416        let detached = dom.create_element("div");
417        let err = dom
418            .node_mut(detached)
419            .insert_adjacent_html(AdjacentPosition::AfterEnd, "<p/>")
420            .unwrap_err();
421        assert!(err.msg.contains("parent"));
422    }
423
424    #[test]
425    fn insert_adjacent_html_propagates_parse_error_strictly() {
426        let mut dom: Dom = Dom::new();
427        let parent = dom.create_element("div");
428        let err = dom
429            .node_mut(parent)
430            .insert_adjacent_html(AdjacentPosition::BeforeEnd, "<a><b></a>")
431            .unwrap_err();
432        assert!(err.msg.to_lowercase().contains("mismatch"));
433    }
434}