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 decoded = fhp_tokenizer::entity::decode_entities(raw);
353            match decoded {
354                std::borrow::Cow::Borrowed(s) => self.try_source_ref(depth, s),
355                std::borrow::Cow::Owned(s) => self.arena.new_text(depth, &s),
356            }
357        };
358
359        #[cfg(not(feature = "entity-decode"))]
360        let node = self.try_source_ref(depth, raw);
361
362        self.arena.append_child(parent, node);
363        Some(node)
364    }
365
366    /// Create a text node, using a source-backed ref if the pointer is within
367    /// the original source range.
368    #[inline]
369    fn try_source_ref(&mut self, depth: u16, content: &str) -> NodeId {
370        if self.source_len > 0 {
371            let ptr = content.as_ptr() as usize;
372            if ptr >= self.source_base && ptr + content.len() <= self.source_base + self.source_len
373            {
374                let offset = ptr - self.source_base;
375                return self
376                    .arena
377                    .new_text_ref(depth, offset as u32, content.len() as u32);
378            }
379        }
380        self.arena.new_text(depth, content)
381    }
382
383    /// Handle a comment.
384    fn handle_comment(&mut self, content: &str) -> Option<NodeId> {
385        let depth = self.current_depth();
386        let parent = self.current_parent();
387        let node = self.arena.new_comment(depth, content);
388        self.arena.append_child(parent, node);
389        Some(node)
390    }
391
392    /// Handle a doctype declaration.
393    fn handle_doctype(&mut self, content: &str) -> Option<NodeId> {
394        let depth = self.current_depth();
395        let parent = self.current_parent();
396        let node = self.arena.new_doctype(depth, content);
397        self.arena.append_child(parent, node);
398        Some(node)
399    }
400
401    /// Apply implicit close rules based on the new tag.
402    fn apply_implicit_close(&mut self, new_tag: Tag) {
403        // Check if the current open element should be implicitly closed.
404        // Tag is read from the cached tuple — no arena access needed.
405        while self.open_elements.len() > 1 {
406            let (_, current_tag, _) = *self.open_elements.last().unwrap();
407
408            if should_implicit_close(current_tag, new_tag) {
409                self.open_elements.pop();
410            } else {
411                break;
412            }
413        }
414    }
415}
416
417impl fhp_tokenizer::TreeSink for TreeBuilder {
418    fn open_tag(&mut self, tag: Tag, name: &str, attr_raw: &str, self_closing: bool) {
419        self.apply_implicit_close(tag);
420
421        if self.current_depth() >= MAX_DEPTH {
422            // See `handle_open_tag`: swallow the matching close of a dropped
423            // non-void open so it does not pop a real element later.
424            if !tag.is_void() && !self_closing {
425                self.suppressed_opens += 1;
426            }
427            return;
428        }
429
430        let depth = self.current_depth();
431        let parent = self.current_parent();
432        let node = self.arena.new_element(tag, depth);
433        if tag == Tag::Unknown {
434            self.arena.set_unknown_tag_name(node, name);
435        }
436
437        // Parse attributes directly into the slab (no intermediate Vec).
438        self.arena.set_attrs_from_raw(node, attr_raw);
439
440        self.arena.append_child(parent, node);
441        if let Some(parent_entry) = self.open_elements.last_mut() {
442            // Saturating: the element index is a u16, so a parent with more
443            // than u16::MAX element children pins later children at u16::MAX
444            // rather than overflowing (debug panic / release wrap to 0, which
445            // would make a node look like "not an element" to :nth-child).
446            parent_entry.2 = parent_entry.2.saturating_add(1);
447            self.arena.set_element_index(node, parent_entry.2);
448        }
449
450        if tag.is_void() || self_closing {
451            if self_closing {
452                self.arena.set_self_closing(node);
453            }
454            if tag.is_void() {
455                self.arena.set_self_closing(node);
456            }
457        } else {
458            self.open_elements.push((node, tag, 0));
459        }
460    }
461
462    fn close_tag(&mut self, tag: Tag, name: &str) {
463        self.handle_close_tag(tag, name);
464    }
465
466    fn text(&mut self, raw: &str) {
467        self.handle_raw_text(raw);
468    }
469
470    fn comment(&mut self, content: &str) {
471        self.handle_comment(content);
472    }
473
474    fn doctype(&mut self, content: &str) {
475        self.handle_doctype(content);
476    }
477
478    fn cdata(&mut self, content: &str) {
479        // CDATA treated as text.
480        self.handle_raw_text(content);
481    }
482}
483
484impl Default for TreeBuilder {
485    fn default() -> Self {
486        Self::new()
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use crate::node::NodeFlags;
494    use std::borrow::Cow;
495
496    fn make_open(tag: Tag) -> Token<'static> {
497        Token::OpenTag {
498            tag,
499            name: Cow::Borrowed(tag.as_str().unwrap_or("unknown")),
500            attributes: vec![],
501            self_closing: false,
502        }
503    }
504
505    fn make_close(tag: Tag) -> Token<'static> {
506        Token::CloseTag {
507            tag,
508            name: Cow::Borrowed(tag.as_str().unwrap_or("unknown")),
509        }
510    }
511
512    fn make_text(content: &'static str) -> Token<'static> {
513        Token::Text {
514            content: Cow::Borrowed(content),
515        }
516    }
517
518    #[test]
519    fn simple_tree() {
520        let mut builder = TreeBuilder::new();
521        builder.process(&make_open(Tag::Div));
522        builder.process(&make_text("hello"));
523        builder.process(&make_close(Tag::Div));
524
525        let (arena, root) = builder.finish();
526
527        // Root should have one child (div).
528        let div = arena.get(root).first_child;
529        assert!(!div.is_null());
530        assert_eq!(arena.get(div).tag, Tag::Div);
531
532        // Div should have one child (text).
533        let text = arena.get(div).first_child;
534        assert!(!text.is_null());
535        assert!(arena.get(text).flags.has(NodeFlags::IS_TEXT));
536        assert_eq!(arena.text(text), "hello");
537    }
538
539    #[test]
540    fn void_element_not_pushed() {
541        let mut builder = TreeBuilder::new();
542        builder.process(&make_open(Tag::Div));
543        builder.process(&Token::OpenTag {
544            tag: Tag::Br,
545            name: Cow::Borrowed("br"),
546            attributes: vec![],
547            self_closing: false,
548        });
549        builder.process(&make_text("after br"));
550        builder.process(&make_close(Tag::Div));
551
552        let (arena, root) = builder.finish();
553        let div = arena.get(root).first_child;
554        let br = arena.get(div).first_child;
555        assert_eq!(arena.get(br).tag, Tag::Br);
556
557        // Text should be a sibling of br, not a child.
558        let text = arena.get(br).next_sibling;
559        assert!(!text.is_null());
560        assert_eq!(arena.text(text), "after br");
561    }
562
563    #[test]
564    fn implicit_close_p() {
565        let mut builder = TreeBuilder::new();
566        builder.process(&make_open(Tag::P));
567        builder.process(&make_text("first"));
568        builder.process(&make_open(Tag::P));
569        builder.process(&make_text("second"));
570        builder.process(&make_close(Tag::P));
571
572        let (arena, root) = builder.finish();
573
574        // Both <p> elements should be children of root.
575        let p1 = arena.get(root).first_child;
576        assert_eq!(arena.get(p1).tag, Tag::P);
577
578        let p2 = arena.get(p1).next_sibling;
579        assert!(!p2.is_null());
580        assert_eq!(arena.get(p2).tag, Tag::P);
581
582        // p1 has "first", p2 has "second".
583        assert_eq!(arena.text(arena.get(p1).first_child), "first");
584        assert_eq!(arena.text(arena.get(p2).first_child), "second");
585    }
586
587    #[test]
588    fn mismatched_close_finds_nearest() {
589        // <div><span></div> — should close both span and div.
590        let mut builder = TreeBuilder::new();
591        builder.process(&make_open(Tag::Div));
592        builder.process(&make_open(Tag::Span));
593        builder.process(&make_text("hi"));
594        builder.process(&make_close(Tag::Div));
595
596        let (arena, root) = builder.finish();
597        let div = arena.get(root).first_child;
598        assert_eq!(arena.get(div).tag, Tag::Div);
599    }
600
601    #[test]
602    fn extra_close_tag_ignored() {
603        let mut builder = TreeBuilder::new();
604        builder.process(&make_close(Tag::Div)); // No matching open — ignored.
605        builder.process(&make_open(Tag::P));
606        builder.process(&make_text("ok"));
607        builder.process(&make_close(Tag::P));
608
609        let (arena, root) = builder.finish();
610        let p = arena.get(root).first_child;
611        assert_eq!(arena.get(p).tag, Tag::P);
612    }
613
614    #[test]
615    fn unknown_close_matches_by_name() {
616        let mut builder = TreeBuilder::new();
617        builder.process(&Token::OpenTag {
618            tag: Tag::Unknown,
619            name: Cow::Borrowed("my-widget"),
620            attributes: vec![],
621            self_closing: false,
622        });
623        builder.process(&Token::OpenTag {
624            tag: Tag::Unknown,
625            name: Cow::Borrowed("x-item"),
626            attributes: vec![],
627            self_closing: false,
628        });
629        builder.process(&Token::CloseTag {
630            tag: Tag::Unknown,
631            name: Cow::Borrowed("my-widget"),
632        });
633
634        let (arena, root) = builder.finish();
635        let my_widget = arena.get(root).first_child;
636        let x_item = arena.get(my_widget).first_child;
637        assert_eq!(arena.unknown_tag_name(my_widget), Some("my-widget"));
638        assert_eq!(arena.unknown_tag_name(x_item), Some("x-item"));
639    }
640
641    #[test]
642    fn should_implicit_close_rules() {
643        assert!(should_implicit_close(Tag::P, Tag::P));
644        assert!(should_implicit_close(Tag::Li, Tag::Li));
645        assert!(should_implicit_close(Tag::Td, Tag::Td));
646        assert!(should_implicit_close(Tag::Td, Tag::Th));
647        assert!(should_implicit_close(Tag::Th, Tag::Td));
648        assert!(should_implicit_close(Tag::Tr, Tag::Tr));
649
650        assert!(!should_implicit_close(Tag::Div, Tag::Div));
651        assert!(!should_implicit_close(Tag::Span, Tag::Span));
652        assert!(!should_implicit_close(Tag::P, Tag::Span));
653    }
654}