1use crate::structs::{
14 AttributeValues, Attributes, Node,
15 NodeType::{self, *},
16};
17use std::fmt::Display;
18
19#[derive(Debug, PartialEq, Eq)]
23pub enum MalformedTagError {
24 MissingClosingBracket(u32),
26 MissingTagName(u32),
28}
29
30#[derive(Debug, PartialEq, Eq)]
34pub enum MalformedAttributeError {
35 MissingQuotationMark(u32),
37 MissingAttributeName(u32),
39 MissingAttributeValue(u32),
41}
42
43#[derive(Debug, PartialEq, Eq)]
47pub enum ParseHTMLError {
48 MalformedTag(String, MalformedTagError),
50 MalformedAttribute(String, MalformedAttributeError),
52}
53
54impl Display for ParseHTMLError {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 match self {
57 ParseHTMLError::MalformedTag(tag, error) => match error {
58 MalformedTagError::MissingClosingBracket(index) => {
59 write!(
60 f,
61 "Malformed tag: {} - Missing closing bracket at around index {}",
62 tag, index
63 )
64 }
65 MalformedTagError::MissingTagName(index) => {
66 write!(
67 f,
68 "Malformed tag: {} - Missing tag name at around index {}",
69 tag, index
70 )
71 }
72 },
73 ParseHTMLError::MalformedAttribute(attr, error) => match error {
74 MalformedAttributeError::MissingQuotationMark(index) => {
75 write!(
76 f,
77 "Malformed attribute: {} - Missing quotation mark at around index {}",
78 attr, index
79 )
80 }
81 MalformedAttributeError::MissingAttributeName(index) => {
82 write!(
83 f,
84 "Malformed attribute: {} - Missing attribute name at around index {}",
85 attr, index
86 )
87 }
88 MalformedAttributeError::MissingAttributeValue(index) => {
89 write!(
90 f,
91 "Malformed attribute: {} - Missing attribute value at around index {}",
92 attr, index
93 )
94 }
95 },
96 }
97 }
98}
99
100pub fn safe_parse_html(input: String) -> Result<Node, ParseHTMLError> {
141 let mut current_index = 0;
143 let mut nodes = Vec::new();
145 let mut stack: Vec<Node> = Vec::new();
147
148 while current_index < input.len() {
149 let rest = &input[current_index..];
150 if rest.starts_with("<!") {
151 if rest
153 .as_bytes()
154 .get(..9)
155 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"<!DOCTYPE"))
156 {
157 if let Some(closing_index) = rest.find('>') {
159 current_index += closing_index + 1;
160 continue;
161 }
162 return Err(ParseHTMLError::MalformedTag(
163 rest.to_string(),
164 MalformedTagError::MissingClosingBracket(current_index as u32),
165 ));
166 }
167 if rest.starts_with("<![CDATA[") {
168 if let Some(closing_index) = rest.find("]]>") {
169 current_index += closing_index + 3;
170 continue;
171 }
172 return Err(ParseHTMLError::MalformedTag(
173 rest.to_string(),
174 MalformedTagError::MissingClosingBracket(current_index as u32),
175 ));
176 }
177 if let Some(closing_comment_index) = rest.find("-->") {
179 let comment = &rest[..closing_comment_index + 3];
182 let mut new_node = Node {
184 tag_name: Some(Comment),
185 value: Some(
186 comment
187 .trim_start_matches("<!")
188 .trim_start_matches("--")
189 .trim_end_matches("-->")
190 .to_string(),
191 ),
192 attributes: None,
193 explicitly_self_closing: false,
194 within_special_tag: None,
195 children: Vec::new(),
196 };
197 if let Some(parent) = stack.last_mut() {
198 modify_node_with_parent(&mut new_node, parent);
199 parent.children.push(new_node);
200 } else {
201 nodes.push(new_node);
202 }
203 current_index += closing_comment_index + 3;
206 continue;
207 }
208 return Err(ParseHTMLError::MalformedTag(
210 rest.to_string(),
211 MalformedTagError::MissingClosingBracket(current_index as u32),
212 ));
213 }
214
215 if rest.starts_with('<') {
216 let closing_bracket = find_closing_bracket_index(rest);
217 if let Err(Some(value_start)) = closing_bracket {
218 let value_end = rest.len() - usize::from(rest.ends_with('>'));
219 return Err(ParseHTMLError::MalformedAttribute(
220 rest[value_start..value_end].to_string(),
221 MalformedAttributeError::MissingQuotationMark(current_index as u32),
222 ));
223 }
224 if let Ok(mut closing_index) = closing_bracket {
225 let tag_end = closing_index + 1;
226 let explicitly_self_closing =
228 if rest.as_bytes().get(closing_index - 1) == Some(&b'/') {
229 closing_index -= 1;
232 true
233 } else {
234 false
236 };
237
238 let tag_content = &rest[1..closing_index];
240
241 let node_name;
243 let mut attribute_map = None;
244 if let Some(space_index) = tag_content.find(|c: char| c.is_whitespace()) {
245 node_name = &tag_content[..space_index];
249 let attributes = &tag_content[space_index..];
251 attribute_map = parse_tag_attributes(attributes, current_index)?;
253 } else {
254 node_name = tag_content;
256 }
257
258 if node_name.is_empty() {
259 return Err(ParseHTMLError::MalformedTag(
261 tag_content.to_string(),
262 MalformedTagError::MissingTagName(current_index as u32),
263 ));
264 }
265
266 if rest.starts_with("</") {
267 match stack.pop() {
269 Some(last_node) => {
270 if let Some(parent) = stack.last_mut() {
271 parent.children.push(last_node);
272 } else {
273 nodes.push(last_node);
275 }
276 current_index += closing_index + 1;
277 continue;
278 }
279 None => {
280 let closing_bracket_of_closing_tag = rest.find('>');
282 return Err(ParseHTMLError::MalformedTag(
283 if let Some(index) = closing_bracket_of_closing_tag {
284 rest[..index + 1].to_string()
286 } else {
287 rest.to_string()
288 },
289 MalformedTagError::MissingClosingBracket(current_index as u32),
290 ));
291 }
292 }
293 }
294
295 let node_type = NodeType::from_tag_str(node_name);
297 let raw_text = is_raw_text_tag(node_name);
298 let mut new_node = Node {
300 tag_name: Some(node_type),
301 value: None,
302 attributes: attribute_map,
303 explicitly_self_closing,
304 within_special_tag: None,
305 children: Vec::new(),
306 };
307
308 if new_node.closes_immediately() {
309 if let Some(parent) = stack.last_mut() {
312 modify_node_with_parent(&mut new_node, parent);
313 parent.children.push(new_node);
314 } else {
315 nodes.push(new_node);
316 }
317 current_index += tag_end;
320 continue;
321 }
322 if raw_text {
323 let content_start = closing_index + 1;
324 let raw_rest = &rest[content_start..];
325 let Some((body_end, closing_end)) = find_raw_text_closing(raw_rest, node_name)
326 else {
327 return Err(ParseHTMLError::MalformedTag(
328 rest.to_string(),
329 MalformedTagError::MissingClosingBracket(current_index as u32),
330 ));
331 };
332 if let Some(parent) = stack.last_mut() {
333 modify_node_with_parent(&mut new_node, parent);
334 }
335 let mut text_node = Node {
336 tag_name: Some(Text),
337 value: Some(raw_rest[..body_end].to_string()),
338 attributes: None,
339 explicitly_self_closing: false,
340 within_special_tag: None,
341 children: Vec::new(),
342 };
343 modify_node_with_parent(&mut text_node, &new_node);
344 new_node.children.push(text_node);
345 if let Some(parent) = stack.last_mut() {
346 parent.children.push(new_node);
347 } else {
348 nodes.push(new_node);
349 }
350 current_index += content_start + closing_end;
351 continue;
352 }
353 if let Some(parent) = stack.last_mut() {
356 modify_node_with_parent(&mut new_node, parent);
357 }
358 stack.push(new_node);
359 current_index += tag_end;
361 continue;
362 } else {
363 return Err(ParseHTMLError::MalformedTag(
364 rest.to_string(),
365 MalformedTagError::MissingClosingBracket(current_index as u32),
366 ));
367 }
368 }
369
370 let next_opening_tag = rest.find('<').unwrap_or(rest.len());
374 let text = &rest[..next_opening_tag];
375 let preserve_whitespace = stack
376 .last()
377 .and_then(|parent| parent.tag_name.as_ref())
378 .is_some_and(|tag| matches!(tag, Code | Pre));
379 if text.trim().is_empty() && !preserve_whitespace {
380 let previous_is_phrasing = stack
381 .last()
382 .and_then(|parent| parent.children.last())
383 .or_else(|| stack.is_empty().then(|| nodes.last()).flatten())
384 .is_some_and(is_phrasing_node);
385 if previous_is_phrasing && starts_with_phrasing_node(&rest[next_opening_tag..]) {
386 let mut whitespace = Node {
387 tag_name: Some(Text),
388 value: Some(" ".to_string()),
389 attributes: None,
390 explicitly_self_closing: false,
391 within_special_tag: None,
392 children: Vec::new(),
393 };
394 if let Some(parent) = stack.last_mut() {
395 modify_node_with_parent(&mut whitespace, parent);
396 parent.children.push(whitespace);
397 } else {
398 nodes.push(whitespace);
399 }
400 }
401 current_index += next_opening_tag;
403 continue;
404 }
405
406 let mut new_node = Node {
408 tag_name: Some(Text),
409 value: Some(text.to_string()),
410 attributes: None,
411 explicitly_self_closing: false,
412 within_special_tag: None,
413 children: Vec::new(),
414 };
415
416 if let Some(parent) = stack.last_mut() {
417 modify_node_with_parent(&mut new_node, parent);
418 parent.children.push(new_node);
419 } else {
420 nodes.push(new_node);
421 }
422
423 current_index += next_opening_tag
424 }
425
426 if !stack.is_empty() {
428 for stack_node in stack.drain(..) {
429 nodes.push(stack_node);
430 }
431 }
432
433 if nodes.len() == 1 {
434 return Ok(nodes.remove(0));
435 }
436
437 Ok(Node {
438 tag_name: None,
439 value: None,
440 attributes: None,
441 explicitly_self_closing: false,
442 within_special_tag: None,
443 children: nodes,
444 })
445}
446
447fn modify_node_with_parent(node: &mut Node, parent: &Node) {
454 if parent.within_special_tag.is_some() {
455 node.within_special_tag
456 .clone_from(&parent.within_special_tag)
457 }
458 if let Some(parent_tag_name) = &parent.tag_name {
459 if parent_tag_name.is_special_tag() {
460 if let Some(within_special_tag) = &mut node.within_special_tag {
461 within_special_tag.push(parent_tag_name.clone());
462 } else {
463 node.within_special_tag = Some(vec![parent_tag_name.clone()]);
464 }
465 }
466 }
467}
468
469fn parse_tag_attributes(
470 tag_attributes: &str,
471 current_index: usize,
472) -> Result<Option<Attributes>, ParseHTMLError> {
473 let tag_attributes = tag_attributes.trim();
474
475 if tag_attributes.is_empty() {
477 return Ok(None);
478 }
479
480 let mut attribute_map = Attributes::new();
481
482 let mut current_key = String::new();
483 let mut current_value_in_quotes = String::new();
484 let mut quote = None;
485 let mut may_be_reading_non_quoted_value = false;
486 let mut whitespace_after_key = false;
487
488 for char in tag_attributes.trim().chars() {
489 if let Some(quotation_mark) = quote {
492 if char == quotation_mark {
495 add_to_attribute_map(&mut attribute_map, ¤t_key, ¤t_value_in_quotes);
498 current_key.clear();
499 current_value_in_quotes.clear();
500 quote = None;
501 continue;
502 }
503 current_value_in_quotes.push(char);
504 continue;
505 }
506
507 if whitespace_after_key && !char.is_whitespace() && char != '=' {
508 attribute_map.insert(current_key.clone(), AttributeValues::from(true));
509 current_key.clear();
510 whitespace_after_key = false;
511 }
512
513 if char.eq(&'"') || char.eq(&'\'') {
514 if current_key.is_empty() {
517 return Err(ParseHTMLError::MalformedAttribute(
519 tag_attributes.to_string(),
520 MalformedAttributeError::MissingAttributeName(current_index as u32),
521 ));
522 }
523 quote = Some(char);
525 may_be_reading_non_quoted_value = false;
528 continue;
529 }
530
531 if char.is_whitespace() {
532 if may_be_reading_non_quoted_value {
533 if current_value_in_quotes.is_empty() {
534 continue;
536 }
537 add_to_attribute_map(&mut attribute_map, ¤t_key, ¤t_value_in_quotes);
540 current_key.clear();
541 current_value_in_quotes.clear();
542 may_be_reading_non_quoted_value = false;
543 continue;
544 }
545 if !current_key.is_empty() {
547 whitespace_after_key = true;
549 continue;
550 }
551 continue;
553 }
554
555 if !may_be_reading_non_quoted_value && char.eq(&'=') {
556 if current_key.is_empty() {
560 return Err(ParseHTMLError::MalformedAttribute(
562 tag_attributes.to_string(),
563 MalformedAttributeError::MissingAttributeName(current_index as u32),
564 ));
565 }
566 whitespace_after_key = false;
568 may_be_reading_non_quoted_value = true;
569 continue;
570 }
571
572 if may_be_reading_non_quoted_value {
573 current_value_in_quotes.push(char);
575 continue;
576 }
577
578 current_key.push(char);
580 }
581
582 if may_be_reading_non_quoted_value {
583 if current_value_in_quotes.is_empty() {
584 return Err(ParseHTMLError::MalformedAttribute(
585 tag_attributes.to_string(),
586 MalformedAttributeError::MissingAttributeValue(current_index as u32),
587 ));
588 }
589 add_to_attribute_map(&mut attribute_map, ¤t_key, ¤t_value_in_quotes);
590 }
591
592 if quote.is_some() {
593 return Err(ParseHTMLError::MalformedAttribute(
594 current_value_in_quotes,
595 MalformedAttributeError::MissingQuotationMark(current_index as u32),
596 ));
597 }
598
599 if !may_be_reading_non_quoted_value && !current_key.is_empty() {
600 attribute_map.insert(current_key, AttributeValues::from(true));
601 }
602
603 match attribute_map.is_empty() {
605 true => Ok(None),
606 false => Ok(Some(attribute_map)),
607 }
608}
609
610fn add_to_attribute_map(
611 attribute_map: &mut Attributes,
612 current_key: &str,
613 current_value_in_quotes: &str,
614) {
615 if current_key.is_empty() {
616 return;
617 }
618 attribute_map.insert(
619 current_key.to_string(),
620 AttributeValues::from(current_value_in_quotes),
621 );
622}
623
624fn find_closing_bracket_index(rest: &str) -> Result<usize, Option<usize>> {
625 let mut quote = None;
626 for (idx, char) in rest.char_indices() {
627 match quote {
628 Some((quotation_mark, _)) if char == quotation_mark => quote = None,
629 Some(_) => {}
630 None if char.eq(&'"') || char.eq(&'\'') => quote = Some((char, idx + 1)),
631 None if char.eq(&'>') => return Ok(idx),
632 None => {}
633 }
634 }
635 Err(quote.map(|(_, value_start)| value_start))
636}
637
638fn is_raw_text_tag(tag_name: &str) -> bool {
639 ["script", "style", "textarea", "title"]
640 .iter()
641 .any(|raw_tag| tag_name.eq_ignore_ascii_case(raw_tag))
642}
643
644fn is_phrasing_node(node: &Node) -> bool {
645 node.tag_name.as_ref().is_some_and(NodeType::is_phrasing)
646}
647
648fn starts_with_phrasing_node(input: &str) -> bool {
649 if input.starts_with("<!--") {
650 return true;
651 }
652 let Some(tag) = input.strip_prefix('<') else {
653 return false;
654 };
655 if tag.starts_with('/') {
656 return false;
657 }
658 let name = tag
659 .split(|character: char| character.is_whitespace() || matches!(character, '/' | '>'))
660 .next()
661 .unwrap_or_default();
662 NodeType::from_tag_str(name).is_phrasing()
663}
664
665fn find_raw_text_closing(rest: &str, tag_name: &str) -> Option<(usize, usize)> {
666 let bytes = rest.as_bytes();
667 let tag_name = tag_name.as_bytes();
668 let mut search_from = 0;
669 while let Some(relative_start) = rest[search_from..].find("</") {
670 let start = search_from + relative_start;
671 let name_start = start + 2;
672 let name_end = name_start + tag_name.len();
673 if bytes
674 .get(name_start..name_end)
675 .is_some_and(|candidate| candidate.eq_ignore_ascii_case(tag_name))
676 {
677 let mut closing_bracket = name_end;
678 while bytes
679 .get(closing_bracket)
680 .is_some_and(|byte| byte.is_ascii_whitespace())
681 {
682 closing_bracket += 1;
683 }
684 if bytes.get(closing_bracket) == Some(&b'>') {
685 return Some((start, closing_bracket + 1));
686 }
687 }
688 search_from = name_start;
689 }
690 None
691}
692
693#[test]
695fn issue_25() {
696 let input = "property=\"og:type\" content= \"website\"".to_string();
697 let expected = Attributes::from(vec![
698 ("property".to_string(), AttributeValues::from("og:type")),
699 ("content".to_string(), AttributeValues::from("website")),
700 ]);
701 let parsed = parse_tag_attributes(&input, 0).unwrap().unwrap();
702 assert_eq!(parsed, expected);
703}
704
705#[test]
707fn issue_31() {
708 let input = r#"<img src="https://exmaple.com/img.png" alt="Rust<br/>Logo"/>"#.to_string();
709 let expected = Node {
710 tag_name: Some(Unknown("img".to_string())),
711 value: None,
712 attributes: Some(Attributes {
713 id: None,
714 class: None,
715 attributes: std::collections::HashMap::from([
716 (
717 "src".to_string(),
718 AttributeValues::from("https://exmaple.com/img.png"),
719 ),
720 ("alt".to_string(), AttributeValues::from("Rust<br/>Logo")),
721 ]),
722 }),
723 explicitly_self_closing: true,
724 children: Vec::new(),
725 within_special_tag: None,
726 };
727 let parsed = safe_parse_html(input).unwrap();
728 assert_eq!(parsed, expected)
729}
730
731#[test]
733fn issue_36() {
734 let input = "<img src=\"https://hoerspiele.dra.de/fileadmin/www.hoerspiele.dra.de/images/vollinfo/4970918_B01.jpg\" />".to_string();
735 let expected = Node {
736 tag_name: Some(Unknown("img".to_string())),
737 value: None,
738 attributes: Some(Attributes {
739 id: None,
740 class: None,
741 attributes: std::collections::HashMap::from([(
742 "src".to_string(),
743 AttributeValues::from("https://hoerspiele.dra.de/fileadmin/www.hoerspiele.dra.de/images/vollinfo/4970918_B01.jpg"),
744 )]),
745 }),
746 explicitly_self_closing: true,
747 children: Vec::new(),
748 within_special_tag: None,
749 };
750 let parsed = safe_parse_html(input).unwrap();
751 assert_eq!(parsed, expected);
752
753 let input = r#"<!DOCTYPE html><meta http-equiv="content-type" content="text/html; charset=utf-8"><div class="column"><div class="gallery-wrap single">
754 <div class="gallery-container">
755 <figure class="image">
756 <figure class="image">
757 <img title="Illustration »Der dunkle Kongress« © ARD / Jürgen Frey"
758 alt="Illustration »Der dunkle Kongress« © ARD / Jürgen Frey"
759 src="https://hoerspiele.dra.de/fileadmin/www.hoerspiele.dra.de/images/vollinfo/4970918_B01.jpg">
760 <figcaption class="image-caption">Illustration »Der dunkle Kongress«
761© ARD / Jürgen Frey</figcaption>
762</figure></div></div></div>"#.to_string();
763 safe_parse_html(input).unwrap();
764}