Skip to main content

html5_parser/
lib.rs

1// Public API surface: `parse` plus the read-only tree types
2// (`Document`/`NodeId`/`NodeKind`/`Attribute`/`Position`/`Node`/`Children`)
3// needed to walk its output — just enough for html-conform's
4// `src/infoset.rs::normalize()` to consume, per Step 1 of this crate's
5// two-stage scope (see `CLAUDE.md`). `Tokenizer`/`TreeBuilder` and
6// everything else stay crate-internal; there's no commitment to their
7// shape yet. See plan/DECISIONS.md.
8
9mod document;
10mod entities;
11mod tokenizer;
12mod tree_builder;
13
14pub use document::{Attribute, Children, Document, Node, NodeId, NodeKind};
15pub use tokenizer::{ParseError, ParseErrorKind, Position};
16
17use tokenizer::Tokenizer;
18use tree_builder::TreeBuilder;
19
20/// [`parse`]'s return value: the parsed [`Document`] tree plus every
21/// WHATWG "parse error" (§13.2.2) encountered along the way. Both fields
22/// together, not a `Result` — parse errors are never fatal, `document`
23/// is always complete regardless of how many occurred (see
24/// [`ParseError`]'s doc comment).
25#[derive(Debug)]
26pub struct ParseResult {
27    pub document: Document,
28    pub errors: Vec<ParseError>,
29}
30
31/// The driver loop (§13.2 "Parsing HTML documents", the "tokenization and
32/// tree construction" step): parses `input` into a [`Document`] tree with
33/// per-node source positions, feeding it through the tokenizer and handing
34/// each token to the tree builder, applying the two pieces of feedback
35/// tree construction sends back to the tokenizer — a state switch
36/// (`Tokenizer::switch_to`, for RCDATA/RAWTEXT/script-data/PLAINTEXT
37/// elements) and the foreign-content flag (`Tokenizer::set_in_foreign_content`,
38/// consulted only by CDATA-section handling).
39///
40/// The tokenizer's iterator yields exactly one `Eof` token and then ends
41/// (`None`) on the next call, so the loop needs no separate condition for
42/// *when* to stop feeding it tokens. `TreeBuilder::stop_parsing` (§13.2.7
43/// "The end") still runs once, explicitly, right after — its one
44/// tree-shape-relevant step ("pop all the nodes off the stack of open
45/// elements") isn't implied by the loop simply ending.
46///
47/// Returns [`ParseResult`], not a bare [`Document`], as of Phase 07
48/// (`plan/07-parse-errors.md`) — `errors` covers every tokenizer-level
49/// parse error (`src/tokenizer.rs`'s `error()` call sites) plus, as of
50/// Phase 08 (`plan/08-tree-construction-errors.md`), the
51/// tree-construction-level (§13.2.6) conditions listed there. Both
52/// sources are merged and sorted by source position, so `errors` is
53/// always in document order regardless of which stage produced each
54/// entry (the two stages interleave: the tokenizer runs ahead of the
55/// tree builder token by token).
56pub fn parse(input: &str) -> ParseResult {
57    let mut tokenizer = Tokenizer::new(input);
58    let mut tree_builder = TreeBuilder::new();
59    while let Some(token) = tokenizer.next() {
60        if let Some(state) = tree_builder.process_token(&token.kind, token.position) {
61            tokenizer.switch_to(state);
62        }
63        tokenizer.set_in_foreign_content(tree_builder.is_in_foreign_content());
64    }
65    tree_builder.stop_parsing();
66    let mut errors = tokenizer.take_errors();
67    errors.append(&mut tree_builder.take_errors());
68    // Stable, so two errors reported at the same position keep their
69    // relative order (tokenizer-first, matching the order the stages
70    // actually observe a given token).
71    errors.sort_by_key(|error| error.position.byte_offset);
72    ParseResult {
73        document: tree_builder.into_document(),
74        errors,
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::parse;
81    use crate::document::{Document, NodeId, NodeKind};
82    use crate::tree_builder::{HTML_NAMESPACE, MATHML_NAMESPACE, SVG_NAMESPACE};
83
84    /// Navigates root -> html -> body, the common starting point for most
85    /// of this module's tree-shape assertions.
86    fn body_of(document: &Document) -> NodeId {
87        let root = document.root();
88        // Skip a possible leading DOCTYPE sibling to find the html
89        // element, root's first *element* child rather than its first
90        // child outright.
91        let html = document
92            .children(root)
93            .find(|&node| matches!(document.node(node).kind, NodeKind::Element { .. }))
94            .unwrap();
95        document.children(html).nth(1).unwrap()
96    }
97
98    #[test]
99    fn parses_a_minimal_document_into_the_expected_tree_shape() {
100        let document = parse(
101            "<!DOCTYPE html><html><head><title>Hi</title></head><body><p>Hello</p></body></html>",
102        )
103        .document;
104
105        let root = document.root();
106        let root_children: Vec<_> = document.children(root).collect();
107        assert_eq!(root_children.len(), 2);
108        assert_eq!(
109            document.node(root_children[0]).kind,
110            NodeKind::Doctype {
111                name: Some("html".to_owned()),
112                public_identifier: Some(String::new()),
113                system_identifier: Some(String::new()),
114            }
115        );
116
117        let html = root_children[1];
118        let html_children: Vec<_> = document.children(html).collect();
119        assert_eq!(html_children.len(), 2);
120        let (head, body) = (html_children[0], html_children[1]);
121
122        let title = document.children(head).next().unwrap();
123        let NodeKind::Element { name, .. } = &document.node(title).kind else {
124            unreachable!()
125        };
126        assert_eq!(name, "title");
127        let title_text = document.children(title).next().unwrap();
128        assert_eq!(
129            document.node(title_text).kind,
130            NodeKind::Text {
131                content: "Hi".to_owned()
132            }
133        );
134
135        let p = document.children(body).next().unwrap();
136        let NodeKind::Element { name, .. } = &document.node(p).kind else {
137            unreachable!()
138        };
139        assert_eq!(name, "p");
140        let p_text = document.children(p).next().unwrap();
141        assert_eq!(
142            document.node(p_text).kind,
143            NodeKind::Text {
144                content: "Hello".to_owned()
145            }
146        );
147    }
148
149    #[test]
150    fn parses_implied_html_head_body_when_missing() {
151        let document = parse("<p>Hello</p>").document;
152
153        let root = document.root();
154        assert_eq!(document.children(root).count(), 1);
155        let html = document.children(root).next().unwrap();
156        let html_children: Vec<_> = document.children(html).collect();
157        assert_eq!(html_children.len(), 2);
158        let body = html_children[1];
159
160        let p = document.children(body).next().unwrap();
161        let NodeKind::Element { name, .. } = &document.node(p).kind else {
162            unreachable!()
163        };
164        assert_eq!(name, "p");
165    }
166
167    #[test]
168    fn rcdata_element_content_is_not_parsed_as_markup() {
169        let document = parse("<title><b>not bold</b></title>").document;
170
171        let root = document.root();
172        let html = document.children(root).next().unwrap();
173        let head = document.children(html).next().unwrap();
174        let title = document.children(head).next().unwrap();
175        let text = document.children(title).next().unwrap();
176        assert_eq!(
177            document.node(text).kind,
178            NodeKind::Text {
179                content: "<b>not bold</b>".to_owned()
180            }
181        );
182    }
183
184    #[test]
185    fn parse_syncs_in_foreign_content_for_cdata_sections() {
186        // Exercises the one piece of driver-loop wiring no lower-level
187        // test can reach: Tokenizer::set_in_foreign_content is only
188        // ever called from here, based on the *real* TreeBuilder state
189        // after processing the <svg> start tag.
190        let document = parse("<svg><![CDATA[hello]]></svg>").document;
191
192        let root = document.root();
193        let html = document.children(root).next().unwrap();
194        let body = document.children(html).nth(1).unwrap();
195        let svg = document.children(body).next().unwrap();
196        let content = document.children(svg).next().unwrap();
197        assert_eq!(
198            document.node(content).kind,
199            NodeKind::Text {
200                content: "hello".to_owned()
201            }
202        );
203    }
204
205    #[test]
206    fn cdata_outside_foreign_content_becomes_a_bogus_comment() {
207        let document = parse("<p><![CDATA[hello]]></p>").document;
208
209        let root = document.root();
210        let html = document.children(root).next().unwrap();
211        let body = document.children(html).nth(1).unwrap();
212        let p = document.children(body).next().unwrap();
213        let content = document.children(p).next().unwrap();
214        assert_eq!(
215            document.node(content).kind,
216            NodeKind::Comment {
217                content: "[CDATA[hello]]".to_owned()
218            }
219        );
220    }
221
222    // The test matrix from plan/03-tree-construction.md's "Testmatrix"
223    // step: cases ported from html-conform's own src/infoset.rs test
224    // matrix (so the eventual switch from its current HTML5-parsing
225    // dependency to this crate is behavior-preserving), plus spec-
226    // derived cases html-conform doesn't cover at all (adoption agency,
227    // quirks mode's effect on tree shape, tables without explicit
228    // tbody/tr) that the phase's exit criteria call out explicitly.
229
230    #[test]
231    fn optional_end_tags_produce_sibling_li_elements() {
232        // Ported from html-conform's optional_end_tags_produce_sibling_elements.
233        let document = parse("<ul><li>a<li>b</ul>").document;
234        let body = body_of(&document);
235        let ul = document.children(body).next().unwrap();
236        let items: Vec<_> = document.children(ul).collect();
237        assert_eq!(items.len(), 2);
238        for (&li, expected_text) in items.iter().zip(["a", "b"]) {
239            let NodeKind::Element { name, .. } = &document.node(li).kind else {
240                unreachable!()
241            };
242            assert_eq!(name, "li");
243            let text = document.children(li).next().unwrap();
244            assert_eq!(
245                document.node(text).kind,
246                NodeKind::Text {
247                    content: expected_text.to_owned()
248                }
249            );
250        }
251    }
252
253    #[test]
254    fn svg_element_keeps_svg_namespace_end_to_end() {
255        // Ported from html-conform's svg_elements_keep_svg_namespace.
256        let document = parse("<svg><circle/></svg>").document;
257        let body = body_of(&document);
258        let svg = document.children(body).next().unwrap();
259        assert_eq!(
260            document.node(svg).kind,
261            NodeKind::Element {
262                name: "svg".to_owned(),
263                namespace: Some(SVG_NAMESPACE.to_owned()),
264                attributes: vec![],
265            }
266        );
267        let circle = document.children(svg).next().unwrap();
268        assert_eq!(
269            document.node(circle).kind,
270            NodeKind::Element {
271                name: "circle".to_owned(),
272                namespace: Some(SVG_NAMESPACE.to_owned()),
273                attributes: vec![],
274            }
275        );
276    }
277
278    #[test]
279    fn mathml_element_keeps_mathml_namespace_end_to_end() {
280        // Ported from html-conform's mathml_elements_keep_mathml_namespace.
281        let document = parse("<math><mi>x</mi></math>").document;
282        let body = body_of(&document);
283        let math = document.children(body).next().unwrap();
284        assert_eq!(
285            document.node(math).kind,
286            NodeKind::Element {
287                name: "math".to_owned(),
288                namespace: Some(MATHML_NAMESPACE.to_owned()),
289                attributes: vec![],
290            }
291        );
292        let mi = document.children(math).next().unwrap();
293        assert_eq!(
294            document.node(mi).kind,
295            NodeKind::Element {
296                name: "mi".to_owned(),
297                namespace: Some(MATHML_NAMESPACE.to_owned()),
298                attributes: vec![],
299            }
300        );
301        let text = document.children(mi).next().unwrap();
302        assert_eq!(
303            document.node(text).kind,
304            NodeKind::Text {
305                content: "x".to_owned()
306            }
307        );
308    }
309
310    #[test]
311    fn script_content_is_not_tokenized_as_markup() {
312        // Ported from html-conform's script_and_style_content_normalize_to_plain_text.
313        let document = parse("<script>1 < 2;</script>").document;
314        let root = document.root();
315        let html = document.children(root).next().unwrap();
316        let head = document.children(html).next().unwrap();
317        let script = document.children(head).next().unwrap();
318        let NodeKind::Element { name, .. } = &document.node(script).kind else {
319            unreachable!()
320        };
321        assert_eq!(name, "script");
322        let text = document.children(script).next().unwrap();
323        assert_eq!(
324            document.node(text).kind,
325            NodeKind::Text {
326                content: "1 < 2;".to_owned()
327            }
328        );
329    }
330
331    #[test]
332    fn style_content_is_not_tokenized_as_markup() {
333        // Ported from html-conform's script_and_style_content_normalize_to_plain_text.
334        let document = parse("<style>a{color:red}</style>").document;
335        let root = document.root();
336        let html = document.children(root).next().unwrap();
337        let head = document.children(html).next().unwrap();
338        let style = document.children(head).next().unwrap();
339        let NodeKind::Element { name, .. } = &document.node(style).kind else {
340            unreachable!()
341        };
342        assert_eq!(name, "style");
343        let text = document.children(style).next().unwrap();
344        assert_eq!(
345            document.node(text).kind,
346            NodeKind::Text {
347                content: "a{color:red}".to_owned()
348            }
349        );
350    }
351
352    #[test]
353    fn named_character_references_resolve_to_decoded_text() {
354        // Ported from html-conform's named_entities_resolve_to_decoded_text.
355        let document = parse("<p>&amp; &copy;</p>").document;
356        let body = body_of(&document);
357        let p = document.children(body).next().unwrap();
358        let text = document.children(p).next().unwrap();
359        assert_eq!(
360            document.node(text).kind,
361            NodeKind::Text {
362                content: "& \u{a9}".to_owned()
363            }
364        );
365    }
366
367    #[test]
368    fn custom_element_gets_html_namespace_like_any_plain_element() {
369        // Ported from html-conform's custom_element_gets_xhtml_namespace_like_any_plain_element.
370        let document = parse("<my-widget>hi</my-widget>").document;
371        let body = body_of(&document);
372        let widget = document.children(body).next().unwrap();
373        assert_eq!(
374            document.node(widget).kind,
375            NodeKind::Element {
376                name: "my-widget".to_owned(),
377                namespace: Some(HTML_NAMESPACE.to_owned()),
378                attributes: vec![],
379            }
380        );
381        let text = document.children(widget).next().unwrap();
382        assert_eq!(
383            document.node(text).kind,
384            NodeKind::Text {
385                content: "hi".to_owned()
386            }
387        );
388    }
389
390    #[test]
391    fn xml_lang_attribute_stays_a_literal_unnamespaced_attribute_name() {
392        // On a plain HTML element (not inside SVG/MathML foreign
393        // content), general HTML5 parsing never namespace-splits
394        // xml:lang - "adjust foreign attributes" (§13.2.6.1) only
395        // applies while inserting a foreign element. html-conform's own
396        // xml:lang tests are about its RELAX-NG-schema adapter's own
397        // remapping on top of this, a schema-validation-specific
398        // concern, not a general parsing fact this crate should assert
399        // - this just verifies the parser's own (unremapped) output.
400        let document = parse(r#"<p xml:lang="de">hi</p>"#).document;
401        let body = body_of(&document);
402        let p = document.children(body).next().unwrap();
403        let NodeKind::Element { attributes, .. } = &document.node(p).kind else {
404            unreachable!()
405        };
406        assert_eq!(attributes.len(), 1);
407        assert_eq!(attributes[0].name, "xml:lang");
408        assert_eq!(attributes[0].value, "de");
409        assert_eq!(attributes[0].namespace, None);
410    }
411
412    #[test]
413    fn table_without_tbody_or_tr_gets_them_synthesized() {
414        // Spec-derived case (§13.2.6.4.9/.13/.14's implied-tag rules),
415        // not covered by html-conform's own test matrix at all.
416        let document = parse("<table><td>x</td></table>").document;
417        let body = body_of(&document);
418        let table = document.children(body).next().unwrap();
419        let tbody = document.children(table).next().unwrap();
420        let NodeKind::Element { name, .. } = &document.node(tbody).kind else {
421            unreachable!()
422        };
423        assert_eq!(name, "tbody");
424        let tr = document.children(tbody).next().unwrap();
425        let NodeKind::Element { name, .. } = &document.node(tr).kind else {
426            unreachable!()
427        };
428        assert_eq!(name, "tr");
429        let td = document.children(tr).next().unwrap();
430        let NodeKind::Element { name, .. } = &document.node(td).kind else {
431            unreachable!()
432        };
433        assert_eq!(name, "td");
434        let text = document.children(td).next().unwrap();
435        assert_eq!(
436            document.node(text).kind,
437            NodeKind::Text {
438                content: "x".to_owned()
439            }
440        );
441    }
442
443    #[test]
444    fn adoption_agency_spec_example_misnested_b_i_tags() {
445        // §13.2.10.1 "Misnested tags: <b><i></b></i>" - the spec's own
446        // fully worked, non-normative example, including its final DOM
447        // tree spelled out in prose: html > head, body > p > [#text:1,
448        // b > [#text:2, i > #text:3], i > #text:4, #text:5]. Not
449        // covered by html-conform's own test matrix at all.
450        let document = parse("<p>1<b>2<i>3</b>4</i>5</p>").document;
451        let body = body_of(&document);
452        let p = document.children(body).next().unwrap();
453        let p_children: Vec<_> = document.children(p).collect();
454        assert_eq!(p_children.len(), 4);
455        assert_eq!(
456            document.node(p_children[0]).kind,
457            NodeKind::Text {
458                content: "1".to_owned()
459            }
460        );
461
462        let b = p_children[1];
463        let NodeKind::Element { name, .. } = &document.node(b).kind else {
464            unreachable!()
465        };
466        assert_eq!(name, "b");
467        let b_children: Vec<_> = document.children(b).collect();
468        assert_eq!(b_children.len(), 2);
469        assert_eq!(
470            document.node(b_children[0]).kind,
471            NodeKind::Text {
472                content: "2".to_owned()
473            }
474        );
475        let inner_i = b_children[1];
476        let NodeKind::Element { name, .. } = &document.node(inner_i).kind else {
477            unreachable!()
478        };
479        assert_eq!(name, "i");
480        let inner_i_text = document.children(inner_i).next().unwrap();
481        assert_eq!(
482            document.node(inner_i_text).kind,
483            NodeKind::Text {
484                content: "3".to_owned()
485            }
486        );
487
488        let outer_i = p_children[2];
489        let NodeKind::Element { name, .. } = &document.node(outer_i).kind else {
490            unreachable!()
491        };
492        assert_eq!(name, "i");
493        let outer_i_text = document.children(outer_i).next().unwrap();
494        assert_eq!(
495            document.node(outer_i_text).kind,
496            NodeKind::Text {
497                content: "4".to_owned()
498            }
499        );
500
501        // The trailing "5" lands as p's own child, not inside the
502        // reconstructed i - </i> closes before it arrives.
503        assert_eq!(
504            document.node(p_children[3]).kind,
505            NodeKind::Text {
506                content: "5".to_owned()
507            }
508        );
509    }
510
511    #[test]
512    fn adoption_agency_spec_example_indirectly_nests_two_a_elements_via_table_misnesting() {
513        // The spec's own example, quoted directly in §13.2.6.4.7's <a>
514        // start-tag rule: "In the non-conforming stream <a href="a">
515        // a<table><a href="b">b</table>x, the first a element would be
516        // closed upon seeing the second one, and the "x" character
517        // would be inside a link to "b", not to "a" [...] The result is
518        // that the two a elements are indirectly nested inside each
519        // other." Not covered by html-conform's own test matrix.
520        let document = parse(r#"<a href="a">a<table><a href="b">b</table>x"#).document;
521        let body = body_of(&document);
522        let body_children: Vec<_> = document.children(body).collect();
523        assert_eq!(body_children.len(), 2);
524
525        let a1 = body_children[0];
526        let a1_children: Vec<_> = document.children(a1).collect();
527        assert_eq!(a1_children.len(), 3);
528        assert_eq!(
529            document.node(a1_children[0]).kind,
530            NodeKind::Text {
531                content: "a".to_owned()
532            }
533        );
534        let a2 = a1_children[1];
535        let NodeKind::Element { name, .. } = &document.node(a2).kind else {
536            unreachable!()
537        };
538        assert_eq!(name, "a");
539        let a2_text = document.children(a2).next().unwrap();
540        assert_eq!(
541            document.node(a2_text).kind,
542            NodeKind::Text {
543                content: "b".to_owned()
544            }
545        );
546        let table = a1_children[2];
547        let NodeKind::Element { name, .. } = &document.node(table).kind else {
548            unreachable!()
549        };
550        assert_eq!(name, "table");
551        assert_eq!(document.children(table).count(), 0);
552
553        // The final "x" lands in a *fresh* a element (href="b",
554        // reconstructed from the active formatting elements list),
555        // a sibling of a1 - not a1 itself.
556        let a3 = body_children[1];
557        let NodeKind::Element { name, .. } = &document.node(a3).kind else {
558            unreachable!()
559        };
560        assert_eq!(name, "a");
561        let a3_text = document.children(a3).next().unwrap();
562        assert_eq!(
563            document.node(a3_text).kind,
564            NodeKind::Text {
565                content: "x".to_owned()
566            }
567        );
568    }
569
570    #[test]
571    fn quirks_mode_changes_whether_table_closes_an_open_p_element() {
572        // Spec-derived case, not covered by html-conform's own test
573        // matrix (which drops DOCTYPE/quirks-mode entirely) - the one
574        // place quirks mode actually shapes the produced tree
575        // (§13.2.6.4.7's <table> start-tag rule).
576        let no_quirks = parse("<!DOCTYPE html><p><table></table>").document;
577        let body = body_of(&no_quirks);
578        let children: Vec<_> = no_quirks.children(body).collect();
579        assert_eq!(children.len(), 2);
580        let NodeKind::Element { name, .. } = &no_quirks.node(children[0]).kind else {
581            unreachable!()
582        };
583        assert_eq!(name, "p");
584        assert_eq!(no_quirks.children(children[0]).count(), 0);
585        let NodeKind::Element { name, .. } = &no_quirks.node(children[1]).kind else {
586            unreachable!()
587        };
588        assert_eq!(name, "table");
589
590        let quirks = parse("<p><table></table>").document; // no DOCTYPE at all -> quirks mode
591        let body = body_of(&quirks);
592        let children: Vec<_> = quirks.children(body).collect();
593        assert_eq!(children.len(), 1);
594        let NodeKind::Element { name, .. } = &quirks.node(children[0]).kind else {
595            unreachable!()
596        };
597        assert_eq!(name, "p");
598        let p_children: Vec<_> = quirks.children(children[0]).collect();
599        assert_eq!(p_children.len(), 1);
600        let NodeKind::Element { name, .. } = &quirks.node(p_children[0]).kind else {
601            unreachable!()
602        };
603        assert_eq!(name, "table");
604    }
605
606    // The next two tests pin the minimal reproductions of two infinite
607    // loops the html5lib-tests conformance corpus (tests/html5lib_conformance.rs)
608    // surfaced — both fixed in `tree_builder.rs`. Neither asserts much
609    // about the resulting tree shape; the property under test is that
610    // `parse` returns at all (a regression would hang this test rather
611    // than fail it cleanly, same as it hung `cargo test` before the fix).
612
613    #[test]
614    fn end_tag_that_walks_out_of_foreign_content_does_not_loop_forever() {
615        // `</a>` while the current node is `<svg>` (no HTML-special
616        // element between them) sends foreign content's "any other end
617        // tag" rule (§13.2.6.5) all the way up to the first HTML-namespace
618        // ancestor without ever popping anything itself — it must then
619        // hand off to that insertion mode's own HTML-content rules
620        // directly, not via `TokenOutcome::Reprocess` (which re-checks
621        // foreign-content dispatch against the very node whose
622        // foreign-namespace-ness never changed, looping forever).
623        let document = parse("<a><svg></a>").document;
624        let body = body_of(&document);
625        assert_eq!(document.children(body).count(), 1);
626    }
627
628    #[test]
629    fn template_end_tag_resets_a_stale_insertion_mode() {
630        // `<template>` inside `<thead>` implicitly opens a `<tr><td>`
631        // (this crate treats `<template>` as a plain element — no
632        // template insertion-modes stack, see README.md's "Known
633        // limitations" — so the insertion mode active for that implicit
634        // `<td>`, `InCell`, is never restored). `</template>` then pops
635        // `<td>`/`<tr>`/`<template>` off the stack without resetting the
636        // insertion mode; the next token (`</table>`) processed under
637        // the still-`InCell` mode assumes a `td`/`th` remains on the
638        // stack, which no longer holds — `close_the_cell` would then
639        // pop the now-empty stack forever looking for one.
640        let document = parse("<table><thead><template><td></template></table>").document;
641        let body = body_of(&document);
642        assert_eq!(document.children(body).count(), 1);
643    }
644
645    #[test]
646    fn frameset_document_replaces_body_and_ignores_stray_text() {
647        // html5lib-tests' tests2.dat#5: a bare `<frameset>` after the
648        // DOCTYPE, with trailing character data "in frameset" mode's
649        // "anything else" rule drops entirely (no `<body>` at all in
650        // the result — frameset and body are mutually exclusive).
651        let document = parse("<!DOCTYPE html><frameset>test").document;
652
653        let root = document.root();
654        let root_children: Vec<_> = document.children(root).collect();
655        assert_eq!(root_children.len(), 2);
656        assert_eq!(
657            document.node(root_children[0]).kind,
658            NodeKind::Doctype {
659                name: Some("html".to_owned()),
660                public_identifier: Some(String::new()),
661                system_identifier: Some(String::new()),
662            }
663        );
664        let html = root_children[1];
665
666        let html_children: Vec<_> = document.children(html).collect();
667        assert_eq!(html_children.len(), 2);
668        let NodeKind::Element { name, .. } = &document.node(html_children[0]).kind else {
669            unreachable!()
670        };
671        assert_eq!(name, "head");
672        let frameset = html_children[1];
673        let NodeKind::Element { name, .. } = &document.node(frameset).kind else {
674            unreachable!()
675        };
676        assert_eq!(name, "frameset");
677        assert_eq!(document.children(frameset).count(), 0);
678    }
679
680    #[test]
681    fn template_content_is_a_separate_fragment_from_the_template_element() {
682        // html5lib-tests' template.dat#0: `<template>`'s real content
683        // model (§13.2.6.4.4/.16) — its child is a `DocumentFragment`
684        // ("template contents"), not the text directly.
685        let document = parse("<body><template>Hello</template>").document;
686        let body = body_of(&document);
687
688        let template = document.children(body).next().unwrap();
689        let NodeKind::Element { name, .. } = &document.node(template).kind else {
690            unreachable!()
691        };
692        assert_eq!(name, "template");
693
694        let template_children: Vec<_> = document.children(template).collect();
695        assert_eq!(template_children.len(), 1);
696        let content = template_children[0];
697        assert_eq!(document.node(content).kind, NodeKind::DocumentFragment);
698
699        let content_children: Vec<_> = document.children(content).collect();
700        assert_eq!(content_children.len(), 1);
701        assert_eq!(
702            document.node(content_children[0]).kind,
703            NodeKind::Text {
704                content: "Hello".to_owned()
705            }
706        );
707    }
708
709    #[test]
710    fn selected_option_content_is_mirrored_into_selectedcontent() {
711        // html5lib-tests' webkit02.dat#47: the explicitly `selected`
712        // option (not the first one) is the one mirrored, and — since
713        // it's the last token in the input — only `stop_parsing`'s
714        // final pop of the still-open `<option>` makes that observable.
715        let document =
716            parse("<select><button><selectedcontent></button><option>X<option selected>Y").document;
717        let body = body_of(&document);
718
719        let select = document.children(body).next().unwrap();
720        let button = document.children(select).next().unwrap();
721        let selectedcontent = document.children(button).next().unwrap();
722        let selectedcontent_children: Vec<_> = document.children(selectedcontent).collect();
723        assert_eq!(selectedcontent_children.len(), 1);
724        assert_eq!(
725            document.node(selectedcontent_children[0]).kind,
726            NodeKind::Text {
727                content: "Y".to_owned()
728            }
729        );
730    }
731}
732
733/// Phase 08 (`plan/08-tree-construction-errors.md`): one minimal
734/// trigger per tree-construction [`ParseErrorKind`] variant, mirroring
735/// Phase 07's per-variant table test for the tokenizer-level kinds.
736///
737/// Each case asserts the expected kind is *present*, not that it's the
738/// only one — several of these inputs legitimately raise more than one
739/// error (an unclosed `<div>`, for instance, is both a stray-end-tag
740/// trigger and an unclosed-element-at-EOF trigger), and pinning the
741/// exact multiset would make the tests brittle without testing anything
742/// more.
743#[cfg(test)]
744mod tree_construction_error_tests {
745    use super::parse;
746    use crate::tokenizer::ParseErrorKind;
747
748    fn kinds(input: &str) -> Vec<ParseErrorKind> {
749        parse(input)
750            .errors
751            .into_iter()
752            .map(|error| error.kind)
753            .collect()
754    }
755
756    #[track_caller]
757    fn assert_raises(input: &str, expected: ParseErrorKind) {
758        let raised = kinds(input);
759        assert!(
760            raised.contains(&expected),
761            "expected {expected:?} for {input:?}, got {raised:?}"
762        );
763    }
764
765    #[track_caller]
766    fn assert_does_not_raise(input: &str, unexpected: ParseErrorKind) {
767        let raised = kinds(input);
768        assert!(
769            !raised.contains(&unexpected),
770            "expected no {unexpected:?} for {input:?}, got {raised:?}"
771        );
772    }
773
774    /// §13.2.6.4.7's "close a p element": the `<div>` closes the open
775    /// `<p>`, but `<span>` (not in the implied-end-tag set) is still
776    /// open when it does.
777    #[test]
778    fn implied_p_end_tag_with_unclosed_elements() {
779        assert_raises(
780            "<!doctype html><p><span><div>",
781            ParseErrorKind::ImpliedEndTagWithUnclosedElements,
782        );
783        // ...but a `<p>` that is itself the current node closes cleanly.
784        assert_does_not_raise(
785            "<!doctype html><p>text<div>",
786            ParseErrorKind::ImpliedEndTagWithUnclosedElements,
787        );
788    }
789
790    /// Note the explicit `<body>`: a bare `</p>` straight after the
791    /// DOCTYPE is still in "before html", whose *own* "any other end
792    /// tag" rule ignores it (reported as `StrayEndTag`, see
793    /// `stray_end_tags_before_body` below) before "in body" ever sees it.
794    #[test]
795    fn p_end_tag_without_p_in_button_scope() {
796        assert_raises(
797            "<!doctype html><body></p>",
798            ParseErrorKind::EndTagPWithoutPInButtonScope,
799        );
800        assert_does_not_raise(
801            "<!doctype html><p>text</p>",
802            ParseErrorKind::EndTagPWithoutPInButtonScope,
803        );
804    }
805
806    /// §13.2.6.4.7's "any other end tag", step 3: `body` is in the
807    /// special category, so the walk up the stack stops there.
808    #[test]
809    fn stray_end_tag_with_no_matching_open_element() {
810        assert_raises("<!doctype html><body></span>", ParseErrorKind::StrayEndTag);
811        assert_does_not_raise("<!doctype html><span>x</span>", ParseErrorKind::StrayEndTag);
812    }
813
814    #[test]
815    fn end_tag_br() {
816        assert_raises("<!doctype html></br>", ParseErrorKind::EndTagBr);
817    }
818
819    /// §13.2.5: a self-closing start tag whose handling rule never
820    /// acknowledges the flag. `<div>` is not a void element; `<br>` is.
821    #[test]
822    fn self_closing_syntax_on_a_non_void_element() {
823        assert_raises(
824            "<!doctype html><div/></div>",
825            ParseErrorKind::NonVoidHtmlElementStartTagWithTrailingSolidus,
826        );
827        assert_does_not_raise(
828            "<!doctype html><br/>",
829            ParseErrorKind::NonVoidHtmlElementStartTagWithTrailingSolidus,
830        );
831        // Foreign content acknowledges it too (§13.2.6.5).
832        assert_does_not_raise(
833            "<!doctype html><svg><rect/></svg>",
834            ParseErrorKind::NonVoidHtmlElementStartTagWithTrailingSolidus,
835        );
836    }
837
838    /// §13.2.6.4.7's end-of-file rule: `div` is not in the "may still be
839    /// open" list, `p` is.
840    #[test]
841    fn eof_with_unclosed_elements() {
842        assert_raises(
843            "<!doctype html><div>",
844            ParseErrorKind::EofWithUnclosedElements,
845        );
846        assert_does_not_raise(
847            "<!doctype html><p>text",
848            ParseErrorKind::EofWithUnclosedElements,
849        );
850    }
851
852    /// §13.2.6.4.8: EOF while still inside a RAWTEXT/RCDATA/script
853    /// element's text.
854    #[test]
855    fn eof_in_text_mode() {
856        assert_raises(
857            "<!doctype html><script>var x = 1;",
858            ParseErrorKind::EofInTextMode,
859        );
860        assert_does_not_raise(
861            "<!doctype html><script>var x = 1;</script>",
862            ParseErrorKind::EofInTextMode,
863        );
864    }
865
866    #[test]
867    fn start_tag_image() {
868        assert_raises(
869            "<!doctype html><image src=x>",
870            ParseErrorKind::StartTagImage,
871        );
872        assert_does_not_raise("<!doctype html><img src=x>", ParseErrorKind::StartTagImage);
873    }
874
875    #[test]
876    fn nested_form() {
877        assert_raises("<!doctype html><form><form>", ParseErrorKind::NestedForm);
878        assert_does_not_raise(
879            "<!doctype html><form></form><form>",
880            ParseErrorKind::NestedForm,
881        );
882    }
883
884    #[test]
885    fn start_tag_table_while_a_table_is_open() {
886        assert_raises(
887            "<!doctype html><table><table></table></table>",
888            ParseErrorKind::StartTagTableInTable,
889        );
890        assert_does_not_raise(
891            "<!doctype html><table></table><table></table>",
892            ParseErrorKind::StartTagTableInTable,
893        );
894    }
895
896    /// §13.2.6.4.9's "anything else" — the foster-parenting fallback.
897    #[test]
898    fn misplaced_token_in_table() {
899        assert_raises(
900            "<!doctype html><table><select></select></table>",
901            ParseErrorKind::MisplacedTokenInTable,
902        );
903        assert_raises(
904            "<!doctype html><table><input></table>",
905            ParseErrorKind::MisplacedTokenInTable,
906        );
907        assert_does_not_raise(
908            "<!doctype html><table><tr><td>x</td></tr></table>",
909            ParseErrorKind::MisplacedTokenInTable,
910        );
911    }
912
913    /// §13.2.6.4.10: reported once per non-whitespace character run, not
914    /// once per character.
915    #[test]
916    fn non_space_characters_in_table() {
917        let raised = kinds("<!doctype html><table>text</table>");
918        assert_eq!(
919            raised
920                .iter()
921                .filter(|kind| **kind == ParseErrorKind::NonSpaceCharactersInTable)
922                .count(),
923            1,
924            "got {raised:?}"
925        );
926        assert_does_not_raise(
927            "<!doctype html><table>   </table>",
928            ParseErrorKind::NonSpaceCharactersInTable,
929        );
930    }
931
932    #[test]
933    fn stray_end_tag_in_table() {
934        assert_raises(
935            "<!doctype html><table></tr></table>",
936            ParseErrorKind::StrayEndTagInTable,
937        );
938    }
939
940    /// §13.2.6.4.17's "anything else" — any non-whitespace content once
941    /// `</body>` has been seen.
942    #[test]
943    fn token_after_body() {
944        assert_raises(
945            "<!doctype html><body></body>text",
946            ParseErrorKind::TokenAfterBody,
947        );
948        assert_raises(
949            "<!doctype html><body></body><p>x</p>",
950            ParseErrorKind::TokenAfterBody,
951        );
952        assert_does_not_raise(
953            "<!doctype html><body></body>\n",
954            ParseErrorKind::TokenAfterBody,
955        );
956    }
957
958    /// A second DOCTYPE, anywhere after the "initial" insertion mode has
959    /// already consumed the first one.
960    #[test]
961    fn stray_doctype() {
962        assert_raises(
963            "<!doctype html><title>t</title><!doctype html>",
964            ParseErrorKind::StrayDoctype,
965        );
966        assert_raises(
967            "<!doctype html><body>x<!doctype html>",
968            ParseErrorKind::StrayDoctype,
969        );
970        assert_does_not_raise(
971            "<!doctype html><title>t</title>",
972            ParseErrorKind::StrayDoctype,
973        );
974    }
975
976    /// A well-formed document raises no tree-construction error at all —
977    /// guards the new ignore-the-token reports against firing on valid
978    /// markup (explicit `html`/`head`/`body` tags, a full table, nested
979    /// formatting elements closed in order).
980    #[test]
981    fn valid_document_raises_no_errors() {
982        let raised = kinds(
983            "<!doctype html><html lang=en><head><title>t</title></head><body>\
984             <h1>x</h1><ul><li>a</li></ul><dl><dt>t</dt><dd>d</dd></dl>\
985             <form><p><b><i>x</i></b> <a href=/>y</a></p></form>\
986             <table><caption>c</caption><colgroup><col></colgroup>\
987             <thead><tr><th>h</th></tr></thead><tbody><tr><td>x</td></tr></tbody></table>\
988             <template><div></div></template><select><option>o</option></select>\
989             </body></html>\n",
990        );
991        assert!(raised.is_empty(), "got {raised:?}");
992    }
993
994    /// §13.2.6.4.7: every "in body" end-tag rule that ignores the token
995    /// when no matching element is in scope ("this is a parse error;
996    /// ignore the token").
997    #[test]
998    fn stray_end_tags_with_no_element_in_scope() {
999        for input in [
1000            "<!doctype html><p>x</p></div>",
1001            "<!doctype html><p>x</p></header>",
1002            "<!doctype html><p>x</p></li>",
1003            "<!doctype html><p>x</p></dd>",
1004            "<!doctype html><p>x</p></h2>",
1005            "<!doctype html><p>x</p></form>",
1006            "<!doctype html><p>x</p></object>",
1007            // `object` is a scope boundary, so `body` is not in scope.
1008            "<!doctype html><object></body></object>",
1009            "<!doctype html><object></html></object>",
1010        ] {
1011            assert_raises(input, ParseErrorKind::StrayEndTag);
1012        }
1013        // Any open heading satisfies a heading end tag, whatever its rank.
1014        assert_does_not_raise("<!doctype html><h1>x</h2>", ParseErrorKind::StrayEndTag);
1015        assert_does_not_raise(
1016            "<!doctype html><ul><li>x</li></ul>",
1017            ParseErrorKind::StrayEndTag,
1018        );
1019    }
1020
1021    /// "Any other end tag: Parse error. Ignore the token." in "before
1022    /// html" (§13.2.6.4.2), "before head" (.3), "in head" (.4), "after
1023    /// head" (.6) and "in template" (.16), plus "in head"'s `</template>`
1024    /// with no template open.
1025    #[test]
1026    fn stray_end_tags_before_body() {
1027        for input in [
1028            "<!doctype html></p>",
1029            "<!doctype html><html></div>",
1030            "<!doctype html><head></div></head>",
1031            "<!doctype html><head></template></head>",
1032            "<!doctype html><head></head></div>",
1033            "<!doctype html><template></div></template>",
1034        ] {
1035            assert_raises(input, ParseErrorKind::StrayEndTag);
1036        }
1037        assert_does_not_raise(
1038            "<!doctype html><html><head></head><body></body></html>",
1039            ParseErrorKind::StrayEndTag,
1040        );
1041    }
1042
1043    /// Start tags the spec ignores (or merges into an existing element)
1044    /// with a parse error: `html`/`body`/`frameset` in body, a second
1045    /// `head`, table-structure tags outside a table, a nested `select`.
1046    #[test]
1047    fn stray_start_tag() {
1048        for input in [
1049            "<!doctype html><body><html lang=en>",
1050            "<!doctype html><head><html>",
1051            "<!doctype html><body><body>",
1052            "<!doctype html><body><frameset>",
1053            "<!doctype html><head><head>",
1054            "<!doctype html><head></head><head>",
1055            "<!doctype html><body><td>x",
1056            "<!doctype html><body><tr>",
1057            "<!doctype html><select><select>",
1058        ] {
1059            assert_raises(input, ParseErrorKind::StrayStartTag);
1060        }
1061        assert_does_not_raise(
1062            "<!doctype html><html><head></head><body><table><tr><td>x</td></tr></table>",
1063            ParseErrorKind::StrayStartTag,
1064        );
1065    }
1066
1067    /// §13.2.6.4.7: an `a` start tag while an `a` is still in the list of
1068    /// active formatting elements, or `nobr` while one is in scope.
1069    #[test]
1070    fn nested_formatting_element() {
1071        assert_raises(
1072            "<!doctype html><a href=x><a href=y>",
1073            ParseErrorKind::NestedFormattingElement,
1074        );
1075        assert_raises(
1076            "<!doctype html><nobr><nobr>",
1077            ParseErrorKind::NestedFormattingElement,
1078        );
1079        assert_does_not_raise(
1080            "<!doctype html><a href=x>x</a><a href=y>y</a>",
1081            ParseErrorKind::NestedFormattingElement,
1082        );
1083    }
1084
1085    /// Adoption agency algorithm step 4.6: the formatting element being
1086    /// closed is not the current node. Step 4.4 then reports the
1087    /// already-closed `</i>` as a stray end tag.
1088    #[test]
1089    fn misnested_formatting_element() {
1090        let input = "<!doctype html><p><b><i>x</b></i></p>";
1091        assert_raises(input, ParseErrorKind::MisnestedFormattingElement);
1092        assert_raises(input, ParseErrorKind::StrayEndTag);
1093        assert_does_not_raise(
1094            "<!doctype html><p><b><i>x</i></b></p>",
1095            ParseErrorKind::MisnestedFormattingElement,
1096        );
1097    }
1098
1099    /// Adoption agency algorithm step 4.5: the `b` is open, but the
1100    /// `table` between it and the current node is a scope boundary.
1101    #[test]
1102    fn formatting_element_not_in_scope() {
1103        assert_raises(
1104            "<!doctype html><b><table></b></table>",
1105            ParseErrorKind::FormattingElementNotInScope,
1106        );
1107        assert_does_not_raise(
1108            "<!doctype html><b>x</b>",
1109            ParseErrorKind::FormattingElementNotInScope,
1110        );
1111    }
1112
1113    /// The table insertion modes' ignore-the-token end-tag rules
1114    /// (§13.2.6.4.9, .11–.15).
1115    #[test]
1116    fn stray_end_tags_in_table_modes() {
1117        for input in [
1118            "<!doctype html><table><caption></td></caption></table>",
1119            "<!doctype html><table><colgroup></col></colgroup></table>",
1120            "<!doctype html><table><tbody></tr></tbody></table>",
1121            "<!doctype html><table><tbody></thead></tbody></table>",
1122            "<!doctype html><table><tr></td></tr></table>",
1123            "<!doctype html><table><tr><td></caption></td></tr></table>",
1124            "<!doctype html><table><tr><td></thead></td></tr></table>",
1125        ] {
1126            assert_raises(input, ParseErrorKind::StrayEndTagInTable);
1127        }
1128        // "In table"'s `form` start tag is a parse error either way.
1129        assert_raises(
1130            "<!doctype html><table><form></table>",
1131            ParseErrorKind::MisplacedTokenInTable,
1132        );
1133    }
1134
1135    /// §13.2.6.5: a DOCTYPE inside foreign content is ignored with a
1136    /// parse error, like everywhere else after the "initial" mode.
1137    #[test]
1138    fn stray_doctype_in_foreign_content() {
1139        assert_raises(
1140            "<!doctype html><svg><!doctype html></svg>",
1141            ParseErrorKind::StrayDoctype,
1142        );
1143    }
1144
1145    /// The merged error list stays in document order even though the two
1146    /// stages produce entries independently (`parse`'s own sort).
1147    #[test]
1148    fn errors_from_both_stages_are_merged_in_document_order() {
1149        let errors = parse("<!doctype html><p>&notAnEntity;<span><div></p>").errors;
1150        assert!(
1151            errors.len() >= 2,
1152            "expected both a tokenizer and a tree-construction error, got {errors:?}"
1153        );
1154        assert!(
1155            errors
1156                .windows(2)
1157                .all(|pair| pair[0].position.byte_offset <= pair[1].position.byte_offset),
1158            "not in document order: {errors:?}"
1159        );
1160    }
1161}