Skip to main content

fhp_tree/
builder.rs

1//! Tree construction from a token stream.
2//!
3//! [`TreeBuilder`](crate::builder::TreeBuilder) consumes [`Token`](fhp_tokenizer::token::Token)s and builds an arena-based DOM tree.
4//! It handles implicit close rules, void elements, and common malformed-HTML
5//! recovery strategies.
6
7use fhp_core::tag::Tag;
8use fhp_tokenizer::token::Token;
9
10use crate::arena::Arena;
11use crate::node::NodeId;
12
13/// Maximum nesting depth to prevent stack overflow on pathological input.
14const MAX_DEPTH: u16 = 512;
15
16/// Implicit close lookup table.
17///
18/// `IMPLICIT_CLOSE[open_tag][new_tag]` is `true` when the arrival of
19/// `new_tag` should implicitly close the currently-open `open_tag`.
20///
21/// Only a subset of tags participate in implicit closing. Tags are mapped
22/// to compact indices 0..=7 via [`implicit_close_index`].
23const IMPLICIT_CLOSE_SIZE: usize = 8;
24static IMPLICIT_CLOSE: [[bool; IMPLICIT_CLOSE_SIZE]; IMPLICIT_CLOSE_SIZE] = {
25    let mut table = [[false; IMPLICIT_CLOSE_SIZE]; IMPLICIT_CLOSE_SIZE];
26
27    // <p> is closed by another block-level element.
28    let p = 0usize;
29    let li = 1;
30    let td = 2;
31    let th = 3;
32    let tr = 4;
33    let thead = 5;
34    let tbody = 6;
35    let option = 7;
36
37    // <p> closed by <p>, <div>, <h1>-<h6>, <ul>, <ol>, <table>, <pre>, etc.
38    // Simplified: <p> closed by <p>
39    table[p][p] = true;
40
41    // <li> closed by <li>
42    table[li][li] = true;
43
44    // <td> closed by <td> or <th>
45    table[td][td] = true;
46    table[td][th] = true;
47
48    // <th> closed by <td> or <th>
49    table[th][td] = true;
50    table[th][th] = true;
51
52    // <tr> closed by <tr>
53    table[tr][tr] = true;
54
55    // <thead> closed by <tbody>
56    table[thead][tbody] = true;
57
58    // <tbody> closed by <tbody>
59    table[tbody][tbody] = true;
60
61    // <option> closed by <option>
62    table[option][option] = true;
63
64    table
65};
66
67/// Map a tag to its implicit close table index, or `None` if it doesn't
68/// participate in implicit closing.
69fn implicit_close_index(tag: Tag) -> Option<usize> {
70    match tag {
71        Tag::P => Some(0),
72        Tag::Li => Some(1),
73        Tag::Td => Some(2),
74        Tag::Th => Some(3),
75        Tag::Tr => Some(4),
76        Tag::Thead => Some(5),
77        Tag::Tbody => Some(6),
78        // Option tag not in Tag enum — skip
79        _ => None,
80    }
81}
82
83/// Check whether `new_tag` should implicitly close `open_tag`.
84#[inline]
85fn should_implicit_close(open_tag: Tag, new_tag: Tag) -> bool {
86    if let (Some(open_idx), Some(new_idx)) = (
87        implicit_close_index(open_tag),
88        implicit_close_index(new_tag),
89    ) {
90        IMPLICIT_CLOSE[open_idx][new_idx]
91    } else {
92        false
93    }
94}
95
96/// Builds a DOM tree from a token stream.
97///
98/// Maintains an open-elements stack and processes each token to either
99/// push new nodes, pop closed elements, or append text/comment/doctype nodes.
100pub struct TreeBuilder {
101    /// The arena that owns all nodes.
102    pub(crate) arena: Arena,
103    /// Stack of open element node ids with cached tags and element child count.
104    open_elements: Vec<(NodeId, Tag, u16)>,
105    /// The synthetic root node (document root).
106    root: NodeId,
107    /// Base address of the original input (for source-backed text).
108    source_base: usize,
109    /// Length of the original input in bytes.
110    source_len: usize,
111    /// Count of non-void open tags dropped at the depth limit whose matching
112    /// close tags must still be swallowed (so they do not pop real elements).
113    suppressed_opens: u32,
114}
115
116impl TreeBuilder {
117    /// Create a new tree builder with default capacity.
118    pub fn new() -> Self {
119        Self::with_capacity_hint(0)
120    }
121
122    /// Create a tree builder with capacity tuned to the expected input size.
123    ///
124    /// Uses heuristics to estimate node, text, and attribute counts from the
125    /// input byte length, reducing reallocations for large documents.
126    pub fn with_capacity_hint(input_len: usize) -> Self {
127        let node_cap = (input_len / 32).max(256);
128        // Source-backed text uses offsets, not slab — smaller alloc suffices.
129        let text_cap = (input_len / 16).max(4096);
130        let attr_cap = (input_len / 128).max(64);
131        let mut arena = Arena::with_capacity(node_cap, text_cap, attr_cap);
132        // Create a synthetic document root.
133        let root = arena.new_element(Tag::Unknown, 0);
134        let mut open_elements = Vec::with_capacity(32);
135        open_elements.push((root, Tag::Unknown, 0));
136        Self {
137            arena,
138            open_elements,
139            root,
140            source_base: 0,
141            source_len: 0,
142            suppressed_opens: 0,
143        }
144    }
145
146    /// Enable the inline tag index for O(1) tag lookups after parsing.
147    ///
148    /// When enabled, each element's tag is indexed during tree construction,
149    /// eliminating the need for a separate DFS pass in
150    /// [`DocumentIndex::build`](crate::arena::Arena::tag_index).
151    pub fn enable_tag_index(&mut self) {
152        self.arena.enable_tag_index();
153    }
154
155    /// Enable source-backed text nodes.
156    ///
157    /// Stores an owned copy of `input` in the arena. Text nodes whose content
158    /// is borrowed from `input` (entity-free) will reference the source
159    /// instead of copying to the text slab.
160    pub fn set_source(&mut self, input: &str) {
161        self.source_base = input.as_ptr() as usize;
162        self.source_len = input.len();
163        self.arena.set_source(input);
164    }
165
166    /// Set source pointer tracking without copying data to the arena.
167    ///
168    /// Use this with [`Arena::set_source_owned`] after tokenization to
169    /// avoid a redundant memcpy when the caller owns the input `String`.
170    pub fn set_source_ptr(&mut self, input: &str) {
171        self.source_base = input.as_ptr() as usize;
172        self.source_len = input.len();
173    }
174
175    /// Process a single token and insert it into the tree.
176    ///
177    /// Returns the [`NodeId`] of the newly created node, or `None` if the
178    /// token did not produce a node (e.g. a close tag or depth limit hit).
179    #[inline]
180    pub fn process(&mut self, token: &Token<'_>) -> Option<NodeId> {
181        match token {
182            Token::OpenTag {
183                tag,
184                name,
185                attributes,
186                self_closing,
187                ..
188            } => self.handle_open_tag(*tag, name.as_ref(), attributes, *self_closing),
189            Token::CloseTag { tag, name } => {
190                self.handle_close_tag(*tag, name.as_ref());
191                None
192            }
193            Token::Text { content } => self.handle_text(content.as_ref()),
194            Token::Comment { content } => self.handle_comment(content.as_ref()),
195            Token::Doctype { content } => self.handle_doctype(content.as_ref()),
196            Token::CData { content } => {
197                // Treat CDATA as text.
198                self.handle_text(content.as_ref())
199            }
200        }
201    }
202
203    /// Finish building and return the root node id and the arena.
204    pub fn finish(self) -> (Arena, NodeId) {
205        // Any unclosed elements are implicitly closed (they stay in the tree).
206        (self.arena, self.root)
207    }
208
209    /// Current parent node (top of open_elements stack).
210    #[inline]
211    fn current_parent(&self) -> NodeId {
212        self.open_elements
213            .last()
214            .map(|&(id, _, _)| id)
215            .unwrap_or(self.root)
216    }
217
218    /// Current depth.
219    #[inline]
220    fn current_depth(&self) -> u16 {
221        (self.open_elements.len() as u16).min(MAX_DEPTH)
222    }
223
224    /// Handle an open tag token.
225    fn handle_open_tag(
226        &mut self,
227        tag: Tag,
228        name: &str,
229        attributes: &[fhp_tokenizer::token::Attribute<'_>],
230        self_closing: bool,
231    ) -> Option<NodeId> {
232        // Apply implicit close rules.
233        self.apply_implicit_close(tag);
234
235        // Enforce depth limit.
236        if self.current_depth() >= MAX_DEPTH {
237            // A dropped non-void open would otherwise have a matching close
238            // later; record it so that close is swallowed instead of popping a
239            // real element.
240            if !tag.is_void() && !self_closing {
241                self.suppressed_opens += 1;
242            }
243            return None;
244        }
245
246        let depth = self.current_depth();
247        let parent = self.current_parent();
248        let node = self.arena.new_element(tag, depth);
249        if tag == Tag::Unknown {
250            self.arena.set_unknown_tag_name(node, name);
251        }
252
253        // Set attributes.
254        if !attributes.is_empty() {
255            self.arena.set_attrs(node, attributes);
256        }
257
258        // Append to parent and set element index from parent's counter.
259        self.arena.append_child(parent, node);
260        if let Some(parent_entry) = self.open_elements.last_mut() {
261            // Saturating: the element index is a u16, so a parent with more
262            // than u16::MAX element children pins later children at u16::MAX
263            // rather than overflowing (debug panic / release wrap to 0, which
264            // would make a node look like "not an element" to :nth-child).
265            parent_entry.2 = parent_entry.2.saturating_add(1);
266            self.arena.set_element_index(node, parent_entry.2);
267        }
268
269        // Void elements and self-closing tags don't go on the stack.
270        if tag.is_void() || self_closing {
271            if self_closing {
272                self.arena.set_self_closing(node);
273            }
274            // Void elements are always marked self-closing in the tree.
275            if tag.is_void() {
276                self.arena.set_self_closing(node);
277            }
278        } else {
279            self.open_elements.push((node, tag, 0));
280        }
281
282        Some(node)
283    }
284
285    /// Handle a close tag token.
286    fn handle_close_tag(&mut self, tag: Tag, name: &str) {
287        // Ignore close tags for void elements.
288        if tag.is_void() {
289            return;
290        }
291
292        // Swallow the close of an element that was dropped at the depth limit
293        // (LIFO: depth-dropped elements are the most deeply nested, so their
294        // closes arrive before any close that targets the real stack).
295        if self.suppressed_opens > 0 {
296            self.suppressed_opens -= 1;
297            return;
298        }
299
300        // Find the matching open element on the stack.
301        // Walk backwards to find the nearest match — read tag from cached tuple.
302        let mut match_idx = None;
303        for i in (1..self.open_elements.len()).rev() {
304            let (open_id, open_tag, _) = self.open_elements[i];
305            if open_tag == tag {
306                if tag == Tag::Unknown {
307                    let open_name = self.arena.unknown_tag_name(open_id).unwrap_or("");
308                    if !open_name.eq_ignore_ascii_case(name) {
309                        continue;
310                    }
311                }
312                match_idx = Some(i);
313                break;
314            }
315        }
316
317        if let Some(idx) = match_idx {
318            // Pop everything from idx onwards (implicitly closing).
319            self.open_elements.truncate(idx);
320        }
321        // If no match found, ignore the close tag (broken HTML recovery).
322    }
323
324    /// Handle text content (already entity-decoded by the tokenizer).
325    ///
326    /// If the text is borrowed from the original source (pointer falls within
327    /// the source range), creates a source-backed text node to avoid copying.
328    fn handle_text(&mut self, content: &str) -> Option<NodeId> {
329        if content.is_empty() {
330            return None;
331        }
332        let depth = self.current_depth();
333        let parent = self.current_parent();
334        let node = self.try_source_ref(depth, content);
335        self.arena.append_child(parent, node);
336        Some(node)
337    }
338
339    /// Handle raw text from TreeSink (not entity-decoded).
340    ///
341    /// Performs entity decoding if the `entity-decode` feature is enabled,
342    /// then creates a source-backed or slab text node.
343    fn handle_raw_text(&mut self, raw: &str) -> Option<NodeId> {
344        if raw.is_empty() {
345            return None;
346        }
347        let depth = self.current_depth();
348        let parent = self.current_parent();
349
350        #[cfg(feature = "entity-decode")]
351        let node = {
352            let parent_is_raw_text = self.arena.get(parent).tag.is_raw_text();
353            if parent_is_raw_text {
354                self.try_source_ref(depth, raw)
355            } else {
356                let decoded = fhp_tokenizer::entity::decode_entities(raw);
357                match decoded {
358                    std::borrow::Cow::Borrowed(s) => self.try_source_ref(depth, s),
359                    std::borrow::Cow::Owned(s) => self.arena.new_text(depth, &s),
360                }
361            }
362        };
363
364        #[cfg(not(feature = "entity-decode"))]
365        let node = self.try_source_ref(depth, raw);
366
367        self.arena.append_child(parent, node);
368        Some(node)
369    }
370
371    /// Create a text node, using a source-backed ref if the pointer is within
372    /// the original source range.
373    #[inline]
374    fn try_source_ref(&mut self, depth: u16, content: &str) -> NodeId {
375        if self.source_len > 0 {
376            let ptr = content.as_ptr() as usize;
377            if ptr >= self.source_base && ptr + content.len() <= self.source_base + self.source_len
378            {
379                let offset = ptr - self.source_base;
380                return self
381                    .arena
382                    .new_text_ref(depth, offset as u32, content.len() as u32);
383            }
384        }
385        self.arena.new_text(depth, content)
386    }
387
388    /// Handle a comment.
389    fn handle_comment(&mut self, content: &str) -> Option<NodeId> {
390        let depth = self.current_depth();
391        let parent = self.current_parent();
392        let node = self.arena.new_comment(depth, content);
393        self.arena.append_child(parent, node);
394        Some(node)
395    }
396
397    /// Handle a doctype declaration.
398    fn handle_doctype(&mut self, content: &str) -> Option<NodeId> {
399        let depth = self.current_depth();
400        let parent = self.current_parent();
401        let node = self.arena.new_doctype(depth, content);
402        self.arena.append_child(parent, node);
403        Some(node)
404    }
405
406    /// Apply implicit close rules based on the new tag.
407    fn apply_implicit_close(&mut self, new_tag: Tag) {
408        // Check if the current open element should be implicitly closed.
409        // Tag is read from the cached tuple — no arena access needed.
410        while self.open_elements.len() > 1 {
411            let (_, current_tag, _) = *self.open_elements.last().unwrap();
412
413            if should_implicit_close(current_tag, new_tag) {
414                self.open_elements.pop();
415            } else {
416                break;
417            }
418        }
419    }
420}
421
422impl fhp_tokenizer::TreeSink for TreeBuilder {
423    fn open_tag(&mut self, tag: Tag, name: &str, attr_raw: &str, self_closing: bool) {
424        self.apply_implicit_close(tag);
425
426        if self.current_depth() >= MAX_DEPTH {
427            // See `handle_open_tag`: swallow the matching close of a dropped
428            // non-void open so it does not pop a real element later.
429            if !tag.is_void() && !self_closing {
430                self.suppressed_opens += 1;
431            }
432            return;
433        }
434
435        let depth = self.current_depth();
436        let parent = self.current_parent();
437        let node = self.arena.new_element(tag, depth);
438        if tag == Tag::Unknown {
439            self.arena.set_unknown_tag_name(node, name);
440        }
441
442        // Parse attributes directly into the slab (no intermediate Vec).
443        self.arena.set_attrs_from_raw(node, attr_raw);
444
445        self.arena.append_child(parent, node);
446        if let Some(parent_entry) = self.open_elements.last_mut() {
447            // Saturating: the element index is a u16, so a parent with more
448            // than u16::MAX element children pins later children at u16::MAX
449            // rather than overflowing (debug panic / release wrap to 0, which
450            // would make a node look like "not an element" to :nth-child).
451            parent_entry.2 = parent_entry.2.saturating_add(1);
452            self.arena.set_element_index(node, parent_entry.2);
453        }
454
455        if tag.is_void() || self_closing {
456            if self_closing {
457                self.arena.set_self_closing(node);
458            }
459            if tag.is_void() {
460                self.arena.set_self_closing(node);
461            }
462        } else {
463            self.open_elements.push((node, tag, 0));
464        }
465    }
466
467    fn close_tag(&mut self, tag: Tag, name: &str) {
468        self.handle_close_tag(tag, name);
469    }
470
471    fn text(&mut self, raw: &str) {
472        self.handle_raw_text(raw);
473    }
474
475    fn comment(&mut self, content: &str) {
476        self.handle_comment(content);
477    }
478
479    fn doctype(&mut self, content: &str) {
480        self.handle_doctype(content);
481    }
482
483    fn cdata(&mut self, content: &str) {
484        // CDATA treated as text.
485        self.handle_raw_text(content);
486    }
487}
488
489impl Default for TreeBuilder {
490    fn default() -> Self {
491        Self::new()
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use crate::node::NodeFlags;
499    use std::borrow::Cow;
500
501    fn make_open(tag: Tag) -> Token<'static> {
502        Token::OpenTag {
503            tag,
504            name: Cow::Borrowed(tag.as_str().unwrap_or("unknown")),
505            attributes: vec![],
506            self_closing: false,
507        }
508    }
509
510    fn make_close(tag: Tag) -> Token<'static> {
511        Token::CloseTag {
512            tag,
513            name: Cow::Borrowed(tag.as_str().unwrap_or("unknown")),
514        }
515    }
516
517    fn make_text(content: &'static str) -> Token<'static> {
518        Token::Text {
519            content: Cow::Borrowed(content),
520        }
521    }
522
523    #[test]
524    fn simple_tree() {
525        let mut builder = TreeBuilder::new();
526        builder.process(&make_open(Tag::Div));
527        builder.process(&make_text("hello"));
528        builder.process(&make_close(Tag::Div));
529
530        let (arena, root) = builder.finish();
531
532        // Root should have one child (div).
533        let div = arena.get(root).first_child;
534        assert!(!div.is_null());
535        assert_eq!(arena.get(div).tag, Tag::Div);
536
537        // Div should have one child (text).
538        let text = arena.get(div).first_child;
539        assert!(!text.is_null());
540        assert!(arena.get(text).flags.has(NodeFlags::IS_TEXT));
541        assert_eq!(arena.text(text), "hello");
542    }
543
544    #[test]
545    fn void_element_not_pushed() {
546        let mut builder = TreeBuilder::new();
547        builder.process(&make_open(Tag::Div));
548        builder.process(&Token::OpenTag {
549            tag: Tag::Br,
550            name: Cow::Borrowed("br"),
551            attributes: vec![],
552            self_closing: false,
553        });
554        builder.process(&make_text("after br"));
555        builder.process(&make_close(Tag::Div));
556
557        let (arena, root) = builder.finish();
558        let div = arena.get(root).first_child;
559        let br = arena.get(div).first_child;
560        assert_eq!(arena.get(br).tag, Tag::Br);
561
562        // Text should be a sibling of br, not a child.
563        let text = arena.get(br).next_sibling;
564        assert!(!text.is_null());
565        assert_eq!(arena.text(text), "after br");
566    }
567
568    #[test]
569    fn implicit_close_p() {
570        let mut builder = TreeBuilder::new();
571        builder.process(&make_open(Tag::P));
572        builder.process(&make_text("first"));
573        builder.process(&make_open(Tag::P));
574        builder.process(&make_text("second"));
575        builder.process(&make_close(Tag::P));
576
577        let (arena, root) = builder.finish();
578
579        // Both <p> elements should be children of root.
580        let p1 = arena.get(root).first_child;
581        assert_eq!(arena.get(p1).tag, Tag::P);
582
583        let p2 = arena.get(p1).next_sibling;
584        assert!(!p2.is_null());
585        assert_eq!(arena.get(p2).tag, Tag::P);
586
587        // p1 has "first", p2 has "second".
588        assert_eq!(arena.text(arena.get(p1).first_child), "first");
589        assert_eq!(arena.text(arena.get(p2).first_child), "second");
590    }
591
592    #[test]
593    fn mismatched_close_finds_nearest() {
594        // <div><span></div> — should close both span and div.
595        let mut builder = TreeBuilder::new();
596        builder.process(&make_open(Tag::Div));
597        builder.process(&make_open(Tag::Span));
598        builder.process(&make_text("hi"));
599        builder.process(&make_close(Tag::Div));
600
601        let (arena, root) = builder.finish();
602        let div = arena.get(root).first_child;
603        assert_eq!(arena.get(div).tag, Tag::Div);
604    }
605
606    #[test]
607    fn extra_close_tag_ignored() {
608        let mut builder = TreeBuilder::new();
609        builder.process(&make_close(Tag::Div)); // No matching open — ignored.
610        builder.process(&make_open(Tag::P));
611        builder.process(&make_text("ok"));
612        builder.process(&make_close(Tag::P));
613
614        let (arena, root) = builder.finish();
615        let p = arena.get(root).first_child;
616        assert_eq!(arena.get(p).tag, Tag::P);
617    }
618
619    #[test]
620    fn unknown_close_matches_by_name() {
621        let mut builder = TreeBuilder::new();
622        builder.process(&Token::OpenTag {
623            tag: Tag::Unknown,
624            name: Cow::Borrowed("my-widget"),
625            attributes: vec![],
626            self_closing: false,
627        });
628        builder.process(&Token::OpenTag {
629            tag: Tag::Unknown,
630            name: Cow::Borrowed("x-item"),
631            attributes: vec![],
632            self_closing: false,
633        });
634        builder.process(&Token::CloseTag {
635            tag: Tag::Unknown,
636            name: Cow::Borrowed("my-widget"),
637        });
638
639        let (arena, root) = builder.finish();
640        let my_widget = arena.get(root).first_child;
641        let x_item = arena.get(my_widget).first_child;
642        assert_eq!(arena.unknown_tag_name(my_widget), Some("my-widget"));
643        assert_eq!(arena.unknown_tag_name(x_item), Some("x-item"));
644    }
645
646    #[test]
647    fn should_implicit_close_rules() {
648        assert!(should_implicit_close(Tag::P, Tag::P));
649        assert!(should_implicit_close(Tag::Li, Tag::Li));
650        assert!(should_implicit_close(Tag::Td, Tag::Td));
651        assert!(should_implicit_close(Tag::Td, Tag::Th));
652        assert!(should_implicit_close(Tag::Th, Tag::Td));
653        assert!(should_implicit_close(Tag::Tr, Tag::Tr));
654
655        assert!(!should_implicit_close(Tag::Div, Tag::Div));
656        assert!(!should_implicit_close(Tag::Span, Tag::Span));
657        assert!(!should_implicit_close(Tag::P, Tag::Span));
658    }
659}