1#![deny(unsafe_code)]
5
6pub mod encoding;
45pub mod events;
46pub mod options;
47mod sink;
48mod stream;
49
50pub use encoding::{decode_html_input, prescan_meta_charset, sniff_html_encoding};
51pub use events::{HtmlAttribute, HtmlAttrs, HtmlAttrsIter, HtmlEvent};
52pub use options::HtmlParseOptions;
53pub use stream::{HtmlBytesReader, HtmlReader, HtmlSaxHandler, HtmlSaxParser};
54
55use html5ever::driver::{parse_document, ParseOpts};
56use html5ever::tendril::TendrilSink;
57use html5ever::tokenizer::TokenizerOpts;
58use html5ever::tree_builder::TreeBuilderOpts;
59
60use crate::error::{ErrorDomain, ErrorLevel, Result, XmlError};
61
62use sup_xml_tree::dom::Document;
63
64pub fn parse_html_str(input: &str) -> Result<Document> {
77 parse_html_str_opts(input, &HtmlParseOptions::default())
78}
79
80pub fn parse_html_bytes(input: &[u8]) -> Result<Document> {
82 parse_html_bytes_opts(input, &HtmlParseOptions::default())
83}
84
85pub fn parse_html_str_opts(input: &str, opts: &HtmlParseOptions) -> Result<Document> {
87 let (doc, _recovered, fatal) = parse_html_str_inner(input, opts);
88 if let Some(fatal) = fatal {
89 return Err(fatal);
90 }
91 Ok(doc)
92}
93
94pub fn parse_html_bytes_opts(
96 input: &[u8],
97 opts: &HtmlParseOptions,
98) -> Result<Document> {
99 let (doc, _recovered, fatal) = parse_html_bytes_inner(input, opts);
100 if let Some(fatal) = fatal {
101 return Err(fatal);
102 }
103 Ok(doc)
104}
105
106#[cfg(feature = "c-abi")]
118#[allow(unsafe_code)]
119pub unsafe fn parse_html_bytes_opts_with_dict(
120 input: &[u8],
121 opts: &HtmlParseOptions,
122 dict: *mut sup_xml_tree::dict::Dict,
123) -> Result<Document> {
124 let (doc, _recovered, fatal) = unsafe { parse_html_bytes_inner_with_dict(input, opts, dict) };
125 if let Some(fatal) = fatal {
126 return Err(fatal);
127 }
128 Ok(doc)
129}
130
131#[cfg(feature = "c-abi")]
140#[allow(unsafe_code)]
141pub unsafe fn parse_html_bytes_opts_with_dict_arena(
142 input: &[u8],
143 opts: &HtmlParseOptions,
144 dict: *mut sup_xml_tree::dict::Dict,
145 arena: std::sync::Arc<bumpalo::Bump>,
146) -> Result<Document> {
147 let (doc, _recovered, fatal) =
148 unsafe { parse_html_bytes_inner_with_dict_arena(input, opts, dict, arena) };
149 if let Some(fatal) = fatal {
150 return Err(fatal);
151 }
152 drop_implicit_empty_head(&doc, input);
153 drop_implicit_empty_body(&doc, input);
154 Ok(doc)
155}
156
157#[cfg(feature = "c-abi")]
166#[allow(unsafe_code)]
167pub unsafe fn parse_html_bytes_recovered_with_dict_arena(
168 input: &[u8],
169 opts: &HtmlParseOptions,
170 dict: *mut sup_xml_tree::dict::Dict,
171 arena: std::sync::Arc<bumpalo::Bump>,
172) -> (Document, Vec<XmlError>) {
173 let (doc, mut recovered, fatal) =
174 unsafe { parse_html_bytes_inner_with_dict_arena(input, opts, dict, arena) };
175 if let Some(f) = fatal {
176 recovered.push(f);
177 }
178 drop_implicit_empty_head(&doc, input);
179 drop_implicit_empty_body(&doc, input);
180 (doc, recovered)
181}
182
183#[cfg(feature = "c-abi")]
189fn source_has_explicit_head(source: &[u8]) -> bool {
190 let mut i = 0;
191 while i + 5 < source.len() {
192 if source[i] == b'<'
193 && source[i + 1].eq_ignore_ascii_case(&b'h')
194 && source[i + 2].eq_ignore_ascii_case(&b'e')
195 && source[i + 3].eq_ignore_ascii_case(&b'a')
196 && source[i + 4].eq_ignore_ascii_case(&b'd')
197 && (source[i + 5] == b'>'
198 || source[i + 5] == b'/'
199 || source[i + 5].is_ascii_whitespace())
200 {
201 return true;
202 }
203 i += 1;
204 }
205 false
206}
207
208#[cfg(feature = "c-abi")]
215fn drop_implicit_empty_head(doc: &Document, source: &[u8]) {
216 if source_has_explicit_head(source) {
217 return;
218 }
219 let root = doc.root();
220 if root.name() != "html" {
221 return;
222 }
223 if let Some(head) = root.children().find(|c| c.is_element() && c.name() == "head") {
224 if head.children().next().is_none() {
225 let prev = head.prev_sibling.get();
229 let next = head.next_sibling.get();
230 match prev {
231 Some(p) => p.next_sibling.set(next),
232 None => root.first_child.set(next),
233 }
234 match next {
235 Some(n) => n.prev_sibling.set(prev),
236 None => root.last_child.set(prev),
237 }
238 head.parent.set(None);
239 head.prev_sibling.set(None);
240 head.next_sibling.set(None);
241 }
242 }
243}
244
245#[cfg(feature = "c-abi")]
250fn source_has_explicit_body(source: &[u8]) -> bool {
251 let mut i = 0;
252 while i + 5 < source.len() {
253 if source[i] == b'<'
254 && source[i + 1].eq_ignore_ascii_case(&b'b')
255 && source[i + 2].eq_ignore_ascii_case(&b'o')
256 && source[i + 3].eq_ignore_ascii_case(&b'd')
257 && source[i + 4].eq_ignore_ascii_case(&b'y')
258 && (source[i + 5] == b'>'
259 || source[i + 5] == b'/'
260 || source[i + 5].is_ascii_whitespace())
261 {
262 return true;
263 }
264 i += 1;
265 }
266 false
267}
268
269#[cfg(feature = "c-abi")]
274fn drop_implicit_empty_body(doc: &Document, source: &[u8]) {
275 if source_has_explicit_body(source) {
276 return;
277 }
278 let root = doc.root();
279 if root.name() != "html" {
280 return;
281 }
282 if let Some(body) = root.children().find(|c| c.is_element() && c.name() == "body") {
283 if body.children().next().is_none() && body.attributes().next().is_none() {
284 let prev = body.prev_sibling.get();
285 let next = body.next_sibling.get();
286 match prev {
287 Some(p) => p.next_sibling.set(next),
288 None => root.first_child.set(next),
289 }
290 match next {
291 Some(n) => n.prev_sibling.set(prev),
292 None => root.last_child.set(prev),
293 }
294 body.parent.set(None);
295 body.prev_sibling.set(None);
296 body.next_sibling.set(None);
297 }
298 }
299}
300
301pub fn parse_html_str_with_recovered(
309 input: &str,
310 opts: &HtmlParseOptions,
311) -> (Result<Document>, Vec<XmlError>) {
312 let (doc, recovered, fatal) = parse_html_str_inner(input, opts);
313 let result = match fatal {
314 Some(e) => Err(e),
315 None => Ok(doc),
316 };
317 (result, recovered)
318}
319
320pub fn parse_html_bytes_with_recovered(
322 input: &[u8],
323 opts: &HtmlParseOptions,
324) -> (Result<Document>, Vec<XmlError>) {
325 let (doc, recovered, fatal) = parse_html_bytes_inner(input, opts);
326 let result = match fatal {
327 Some(e) => Err(e),
328 None => Ok(doc),
329 };
330 (result, recovered)
331}
332
333fn parse_html_str_inner(
334 input: &str,
335 opts: &HtmlParseOptions,
336) -> (Document, Vec<XmlError>, Option<XmlError>) {
337 if let Err(e) = crate::license_gate::ensure_licensed() {
338 let b = sup_xml_tree::dom::DocumentBuilder::new();
339 let synth = b.new_element(b.alloc_str("html"));
340 b.set_root(synth);
341 return (b.build(), vec![e.clone()], Some(e));
342 }
343 let sink = sink::BatchSinkArena::new(opts.clone());
344 let parser = parse_document(sink, make_h5_opts(opts));
345 let sink = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| parser.one(input))) {
346 Ok(s) => s,
347 Err(_) => {
348 let err = XmlError::new(
349 ErrorDomain::Html,
350 ErrorLevel::Fatal,
351 "internal HTML parser panic — input may be adversarial",
352 );
353 let b = sup_xml_tree::dom::DocumentBuilder::new();
355 let synth = b.new_element(b.alloc_str("html"));
356 b.set_root(synth);
357 return (b.build(), vec![err.clone()], Some(err));
358 }
359 };
360 sink::finalize_arena(sink)
361}
362
363fn parse_html_bytes_inner(
364 input: &[u8],
365 opts: &HtmlParseOptions,
366) -> (Document, Vec<XmlError>, Option<XmlError>) {
367 let (utf8, _detected_encoding) = match encoding::decode_html_input(input, opts) {
371 Ok(out) => out,
372 Err(e) => {
373 let b = sup_xml_tree::dom::DocumentBuilder::new();
374 let synth = b.new_element(b.alloc_str("html"));
375 b.set_root(synth);
376 return (b.build(), vec![e.clone()], Some(e));
377 }
378 };
379 match std::str::from_utf8(&utf8) {
380 Ok(s) => parse_html_str_inner(s, opts),
381 Err(_) => {
382 let lossy = String::from_utf8_lossy(&utf8).into_owned();
383 parse_html_str_inner(&lossy, opts)
384 }
385 }
386}
387
388#[cfg(feature = "c-abi")]
399#[allow(unsafe_code)]
400unsafe fn parse_html_bytes_inner_with_dict(
401 input: &[u8],
402 opts: &HtmlParseOptions,
403 dict: *mut sup_xml_tree::dict::Dict,
404) -> (Document, Vec<XmlError>, Option<XmlError>) {
405 if let Err(e) = crate::license_gate::ensure_licensed() {
406 let b = unsafe { sup_xml_tree::dom::DocumentBuilder::new_with_dict(dict) };
407 let synth = b.new_element(b.alloc_str("html"));
408 b.set_root(synth);
409 return (b.build(), vec![e.clone()], Some(e));
410 }
411 let (utf8, _detected_encoding) = match encoding::decode_html_input(input, opts) {
412 Ok(out) => out,
413 Err(e) => {
414 let b = unsafe { sup_xml_tree::dom::DocumentBuilder::new_with_dict(dict) };
415 let synth = b.new_element(b.alloc_str("html"));
416 b.set_root(synth);
417 return (b.build(), vec![e.clone()], Some(e));
418 }
419 };
420 let owned: String = match std::str::from_utf8(&utf8) {
421 Ok(s) => s.to_string(),
422 Err(_) => String::from_utf8_lossy(&utf8).into_owned(),
423 };
424 let sink = unsafe { sink::BatchSinkArena::new_with_dict(opts.clone(), dict) };
425 let parser = parse_document(sink, make_h5_opts(opts));
426 let sink = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| parser.one(owned.as_str()))) {
427 Ok(s) => s,
428 Err(_) => {
429 let err = XmlError::new(
430 ErrorDomain::Html,
431 ErrorLevel::Fatal,
432 "internal HTML parser panic — input may be adversarial",
433 );
434 let b = unsafe { sup_xml_tree::dom::DocumentBuilder::new_with_dict(dict) };
435 let synth = b.new_element(b.alloc_str("html"));
436 b.set_root(synth);
437 return (b.build(), vec![err.clone()], Some(err));
438 }
439 };
440 sink::finalize_arena(sink)
441}
442
443#[cfg(feature = "c-abi")]
446#[allow(unsafe_code)]
447unsafe fn parse_html_bytes_inner_with_dict_arena(
448 input: &[u8],
449 opts: &HtmlParseOptions,
450 dict: *mut sup_xml_tree::dict::Dict,
451 arena: std::sync::Arc<bumpalo::Bump>,
452) -> (Document, Vec<XmlError>, Option<XmlError>) {
453 if let Err(e) = crate::license_gate::ensure_licensed() {
454 let b = unsafe {
455 sup_xml_tree::dom::DocumentBuilder::new_with_dict_and_arena(
456 dict, std::sync::Arc::clone(&arena),
457 )
458 };
459 let synth = b.new_element(b.alloc_str("html"));
460 b.set_root(synth);
461 return (b.build(), vec![e.clone()], Some(e));
462 }
463 let (utf8, _detected_encoding) = match encoding::decode_html_input(input, opts) {
464 Ok(out) => out,
465 Err(e) => {
466 let b = unsafe {
467 sup_xml_tree::dom::DocumentBuilder::new_with_dict_and_arena(
468 dict, std::sync::Arc::clone(&arena),
469 )
470 };
471 let synth = b.new_element(b.alloc_str("html"));
472 b.set_root(synth);
473 return (b.build(), vec![e.clone()], Some(e));
474 }
475 };
476 let owned: String = match std::str::from_utf8(&utf8) {
477 Ok(s) => s.to_string(),
478 Err(_) => String::from_utf8_lossy(&utf8).into_owned(),
479 };
480 let sink = unsafe {
481 sink::BatchSinkArena::new_with_dict_and_arena(opts.clone(), dict, std::sync::Arc::clone(&arena))
482 };
483 let parser = parse_document(sink, make_h5_opts(opts));
484 let sink = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| parser.one(owned.as_str()))) {
485 Ok(s) => s,
486 Err(_) => {
487 let err = XmlError::new(
488 ErrorDomain::Html,
489 ErrorLevel::Fatal,
490 "internal HTML parser panic — input may be adversarial",
491 );
492 let b = unsafe {
493 sup_xml_tree::dom::DocumentBuilder::new_with_dict_and_arena(dict, arena)
494 };
495 let synth = b.new_element(b.alloc_str("html"));
496 b.set_root(synth);
497 return (b.build(), vec![err.clone()], Some(err));
498 }
499 };
500 sink::finalize_arena(sink)
501}
502
503fn make_h5_opts(opts: &HtmlParseOptions) -> ParseOpts {
506 ParseOpts {
507 tokenizer: TokenizerOpts {
508 discard_bom: opts.discard_bom,
509 ..Default::default()
510 },
511 tree_builder: TreeBuilderOpts {
512 scripting_enabled: opts.scripting_enabled,
513 iframe_srcdoc: opts.iframe_srcdoc,
514 ..Default::default()
515 },
516 }
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
523 use sup_xml_tree::dom::NodeKind;
524
525 fn find_elem<'a>(
527 n: &'a sup_xml_tree::dom::Node<'a>,
528 name: &str,
529 ) -> Option<&'a sup_xml_tree::dom::Node<'a>> {
530 if n.kind == NodeKind::Element && n.name() == name {
531 return Some(n);
532 }
533 for c in n.children() {
534 if let Some(hit) = find_elem(c, name) {
535 return Some(hit);
536 }
537 }
538 None
539 }
540
541 fn collect_text<'a>(n: &'a sup_xml_tree::dom::Node<'a>) -> String {
543 let mut s = String::new();
544 fn go<'a>(n: &'a sup_xml_tree::dom::Node<'a>, s: &mut String) {
545 match n.kind {
546 NodeKind::Text | NodeKind::CData => s.push_str(n.content()),
547 NodeKind::Element => {
548 for c in n.children() { go(c, s); }
549 }
550 _ => {}
551 }
552 }
553 go(n, &mut s);
554 s
555 }
556
557 #[test]
558 fn simple_document_parses() {
559 let html = "<html><body><p>hello</p></body></html>";
560 let doc = parse_html_str(html).unwrap();
561 assert!(doc.is_html());
562 assert_eq!(doc.root().name(),"html");
563 }
564
565 #[test]
566 fn fragment_gets_implicit_html_body() {
567 let doc = parse_html_str("<p>hi</p>").unwrap();
568 assert_eq!(doc.root().name(),"html");
569 assert!(find_elem(doc.root(), "body").is_some());
571 assert!(find_elem(doc.root(), "p").is_some());
572 }
573
574 #[test]
575 fn tag_soup_recovers() {
576 let doc = parse_html_str("<div><p>oops</div></p>").unwrap();
578 assert_eq!(doc.root().name(),"html");
579 }
580
581 #[test]
582 fn doctype_is_captured_in_metadata() {
583 let html = "<!DOCTYPE html><html><body></body></html>";
584 let doc = parse_html_str(html).unwrap();
585 let meta = doc.html_metadata.as_ref().unwrap();
586 let dt = meta.doctype.as_ref().unwrap();
587 assert_eq!(dt.name, "html");
588 assert!(dt.public_id.is_empty());
589 }
590
591 #[test]
592 fn quirks_mode_set_for_no_doctype() {
593 let doc = parse_html_str("<html><body></body></html>").unwrap();
594 let meta = doc.html_metadata.as_ref().unwrap();
595 assert!(matches!(meta.quirks_mode, sup_xml_tree::QuirksMode::Quirks));
596 }
597
598 #[test]
599 fn no_quirks_for_html5_doctype() {
600 let doc = parse_html_str("<!DOCTYPE html><html><body></body></html>").unwrap();
601 let meta = doc.html_metadata.as_ref().unwrap();
602 assert!(matches!(meta.quirks_mode, sup_xml_tree::QuirksMode::NoQuirks));
603 }
604
605 #[test]
606 fn entity_decoded() {
607 let doc = parse_html_str("<p>a & b © c</p>").unwrap();
608 let text = collect_text(doc.root());
609 assert!(text.contains('&'), "decoded ampersand: {text}");
610 assert!(text.contains('©'), "decoded named entity: {text}");
611 }
612
613 #[test]
614 fn case_folded_tag_names() {
615 let doc = parse_html_str("<HTML><BODY><P>X</P></BODY></HTML>").unwrap();
616 assert_eq!(doc.root().name(),"html");
617 assert!(find_elem(doc.root(), "body").is_some());
618 assert!(find_elem(doc.root(), "p").is_some());
619 }
620
621 #[test]
622 fn parse_bytes_works() {
623 let doc = parse_html_bytes(b"<p>hello</p>").unwrap();
624 assert!(doc.is_html());
625 assert_eq!(doc.root().name(),"html");
626 }
627
628 #[test]
629 fn strict_mode_returns_err_on_first_error() {
630 let opts = HtmlParseOptions {
631 recovery_mode: false,
632 ..Default::default()
633 };
634 let result = parse_html_str_opts("<p>oops</div>", &opts);
636 assert!(result.is_err(), "strict mode should error on mismatched tags");
637 }
638
639 #[test]
640 fn recovered_errors_surface() {
641 let (doc, recovered) =
642 parse_html_str_with_recovered("<p>oops</div>", &HtmlParseOptions::default());
643 assert!(doc.is_ok());
644 assert!(!recovered.is_empty(), "should have recovered errors");
645 }
646
647 #[test]
648 fn depth_limit_enforced() {
649 let opts = HtmlParseOptions {
650 max_element_depth: 5,
651 ..Default::default()
652 };
653 let html = "<div>".repeat(100) + &"</div>".repeat(100);
654 let result = parse_html_str_opts(&html, &opts);
655 assert!(result.is_err(), "must reject input exceeding depth limit");
656 }
657
658 #[test]
659 fn text_byte_limit_enforced() {
660 let opts = HtmlParseOptions {
661 max_text_bytes: 100,
662 ..Default::default()
663 };
664 let html = format!("<p>{}</p>", "a".repeat(10_000));
665 let result = parse_html_str_opts(&html, &opts);
666 assert!(result.is_err(), "must reject input exceeding text-byte limit");
667 }
668
669 #[test]
670 fn parse_html_bytes_decodes_windows1252_via_meta() {
671 let mut bytes = b"<!DOCTYPE html><html><head><meta charset=\"windows-1252\"></head><body><p>".to_vec();
672 bytes.push(0x97);
673 bytes.extend_from_slice(b"</p></body></html>");
674
675 let doc = parse_html_bytes(&bytes).expect("must parse");
676 assert!(doc.is_html());
677 let p = find_elem(doc.root(), "p").expect("must find <p>");
678 let text = collect_text(p);
679 assert!(
680 text.contains('\u{2014}'),
681 "em dash (U+2014) should be decoded from byte 0x97; got {text:?}"
682 );
683 }
684
685 #[test]
686 fn parse_html_bytes_respects_encoding_override() {
687 let mut bytes = b"<html><body><p>".to_vec();
692 bytes.push(0x97);
693 bytes.extend_from_slice(b"</p></body></html>");
694
695 let opts = HtmlParseOptions {
696 encoding_override: Some("UTF-8".into()),
697 ..Default::default()
698 };
699 let doc = parse_html_bytes_opts(&bytes, &opts).expect("must parse");
700 let p = find_elem(doc.root(), "p").expect("must find <p>");
701 let text = collect_text(p);
702 assert!(
704 text.contains('\u{FFFD}') || !text.contains('\u{2014}'),
705 "with UTF-8 override, byte 0x97 should not decode as em dash; got {text:?}"
706 );
707 }
708
709 #[test]
710 fn attributes_preserved() {
711 let doc = parse_html_str(r#"<html><body><a href="/x" class="nav">x</a></body></html>"#)
712 .unwrap();
713 let a = find_elem(doc.root(), "a").expect("<a>");
714 let attrs: Vec<(&str, &str)> = a.attributes().map(|x| (x.name(), x.value())).collect();
715 assert!(attrs.iter().any(|(n, v)| *n == "href" && *v == "/x"), "got: {:?}", attrs);
716 assert!(attrs.iter().any(|(n, v)| *n == "class" && *v == "nav"), "got: {:?}", attrs);
717 }
718
719 #[test]
720 fn comments_preserved() {
721 let doc = parse_html_str("<html><body><!-- hello --><p>x</p></body></html>")
722 .unwrap();
723 let body = find_elem(doc.root(), "body").expect("body");
724 let comment = body.children().find(|c| c.kind == NodeKind::Comment);
725 let c = comment.expect("comment");
726 assert_eq!(c.content(), " hello ");
727 }
728
729 #[test]
730 fn deeply_nested_recovers() {
731 let html = "<div>".repeat(50) + "x" + &"</div>".repeat(50);
734 let doc = parse_html_str(&html).unwrap();
735 let body = find_elem(doc.root(), "body").expect("body");
736 let mut depth = 0;
737 let mut cur = body;
738 loop {
739 let next = cur.children().find(|c| c.kind == NodeKind::Element && c.name() == "div");
740 match next {
741 Some(d) => { depth += 1; cur = d; }
742 None => break,
743 }
744 }
745 assert!(depth >= 50, "expected at least 50 nested divs; got {depth}");
746 }
747
748 use super::events::HtmlAttrs;
751 use super::stream::{HtmlBytesReader, HtmlReader, HtmlSaxHandler, HtmlSaxParser};
752 use super::HtmlEvent;
753
754 fn drain_pull(reader: &mut HtmlReader<'_>) -> String {
758 let mut out = String::new();
759 loop {
760 let event = reader.next().expect("pull errored unexpectedly");
761 match event {
762 HtmlEvent::StartElement { name, attributes } => {
763 out.push_str("S:");
764 out.push_str(name);
765 let mut attrs: Vec<_> = attributes
766 .iter()
767 .map(|a| format!("{}={}", a.name, a.value))
768 .collect();
769 attrs.sort();
770 if !attrs.is_empty() {
771 out.push('[');
772 out.push_str(&attrs.join(","));
773 out.push(']');
774 }
775 out.push('\n');
776 }
777 HtmlEvent::EndElement { name } => {
778 out.push_str("E:");
779 out.push_str(name);
780 out.push('\n');
781 }
782 HtmlEvent::Text(t) => {
783 out.push_str("T:");
784 out.push_str(t);
785 out.push('\n');
786 }
787 HtmlEvent::Comment(c) => {
788 out.push_str("C:");
789 out.push_str(c);
790 out.push('\n');
791 }
792 HtmlEvent::Doctype { name, .. } => {
793 out.push_str("D:");
794 out.push_str(name);
795 out.push('\n');
796 }
797 HtmlEvent::Eof => return out,
798 }
799 }
800 }
801
802 #[test]
803 fn streaming_basic_event_sequence() {
804 let mut r = HtmlReader::new("<html><body><p>hi</p></body></html>");
805 let trace = drain_pull(&mut r);
806 assert!(trace.contains("S:html"), "trace:\n{trace}");
807 assert!(trace.contains("S:body"), "trace:\n{trace}");
808 assert!(trace.contains("S:p"), "trace:\n{trace}");
809 assert!(trace.contains("T:hi"), "trace:\n{trace}");
810 assert!(trace.contains("E:p"), "trace:\n{trace}");
811 assert!(trace.contains("E:body"), "trace:\n{trace}");
812 assert!(trace.contains("E:html"), "trace:\n{trace}");
813 }
814
815 #[test]
816 fn streaming_text_coalescing() {
817 let mut r = HtmlReader::new("<p>hello world & goodbye</p>");
820 let trace = drain_pull(&mut r);
821 let text_lines: Vec<&str> = trace.lines().filter(|l| l.starts_with("T:")).collect();
823 assert!(
824 text_lines.iter().any(|l| l.contains("hello world & goodbye")),
825 "expected coalesced text; got lines: {:?}",
826 text_lines
827 );
828 }
829
830 #[test]
831 fn streaming_doctype_event() {
832 let mut r = HtmlReader::new("<!DOCTYPE html><html><body>x</body></html>");
833 let trace = drain_pull(&mut r);
834 assert!(trace.contains("D:html"));
835 }
836
837 #[test]
838 fn streaming_attributes_visible() {
839 let mut r = HtmlReader::new(r#"<a href="/x" class="nav">x</a>"#);
840 let trace = drain_pull(&mut r);
841 assert!(
842 trace.lines().any(|l| l.starts_with("S:a[") && l.contains("href=/x") && l.contains("class=nav")),
843 "expected start tag with attrs; got:\n{}",
844 trace
845 );
846 }
847
848 #[test]
849 fn streaming_chunk_boundary_invariance() {
850 let html = "<html><head><title>t</title></head><body><p>a</p><p>b & c</p></body></html>";
851 let mut a = HtmlReader::new(html);
852 let mut b = HtmlBytesReader::new(html.as_bytes()).expect("bytes reader init");
853 let trace_a = drain_pull(&mut a);
854 let trace_b = drain_pull_bytes(&mut b);
855 assert_eq!(trace_a, trace_b, "str and bytes readers must agree");
856 }
857
858 fn drain_pull_bytes(reader: &mut HtmlBytesReader<'_>) -> String {
859 let mut out = String::new();
860 loop {
861 let event = reader.next().expect("bytes pull errored");
862 match event {
863 HtmlEvent::StartElement { name, attributes } => {
864 out.push_str("S:");
865 out.push_str(name);
866 let mut attrs: Vec<_> = attributes
867 .iter()
868 .map(|a| format!("{}={}", a.name, a.value))
869 .collect();
870 attrs.sort();
871 if !attrs.is_empty() {
872 out.push('[');
873 out.push_str(&attrs.join(","));
874 out.push(']');
875 }
876 out.push('\n');
877 }
878 HtmlEvent::EndElement { name } => {
879 out.push_str("E:");
880 out.push_str(name);
881 out.push('\n');
882 }
883 HtmlEvent::Text(t) => {
884 out.push_str("T:");
885 out.push_str(t);
886 out.push('\n');
887 }
888 HtmlEvent::Comment(c) => {
889 out.push_str("C:");
890 out.push_str(c);
891 out.push('\n');
892 }
893 HtmlEvent::Doctype { name, .. } => {
894 out.push_str("D:");
895 out.push_str(name);
896 out.push('\n');
897 }
898 HtmlEvent::Eof => return out,
899 }
900 }
901 }
902
903 #[derive(Default)]
904 struct LinkCollector {
905 hrefs: Vec<String>,
906 }
907
908 impl HtmlSaxHandler for LinkCollector {
909 fn start_element(&mut self, name: &str, attrs: HtmlAttrs<'_>) {
910 if name == "a" {
911 if let Some(href) = attrs.get("href") {
912 self.hrefs.push(href.to_string());
913 }
914 }
915 }
916 }
917
918 #[test]
919 fn sax_parser_collects_attributes() {
920 let html = r#"<a href="/x">x</a><a href="/y">y</a><a>none</a>"#;
921 let mut p = HtmlSaxParser::new(LinkCollector::default());
922 p.feed(html).unwrap();
923 let collector = p.finish().unwrap();
924 assert_eq!(collector.hrefs, vec!["/x", "/y"]);
925 }
926
927 #[test]
928 fn sax_parser_chunked_feed() {
929 let html = r#"<a href="/x">x</a>"#;
932 let mut p = HtmlSaxParser::new(LinkCollector::default());
933 for ch in html.chars() {
934 let mut tmp = [0u8; 4];
935 let s = ch.encode_utf8(&mut tmp);
936 p.feed(s).unwrap();
937 }
938 let collector = p.finish().unwrap();
939 assert_eq!(collector.hrefs, vec!["/x"]);
940 }
941
942 #[derive(Default)]
944 struct TraceHandler {
945 out: String,
946 }
947
948 impl HtmlSaxHandler for TraceHandler {
949 fn start_element(&mut self, name: &str, attrs: HtmlAttrs<'_>) {
950 self.out.push_str("S:");
951 self.out.push_str(name);
952 let mut a: Vec<_> = attrs
953 .iter()
954 .map(|a| format!("{}={}", a.name, a.value))
955 .collect();
956 a.sort();
957 if !a.is_empty() {
958 self.out.push('[');
959 self.out.push_str(&a.join(","));
960 self.out.push(']');
961 }
962 self.out.push('\n');
963 }
964 fn end_element(&mut self, name: &str) {
965 self.out.push_str("E:");
966 self.out.push_str(name);
967 self.out.push('\n');
968 }
969 fn text(&mut self, content: &str) {
970 self.out.push_str("T:");
971 self.out.push_str(content);
972 self.out.push('\n');
973 }
974 fn comment(&mut self, content: &str) {
975 self.out.push_str("C:");
976 self.out.push_str(content);
977 self.out.push('\n');
978 }
979 fn doctype(&mut self, name: &str, _public_id: &str, _system_id: &str) {
980 self.out.push_str("D:");
981 self.out.push_str(name);
982 self.out.push('\n');
983 }
984 }
985
986 #[test]
987 fn pull_push_parity() {
988 let html = "<!DOCTYPE html><html><body><p>foo & bar</p><!-- comment --><a href=\"/x\">link</a></body></html>";
989
990 let mut pull = HtmlReader::new(html);
991 let pull_trace = drain_pull(&mut pull);
992
993 let mut push = HtmlSaxParser::new(TraceHandler::default());
994 push.feed(html).unwrap();
995 let push_trace = push.finish().unwrap().out;
996
997 assert_eq!(
998 pull_trace, push_trace,
999 "pull and push surfaces must produce identical event traces"
1000 );
1001 }
1002
1003 #[test]
1004 fn streaming_recovered_errors_collected() {
1005 let mut r = HtmlReader::new("<p>oops</div>");
1006 let _ = drain_pull(&mut r);
1007 assert!(
1008 !r.recovered_errors().is_empty(),
1009 "should have recovered at least one parse error"
1010 );
1011 }
1012
1013 #[test]
1014 fn streaming_bytes_reader_decodes_windows1252() {
1015 let mut bytes = b"<!DOCTYPE html><html><head><meta charset=\"windows-1252\"></head><body><p>".to_vec();
1016 bytes.push(0x97);
1017 bytes.extend_from_slice(b"</p></body></html>");
1018
1019 let mut r = HtmlBytesReader::new(&bytes).expect("init");
1020 let mut found_em_dash = false;
1021 loop {
1022 match r.next().expect("pull") {
1023 HtmlEvent::Eof => break,
1024 HtmlEvent::Text(t) if t.contains('\u{2014}') => found_em_dash = true,
1025 _ => {}
1026 }
1027 }
1028 assert!(
1029 found_em_dash,
1030 "streaming bytes reader should decode windows-1252 em dash"
1031 );
1032 }
1033
1034 #[test]
1035 fn streaming_strict_mode_errors() {
1036 let opts = HtmlParseOptions {
1037 recovery_mode: false,
1038 ..Default::default()
1039 };
1040 let mut r = HtmlReader::with_opts("<p>oops</div>", opts);
1041 let mut hit_err = false;
1042 loop {
1043 match r.next() {
1044 Ok(HtmlEvent::Eof) => break,
1045 Ok(_) => {}
1046 Err(_) => {
1047 hit_err = true;
1048 break;
1049 }
1050 }
1051 }
1052 assert!(hit_err, "strict mode should surface a parse error");
1053 }
1054
1055 #[test]
1058 fn comments_before_and_after_html_element() {
1059 let html = "<!-- pre --><html><body>x</body></html><!-- post -->";
1065 let doc = parse_html_str(html).unwrap();
1066 assert_eq!(doc.root().name(), "html");
1070 }
1071
1072 #[test]
1073 fn processing_instruction_in_html_becomes_comment() {
1074 let doc = parse_html_str("<?xml-stylesheet href='x'?><html><body>x</body></html>")
1077 .unwrap();
1078 assert_eq!(doc.root().name(), "html");
1080 }
1081
1082 #[test]
1083 fn max_text_bytes_exceeded_aborts_parse() {
1084 let opts = HtmlParseOptions {
1085 max_text_bytes: 8,
1086 ..Default::default()
1087 };
1088 let (result, _recovered) = parse_html_str_with_recovered(
1089 "<p>this is more than eight bytes of text</p>", &opts,
1090 );
1091 assert!(result.is_err());
1093 if let Err(e) = result {
1094 assert!(e.message.contains("max_text_bytes"), "got {}", e.message);
1095 }
1096 }
1097
1098 #[test]
1099 fn max_element_depth_exceeded_aborts_parse() {
1100 let opts = HtmlParseOptions {
1101 max_element_depth: 4,
1102 ..Default::default()
1103 };
1104 let html = "<div>".repeat(10) + "x" + &"</div>".repeat(10);
1106 let (result, _) = parse_html_str_with_recovered(&html, &opts);
1107 assert!(result.is_err());
1108 if let Err(e) = result {
1109 assert!(e.message.contains("max_element_depth"), "got {}", e.message);
1110 }
1111 }
1112
1113 #[test]
1114 fn table_foster_parenting_with_text() {
1115 let html = "<table>raw text<tr><td>cell</td></tr></table>";
1120 let doc = parse_html_str(html).unwrap();
1121 assert_eq!(doc.root().name(), "html");
1123 let body = find_elem(doc.root(), "body").expect("body");
1124 let all_text = collect_text(body);
1125 assert!(all_text.contains("raw text"), "got: {all_text:?}");
1126 assert!(all_text.contains("cell"), "got: {all_text:?}");
1127 }
1128
1129 #[test]
1130 fn duplicate_html_attributes_use_add_attrs_if_missing() {
1131 let html = r#"<html lang="en"><body></body></html><html dir="ltr">"#;
1134 let doc = parse_html_str(html).unwrap();
1135 let root = doc.root();
1136 let attrs: Vec<(&str, &str)> = root.attributes()
1137 .map(|a| (a.name(), a.value())).collect();
1138 assert!(attrs.iter().any(|(n, _)| *n == "lang"), "got {attrs:?}");
1140 assert!(attrs.iter().any(|(n, _)| *n == "dir"), "got {attrs:?}");
1141 }
1142
1143 #[test]
1144 fn duplicate_html_attribute_does_not_overwrite() {
1145 let html = r#"<html lang="en"><body></body></html><html lang="fr">"#;
1147 let doc = parse_html_str(html).unwrap();
1148 let lang = doc.root().attributes()
1149 .find(|a| a.name() == "lang").map(|a| a.value().to_owned());
1150 assert_eq!(lang.as_deref(), Some("en"), "first lang should win");
1151 }
1152
1153 #[test]
1154 fn limited_quirks_doctype_sets_metadata() {
1155 let html = r#"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
1158<html><body></body></html>"#;
1159 let doc = parse_html_str(html).unwrap();
1160 let meta = doc.html_metadata.as_ref().expect("metadata");
1161 assert!(!matches!(meta.quirks_mode, sup_xml_tree::QuirksMode::Quirks),
1165 "transitional DOCTYPE should not trigger full Quirks mode");
1166 }
1167
1168 #[test]
1169 fn empty_input_synthesizes_html() {
1170 let doc = parse_html_str("").unwrap();
1173 assert_eq!(doc.root().name(), "html");
1174 }
1175
1176 #[test]
1177 fn long_run_of_text_chunks_coalesces() {
1178 let body = "x".repeat(10_000);
1181 let html = format!("<html><body><p>{body}</p></body></html>");
1182 let doc = parse_html_str(&html).unwrap();
1183 let p = find_elem(doc.root(), "p").expect("p");
1184 let text = collect_text(p);
1185 assert_eq!(text.len(), 10_000);
1186 }
1187
1188 #[test]
1189 fn parse_error_is_recorded_in_strict_mode() {
1190 let opts = HtmlParseOptions {
1191 recovery_mode: false,
1192 ..Default::default()
1193 };
1194 let (result, _) = parse_html_str_with_recovered("<p>x</_invalid>", &opts);
1197 assert!(result.is_err());
1199 }
1200
1201 #[test]
1202 fn parse_errors_in_recovery_mode_recorded_not_fatal() {
1203 let opts = HtmlParseOptions { recovery_mode: true, ..Default::default() };
1204 let (result, recovered) = parse_html_str_with_recovered(
1205 "<p>x</_invalid>", &opts,
1206 );
1207 assert!(result.is_ok());
1209 assert!(!recovered.is_empty() || result.unwrap().root().name() == "html");
1211 }
1212
1213 #[test]
1214 fn quirks_mode_with_old_html_doctype() {
1215 let doc = parse_html_str("<!DOCTYPE html><html><body></body></html>").unwrap();
1219 let meta = doc.html_metadata.as_ref().unwrap();
1220 assert!(matches!(meta.quirks_mode, sup_xml_tree::QuirksMode::NoQuirks));
1221 }
1222
1223 #[test]
1224 fn doctype_with_public_and_system_ids() {
1225 let html = r#"<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd"><html><body></body></html>"#;
1228 let doc = parse_html_str(html).unwrap();
1229 let meta = doc.html_metadata.as_ref().unwrap();
1230 let dt = meta.doctype.as_ref().unwrap();
1231 assert_eq!(dt.name, "html");
1232 assert!(dt.public_id.contains("HTML 4.01"));
1233 assert!(dt.system_id.contains("html4"));
1234 }
1235
1236 #[test]
1237 fn template_element_parses() {
1238 let html = "<html><body><template><p>tmpl</p></template></body></html>";
1240 let doc = parse_html_str(html).unwrap();
1241 assert!(find_elem(doc.root(), "template").is_some());
1242 }
1243
1244 #[test]
1245 fn table_foster_parenting_runs_of_text() {
1246 let html = "<table>first<tr><td>cell</td></tr>second</table>";
1250 let doc = parse_html_str(html).unwrap();
1251 let body = find_elem(doc.root(), "body").expect("body");
1252 let all = collect_text(body);
1253 assert!(all.contains("first") && all.contains("second"),
1254 "got: {all:?}");
1255 }
1256
1257 #[test]
1258 fn nested_table_foster_parenting() {
1259 let html = r#"<table><tr><td>a</td></tr>extra<tr><td>b</td></tr></table>"#;
1262 let doc = parse_html_str(html).unwrap();
1263 let body = find_elem(doc.root(), "body").expect("body");
1264 let text = collect_text(body);
1265 assert!(text.contains("extra"));
1266 assert!(text.contains("a") && text.contains("b"));
1267 }
1268
1269 #[test]
1270 fn svg_with_processing_instruction() {
1271 let html = r#"<html><body><svg xmlns="http://www.w3.org/2000/svg"><circle/></svg></body></html>"#;
1274 let doc = parse_html_str(html).unwrap();
1275 assert!(find_elem(doc.root(), "svg").is_some());
1276 assert!(find_elem(doc.root(), "circle").is_some());
1277 }
1278
1279 #[test]
1280 fn text_after_html_close_appended() {
1281 let html = "<html><body>x</body></html>extra-text";
1284 let doc = parse_html_str(html).unwrap();
1285 let body = find_elem(doc.root(), "body").expect("body");
1286 let text = collect_text(body);
1287 assert!(text.contains("x"));
1288 }
1289
1290 #[test]
1291 fn list_with_misnested_tags() {
1292 let html = "<ul><li>one<li>two<li>three</ul>";
1294 let doc = parse_html_str(html).unwrap();
1295 let body = find_elem(doc.root(), "body").expect("body");
1296 let li_count = body.children().filter_map(|c| {
1297 find_elem(c, "li")
1298 }).count();
1299 assert!(li_count > 0);
1300 }
1301
1302 #[test]
1303 fn text_byte_limit_hit_during_merge() {
1304 let opts = HtmlParseOptions {
1308 max_text_bytes: 5,
1309 ..Default::default()
1310 };
1311 let html = "<html><body><script>aaa</script><script>bbbbbbbbbb</script></body></html>";
1316 let (result, _) = parse_html_str_with_recovered(html, &opts);
1317 assert!(result.is_err());
1320 if let Err(e) = result {
1321 assert!(e.message.contains("max_text_bytes"), "got {}", e.message);
1322 }
1323 }
1324
1325 #[test]
1326 fn debug_format_on_owned_elem_name_via_strict_error() {
1327 let html = "<html><body><div><span><a><b><i></i></b></a></span></div></body></html>";
1333 let doc = parse_html_str(html).unwrap();
1334 assert_eq!(doc.root().name(), "html");
1336 }
1337}