From 192a95831997ba7ed929f3e0e6945e9a84f8eace Mon Sep 17 00:00:00 2001 From: Scott Taylor Date: Fri, 9 Oct 2026 12:25:52 -0400 Subject: [PATCH] html5ever: Add `with_open_elements` and `is_in_text_insertion_mode` Code that feeds a `TreeBuilder` from its own tokenizer, or wraps it to enforce limits, needs to know where tokens will end up: how deeply elements are nested, whether a token is inside a given element such as `template` or `noscript`, and whether character tokens are about to become the contents of an element like `script` or `style`. The tree builder tracks all of this, but the stack of open elements and the insertion mode are private, so such code has to replicate parts of tree construction to recover them. Add two read-only methods to `TreeBuilder`: * `with_open_elements` calls a closure with the stack of open elements as a slice, from the root `html` element to the current node. Passing a slice to a closure, rather than returning a `Ref`, keeps the `RefCell` that holds the stack out of the public API, and means the borrow cannot be held by mistake while the tree builder processes the next token, which would panic. * `is_in_text_insertion_mode` returns whether the insertion mode is "text", in which character tokens are inserted into the current node. A dedicated query avoids making the `InsertionMode` enum public. Neither method changes how documents are parsed. --- html5ever/src/tree_builder/mod.rs | 35 +++++ rcdom/tests/html-tree-builder-state.rs | 170 +++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 rcdom/tests/html-tree-builder-state.rs diff --git a/html5ever/src/tree_builder/mod.rs b/html5ever/src/tree_builder/mod.rs index 803a398e..533c21bb 100644 --- a/html5ever/src/tree_builder/mod.rs +++ b/html5ever/src/tree_builder/mod.rs @@ -413,6 +413,41 @@ where self.context_elem.borrow().is_some() } + /// Call `f` with the [stack of open elements]. + /// + /// The first element is the topmost node of the stack, the root `html` element, and the last + /// one is the bottommost node, the [current node]. The stack is empty before the root element + /// has been inserted and after [`end`](TokenSink::end) has been called. When parsing a + /// fragment, the context element is not on the stack. + /// + /// # Panics + /// + /// The stack is borrowed while `f` runs, so processing a token with this tree builder from + /// within `f` may panic. + /// + /// [stack of open elements]: https://html.spec.whatwg.org/#stack-of-open-elements + /// [current node]: https://html.spec.whatwg.org/#current-node + pub fn with_open_elements(&self, f: F) -> R + where + F: FnOnce(&[Handle]) -> R, + { + f(&self.open_elems.borrow()) + } + + /// Is the [insertion mode] ["text"]? + /// + /// The tree builder switches to this insertion mode after inserting an element whose contents + /// are parsed as raw text or RCDATA, such as `script`, `style`, `title` or `textarea`, and + /// switches back at the element's end tag or at the end of the input. In this insertion mode, + /// character tokens are inserted into the [current node], which is that element. + /// + /// [insertion mode]: https://html.spec.whatwg.org/#insertion-mode + /// ["text"]: https://html.spec.whatwg.org/#parsing-main-incdata + /// [current node]: https://html.spec.whatwg.org/#current-node + pub fn is_in_text_insertion_mode(&self) -> bool { + self.mode.get() == InsertionMode::Text + } + /// https://html.spec.whatwg.org/multipage/#appropriate-place-for-inserting-a-node fn appropriate_place_for_insertion( &self, diff --git a/rcdom/tests/html-tree-builder-state.rs b/rcdom/tests/html-tree-builder-state.rs new file mode 100644 index 00000000..c0b4cfdf --- /dev/null +++ b/rcdom/tests/html-tree-builder-state.rs @@ -0,0 +1,170 @@ +// Copyright 2014-2017 The html5ever Project Developers. See the +// COPYRIGHT file at the top-level directory of this distribution. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use html5ever::driver::{self, ParseOpts}; +use html5ever::tendril::TendrilSink; +use html5ever::tree_builder::{TreeBuilder, TreeBuilderOpts}; +use html5ever::{local_name, ns, QualName}; +use markup5ever_rcdom::{Handle, NodeData, RcDom}; + +/// The names of the open elements, written like in the html5lib tree construction tests. +fn open_elements(tree_builder: &TreeBuilder) -> Vec { + tree_builder.with_open_elements(|elements| { + elements + .iter() + .map(|element| { + let NodeData::Element { ref name, .. } = element.data else { + panic!("not an element"); + }; + match name.ns { + ns!(html) => name.local.to_string(), + ns!(svg) => format!("svg {}", name.local), + ns!(mathml) => format!("math {}", name.local), + _ => panic!("unexpected namespace"), + } + }) + .collect() + }) +} + +/// The text of the children of the current node. +fn current_node_text(tree_builder: &TreeBuilder) -> String { + tree_builder.with_open_elements(|elements| { + let current_node = elements.last().expect("no current node"); + let mut text = String::new(); + for child in current_node.children.borrow().iter() { + if let NodeData::Text { ref contents } = child.data { + text.push_str(&contents.borrow()); + } + } + text + }) +} + +#[test] +fn open_elements_in_document() { + let mut parser = driver::parse_document(RcDom::default(), ParseOpts::default()); + parser.process("".into()); + assert!(open_elements(&parser.tokenizer.sink).is_empty()); + + parser.process("
".into()); + assert_eq!( + open_elements(&parser.tokenizer.sink), + ["html", "body", "table", "tbody", "tr", "td", "b"] + ); + + parser.process("
".into()); + assert_eq!( + open_elements(&parser.tokenizer.sink), + ["html", "body", "svg svg", "svg circle"] + ); + + parser.tokenizer.end(); + assert!(open_elements(&parser.tokenizer.sink).is_empty()); +} + +#[test] +fn open_elements_in_fragment() { + let context = QualName::new(None, ns!(html), local_name!("ul")); + let mut parser = driver::parse_fragment( + RcDom::default(), + ParseOpts::default(), + context, + vec![], + true, + ); + assert_eq!(open_elements(&parser.tokenizer.sink), ["html"]); + + parser.process("
  • ".into()); + assert_eq!(open_elements(&parser.tokenizer.sink), ["html", "li", "p"]); +} + +#[test] +fn text_insertion_mode() { + for name in [ + "iframe", "noembed", "noframes", "noscript", "script", "style", "textarea", "title", "xmp", + ] { + let mut parser = driver::parse_document(RcDom::default(), ParseOpts::default()); + parser.process("".into()); + assert!(!parser.tokenizer.sink.is_in_text_insertion_mode()); + + parser.process(format!("<{name}>").into()); + assert!(parser.tokenizer.sink.is_in_text_insertion_mode(), "{name}"); + assert_eq!( + open_elements(&parser.tokenizer.sink), + ["html", "body", name] + ); + + // Character tokens are inserted into the current node, which is that element. + parser.process("

    ".into()); + assert!(parser.tokenizer.sink.is_in_text_insertion_mode(), "{name}"); + assert_eq!(current_node_text(&parser.tokenizer.sink), "

    ", "{name}"); + + parser.process(format!("").into()); + assert!(!parser.tokenizer.sink.is_in_text_insertion_mode(), "{name}"); + assert_eq!(open_elements(&parser.tokenizer.sink), ["html", "body"]); + } +} + +#[test] +fn text_insertion_mode_ends_at_eof() { + let mut parser = driver::parse_document(RcDom::default(), ParseOpts::default()); + parser.process("