1use crate::types::DOMNodeInfo;
2
3fn decode_html_entities(s: &str) -> String {
5 let mut result = String::with_capacity(s.len());
6 let bytes = s.as_bytes();
7 let mut i = 0;
8
9 while i < bytes.len() {
10 if bytes[i] == b'&' {
11 if i + 2 < bytes.len() && bytes[i + 1] == b'#' {
13 let start = i + 2;
14 let mut end = start;
15 while end < bytes.len() && end < start + 10 {
16 if bytes[end] == b';' {
17 break;
18 }
19 end += 1;
20 }
21
22 if end < bytes.len() && bytes[end] == b';' {
23 let entity = &s[start..end];
24
25 let value = if entity.starts_with('x') || entity.starts_with('X') {
27 let hex = &entity[1..];
29 u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
30 } else {
31 entity.parse::<u32>().ok().and_then(char::from_u32)
33 };
34
35 if let Some(c) = value {
36 result.push(c);
37 i = end + 1;
38 continue;
39 }
40 }
41 } else {
42 let named_entities = [
44 ("nbsp", '\u{00A0}'),
45 ("amp", '&'),
46 ("lt", '<'),
47 ("gt", '>'),
48 ("quot", '"'),
49 ("apos", '\''),
50 ("copy", '©'),
51 ("reg", '®'),
52 ("trade", '™'),
53 ("ndash", '–'),
54 ("mdash", '—'),
55 ("hellip", '…'),
56 ("laquo", '«'),
57 ("raquo", '»'),
58 ("euro", '€'),
59 ("pound", '£'),
60 ("yen", '¥'),
61 ];
62
63 let mut found = false;
64 for (name, char) in named_entities {
65 let name_len = name.len();
66 if i + 1 + name_len < bytes.len() && bytes[i + 1..].starts_with(name.as_bytes())
68 && bytes[i + 1 + name_len] == b';'
69 {
70 result.push(char);
71 i += 1 + name_len + 1;
72 found = true;
73 break;
74 }
75 }
76
77 if found {
78 continue;
79 }
80 }
81 }
82
83 result.push(bytes[i] as char);
84 i += 1;
85 }
86
87 result
88}
89
90#[derive(Debug, Clone)]
91pub struct DomNode {
92 pub tag: String,
93 pub id: Option<String>,
94 pub classes: Vec<String>,
95 pub start: usize,
96 pub end: usize,
97 pub parent: Option<usize>,
98 pub children: Vec<usize>,
99}
100
101#[derive(Debug, Clone)]
102pub struct DomTree {
103 nodes: Vec<DomNode>,
104 roots: Vec<usize>,
105}
106
107#[derive(Debug, Clone)]
108pub struct StyleBlock {
109 pub content: String,
110 pub content_start: usize,
111}
112
113#[derive(Debug, Clone)]
114pub struct InlineStyle {
115 pub value: String,
116 pub raw_value: String,
117 pub value_start: usize,
118 pub attribute_start: usize,
119}
120
121#[derive(Debug, Clone)]
122pub struct HtmlParseResult {
123 pub dom_tree: DomTree,
124 pub style_blocks: Vec<StyleBlock>,
125 pub inline_styles: Vec<InlineStyle>,
126}
127
128impl DomTree {
129 pub fn parse(html: &str) -> HtmlParseResult {
130 let bytes = html.as_bytes();
131 let len = bytes.len();
132 let mut i = 0;
133 let mut comment_depth = 0usize;
134 let mut nodes: Vec<DomNode> = Vec::new();
135 let mut roots: Vec<usize> = Vec::new();
136 let mut stack: Vec<usize> = Vec::new();
137 let mut style_blocks = Vec::new();
138 let mut inline_styles = Vec::new();
139
140 while i < len {
141 if comment_depth > 0 {
142 if starts_with(bytes, i, b"<!--") {
143 comment_depth += 1;
144 i += 4;
145 continue;
146 }
147 if starts_with(bytes, i, b"-->") {
148 comment_depth -= 1;
149 i += 3;
150 continue;
151 }
152 i += 1;
153 continue;
154 }
155
156 if starts_with(bytes, i, b"<!--") {
157 comment_depth = 1;
158 i += 4;
159 continue;
160 }
161
162 if bytes[i] != b'<' {
163 i += 1;
164 continue;
165 }
166
167 if i + 1 >= len {
168 break;
169 }
170
171 if bytes[i + 1] == b'/' {
172 if let Some((tag_name, end_pos)) = parse_end_tag(html, i) {
174 let mut match_index = None;
175 for (pos, node_idx) in stack.iter().enumerate().rev() {
176 if nodes[*node_idx].tag == tag_name {
177 match_index = Some(pos);
178 break;
179 }
180 }
181 if let Some(pos) = match_index {
182 let node_idx = stack[pos];
183 nodes[node_idx].end = end_pos;
184 stack.truncate(pos);
185 }
186 i = end_pos;
187 continue;
188 }
189 }
190
191 if bytes[i + 1] == b'!' {
192 if let Some(end_pos) = find_char(bytes, i + 2, b'>') {
194 i = end_pos + 1;
195 continue;
196 }
197 }
198
199 let tag_start = i;
200 i += 1;
201 while i < len && bytes[i].is_ascii_whitespace() {
202 i += 1;
203 }
204 let name_start = i;
205 while i < len && is_tag_name_char(bytes[i]) {
206 i += 1;
207 }
208 if name_start == i {
209 i += 1;
210 continue;
211 }
212 let tag_name = html[name_start..i].to_lowercase();
213
214 let mut id: Option<String> = None;
215 let mut classes: Vec<String> = Vec::new();
216 let mut self_closing = false;
217
218 while i < len {
219 while i < len && bytes[i].is_ascii_whitespace() {
220 i += 1;
221 }
222 if i >= len {
223 break;
224 }
225 if bytes[i] == b'/' && i + 1 < len && bytes[i + 1] == b'>' {
226 self_closing = true;
227 i += 2;
228 break;
229 }
230 if bytes[i] == b'>' {
231 i += 1;
232 break;
233 }
234
235 let attr_name_start = i;
236 while i < len && is_attr_name_char(bytes[i]) {
237 i += 1;
238 }
239 if attr_name_start == i {
240 i += 1;
241 continue;
242 }
243 let attr_name = html[attr_name_start..i].to_lowercase();
244 while i < len && bytes[i].is_ascii_whitespace() {
245 i += 1;
246 }
247
248 let mut value: Option<String> = None;
249 let mut raw_value: Option<String> = None;
250 let mut value_start = None;
251 if i < len && bytes[i] == b'=' {
252 i += 1;
253 while i < len && bytes[i].is_ascii_whitespace() {
254 i += 1;
255 }
256 if i < len && (bytes[i] == b'"' || bytes[i] == b'\'') {
257 let quote = bytes[i];
258 i += 1;
259 let start = i;
260 while i < len && bytes[i] != quote {
261 i += 1;
262 }
263 let end = i.min(len);
264 let raw = html[start..end].to_string();
265 value = Some(decode_html_entities(&raw));
266 raw_value = Some(raw);
267 value_start = Some(start);
268 if i < len {
269 i += 1;
270 }
271 } else {
272 let start = i;
273 while i < len && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>' {
274 i += 1;
275 }
276 let end = i;
277 let raw = html[start..end].to_string();
278 value = Some(decode_html_entities(&raw));
279 raw_value = Some(raw);
280 value_start = Some(start);
281 }
282 }
283
284 match attr_name.as_str() {
285 "id" => {
286 if let Some(v) = &value {
287 if !v.is_empty() {
288 id = Some(v.to_string());
289 }
290 }
291 }
292 "class" | "classname" => {
293 if let Some(v) = &value {
294 classes.extend(v.split_whitespace().map(|c| c.to_string()));
295 }
296 }
297 "style" => {
298 if let (Some(v), Some(raw), Some(v_start)) =
299 (value.clone(), raw_value, value_start)
300 {
301 inline_styles.push(InlineStyle {
302 value: v,
303 raw_value: raw,
304 value_start: v_start,
305 attribute_start: attr_name_start,
306 });
307 }
308 }
309 _ => {}
310 }
311 }
312
313 let tag_end = i;
314 let node_idx = nodes.len();
315 let parent = stack.last().copied();
316 nodes.push(DomNode {
317 tag: tag_name.clone(),
318 id,
319 classes,
320 start: tag_start,
321 end: tag_end,
322 parent,
323 children: Vec::new(),
324 });
325
326 if let Some(parent_idx) = parent {
327 nodes[parent_idx].children.push(node_idx);
328 } else {
329 roots.push(node_idx);
330 }
331
332 if tag_name == "style" {
333 if let Some((content_start, content_end, close_end)) =
334 find_block_content(html, tag_end, "style")
335 {
336 let content = html[content_start..content_end].to_string();
337 style_blocks.push(StyleBlock {
338 content,
339 content_start,
340 });
341 nodes[node_idx].end = close_end;
342 i = close_end;
343 continue;
344 }
345 }
346
347 if tag_name == "script" {
348 if let Some((_, _, close_end)) = find_block_content(html, tag_end, "script") {
349 nodes[node_idx].end = close_end;
350 i = close_end;
351 continue;
352 }
353 }
354
355 if self_closing || is_void_tag(&tag_name) {
356 nodes[node_idx].end = tag_end;
357 } else {
358 stack.push(node_idx);
359 }
360 }
361
362 let final_end = html.len();
363 for idx in stack {
364 if nodes[idx].end < final_end {
365 nodes[idx].end = final_end;
366 }
367 }
368
369 HtmlParseResult {
370 dom_tree: DomTree { nodes, roots },
371 style_blocks,
372 inline_styles,
373 }
374 }
375
376 pub fn find_node_at_position(&self, position: usize) -> Option<DOMNodeInfo> {
377 for &root_idx in &self.roots {
378 if let Some(info) = self.find_node_recursive(root_idx, position) {
379 return Some(info);
380 }
381 }
382 None
383 }
384
385 fn find_node_recursive(&self, idx: usize, position: usize) -> Option<DOMNodeInfo> {
386 let node = &self.nodes[idx];
387 if position < node.start || position > node.end {
388 return None;
389 }
390 for &child in &node.children {
391 if let Some(found) = self.find_node_recursive(child, position) {
392 return Some(found);
393 }
394 }
395 Some(self.to_info(idx))
396 }
397
398 pub fn matches_selector(&self, node_index: usize, selector: &str) -> bool {
399 let selector = selector.trim();
400 if selector.is_empty() {
401 return false;
402 }
403 if selector == ":root" {
404 return true;
405 }
406 let selectors: Vec<&str> = selector.split(',').map(|s| s.trim()).collect();
407 for sel in selectors {
408 if sel.is_empty() {
409 continue;
410 }
411 let parts = parse_selector_parts(sel);
412 if matches_selector_parts(self, node_index, &parts) {
413 return true;
414 }
415 }
416 false
417 }
418
419 fn to_info(&self, idx: usize) -> DOMNodeInfo {
420 let node = &self.nodes[idx];
421 DOMNodeInfo {
422 tag: node.tag.clone(),
423 id: node.id.clone(),
424 classes: node.classes.clone(),
425 position: node.start,
426 node_index: Some(idx),
427 }
428 }
429}
430
431#[derive(Debug, Clone, Copy)]
432enum Combinator {
433 Descendant,
434 Child,
435}
436
437#[derive(Debug, Clone)]
438struct SimpleSelector {
439 tag: Option<String>,
440 id: Option<String>,
441 classes: Vec<String>,
442}
443
444#[derive(Debug, Clone)]
445struct SelectorPart {
446 combinator: Combinator,
447 selector: SimpleSelector,
448}
449
450fn parse_selector_parts(selector: &str) -> Vec<SelectorPart> {
451 let mut tokens: Vec<String> = Vec::new();
452 let mut current = String::new();
453 let mut in_attr = 0usize;
454 let mut in_paren = 0usize;
455 let mut last_was_space = false;
456
457 for ch in selector.chars() {
458 match ch {
459 '[' => {
460 in_attr += 1;
461 current.push(ch);
462 last_was_space = false;
463 }
464 ']' => {
465 in_attr = in_attr.saturating_sub(1);
466 current.push(ch);
467 last_was_space = false;
468 }
469 '(' => {
470 in_paren += 1;
471 current.push(ch);
472 last_was_space = false;
473 }
474 ')' => {
475 in_paren = in_paren.saturating_sub(1);
476 current.push(ch);
477 last_was_space = false;
478 }
479 '>' if in_attr == 0 && in_paren == 0 => {
480 if !current.trim().is_empty() {
481 tokens.push(current.trim().to_string());
482 }
483 tokens.push(">".to_string());
484 current.clear();
485 last_was_space = false;
486 }
487 ch if ch.is_whitespace() && in_attr == 0 && in_paren == 0 => {
488 if !current.trim().is_empty() {
489 tokens.push(current.trim().to_string());
490 current.clear();
491 }
492 if !last_was_space {
493 tokens.push(" ".to_string());
494 last_was_space = true;
495 }
496 }
497 _ => {
498 current.push(ch);
499 last_was_space = false;
500 }
501 }
502 }
503
504 if !current.trim().is_empty() {
505 tokens.push(current.trim().to_string());
506 }
507
508 let mut parts = Vec::new();
509 let mut next_combinator = Combinator::Descendant;
510
511 for token in tokens {
512 if token == ">" {
513 next_combinator = Combinator::Child;
514 continue;
515 }
516 if token == " " {
517 next_combinator = Combinator::Descendant;
518 continue;
519 }
520 let selector = parse_simple_selector(&token);
521 parts.push(SelectorPart {
522 combinator: next_combinator,
523 selector,
524 });
525 next_combinator = Combinator::Descendant;
526 }
527
528 parts
529}
530
531fn parse_simple_selector(token: &str) -> SimpleSelector {
532 let mut tag: Option<String> = None;
533 let mut id: Option<String> = None;
534 let mut classes: Vec<String> = Vec::new();
535
536 let mut slice = token;
537 if let Some(idx) = slice.find([':', '[']) {
538 slice = &slice[..idx];
539 }
540
541 let mut current = String::new();
542 let mut mode = 't'; for ch in slice.chars() {
545 match ch {
546 '#' => {
547 if mode == 't' && !current.is_empty() && tag.is_none() {
548 tag = Some(current.clone());
549 } else if mode == 'c' && !current.is_empty() {
550 classes.push(current.clone());
551 }
552 current.clear();
553 mode = 'i';
554 }
555 '.' => {
556 if mode == 't' && !current.is_empty() && tag.is_none() {
557 tag = Some(current.clone());
558 } else if mode == 'i' && !current.is_empty() {
559 id = Some(current.clone());
560 } else if mode == 'c' && !current.is_empty() {
561 classes.push(current.clone());
562 }
563 current.clear();
564 mode = 'c';
565 }
566 _ => current.push(ch),
567 }
568 }
569
570 if !current.is_empty() {
571 match mode {
572 't' if current != "*" => {
573 tag = Some(current);
574 }
575 'i' => id = Some(current),
576 'c' => classes.push(current),
577 _ => {}
578 }
579 }
580
581 SimpleSelector { tag, id, classes }
582}
583
584fn matches_selector_parts(tree: &DomTree, node_index: usize, parts: &[SelectorPart]) -> bool {
585 if parts.is_empty() {
586 return false;
587 }
588
589 let mut current_index = Some(node_index);
590 for (idx, part) in parts.iter().enumerate().rev() {
591 let node_idx = match current_index {
592 Some(i) => i,
593 None => return false,
594 };
595 if !matches_simple_selector(&tree.nodes[node_idx], &part.selector) {
596 return false;
597 }
598
599 if idx == 0 {
600 return true;
601 }
602
603 let next_part = &parts[idx - 1];
604 match part.combinator {
605 Combinator::Child => {
606 current_index = tree.nodes[node_idx].parent;
607 if let Some(parent_idx) = current_index {
608 if !matches_simple_selector(&tree.nodes[parent_idx], &next_part.selector) {
609 return false;
610 }
611 } else {
612 return false;
613 }
614 }
615 Combinator::Descendant => {
616 let mut parent = tree.nodes[node_idx].parent;
617 let mut matched = false;
618 while let Some(parent_idx) = parent {
619 if matches_simple_selector(&tree.nodes[parent_idx], &next_part.selector) {
620 matched = true;
621 current_index = Some(parent_idx);
622 break;
623 }
624 parent = tree.nodes[parent_idx].parent;
625 }
626 if !matched {
627 return false;
628 }
629 }
630 }
631 }
632
633 true
634}
635
636fn matches_simple_selector(node: &DomNode, selector: &SimpleSelector) -> bool {
637 if let Some(tag) = &selector.tag {
638 if !node.tag.eq_ignore_ascii_case(tag) {
639 return false;
640 }
641 }
642 if let Some(id) = &selector.id {
643 if node.id.as_deref() != Some(id) {
644 return false;
645 }
646 }
647 for class in &selector.classes {
648 if !node.classes.iter().any(|c| c == class) {
649 return false;
650 }
651 }
652 true
653}
654
655fn is_tag_name_char(b: u8) -> bool {
656 b.is_ascii_alphanumeric() || b == b'-' || b == b':'
657}
658
659fn is_attr_name_char(b: u8) -> bool {
660 b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b':'
661}
662
663fn is_void_tag(tag: &str) -> bool {
664 matches!(
665 tag,
666 "area"
667 | "base"
668 | "br"
669 | "col"
670 | "embed"
671 | "hr"
672 | "img"
673 | "input"
674 | "link"
675 | "meta"
676 | "param"
677 | "source"
678 | "track"
679 | "wbr"
680 )
681}
682
683fn starts_with(bytes: &[u8], idx: usize, pattern: &[u8]) -> bool {
684 bytes.len() >= idx + pattern.len() && &bytes[idx..idx + pattern.len()] == pattern
685}
686
687fn find_char(bytes: &[u8], start: usize, target: u8) -> Option<usize> {
688 (start..bytes.len()).find(|&i| bytes[i] == target)
689}
690
691fn parse_end_tag(html: &str, start: usize) -> Option<(String, usize)> {
692 let bytes = html.as_bytes();
693 let len = bytes.len();
694 if start + 2 >= len {
695 return None;
696 }
697 let mut i = start + 2;
698 while i < len && bytes[i].is_ascii_whitespace() {
699 i += 1;
700 }
701 let name_start = i;
702 while i < len && is_tag_name_char(bytes[i]) {
703 i += 1;
704 }
705 if name_start == i {
706 return None;
707 }
708 let tag_name = html[name_start..i].to_lowercase();
709 if let Some(end_pos) = find_char(bytes, i, b'>') {
710 return Some((tag_name, end_pos + 1));
711 }
712 None
713}
714
715fn find_block_content(html: &str, start: usize, tag: &str) -> Option<(usize, usize, usize)> {
716 let lower = html.to_lowercase();
717 let bytes = lower.as_bytes();
718 let len = bytes.len();
719 let target = format!("</{}", tag.to_lowercase());
720 let target_bytes = target.as_bytes();
721 let mut i = start;
722 while i + target_bytes.len() < len {
723 if bytes[i] == b'<' && starts_with(bytes, i, target_bytes) {
724 if let Some(end_pos) = find_char(bytes, i + target_bytes.len(), b'>') {
725 return Some((start, i, end_pos + 1));
726 }
727 }
728 i += 1;
729 }
730 None
731}
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736
737 #[test]
738 fn test_dom_tree_basic() {
739 let html = r#"
740 <html>
741 <body>
742 <div class="container">
743 <p id="text">Hello</p>
744 </div>
745 </body>
746 </html>
747 "#;
748
749 let result = DomTree::parse(html);
750 let tree = result.dom_tree;
751 assert!(!tree.roots.is_empty());
752
753 let node = tree.find_node_at_position(50);
755 assert!(node.is_some());
756 }
757
758 #[test]
759 fn test_dom_tree_nested_structure() {
760 let html =
761 r#"<div class="outer"><div class="inner"><span id="item">Text</span></div></div>"#;
762
763 let result = DomTree::parse(html);
764 let tree = result.dom_tree;
765
766 assert!(!tree.roots.is_empty());
768
769 assert_eq!(tree.nodes.len(), 3); }
772
773 #[test]
774 fn test_dom_tree_multiple_classes() {
775 let html = r#"<div class="class1 class2 class3">Content</div>"#;
776
777 let result = DomTree::parse(html);
778 assert!(!result.dom_tree.roots.is_empty());
779 }
781
782 #[test]
783 fn test_dom_tree_self_closing_tags() {
784 let html = r#"<div><img src="test.jpg" /><br /><input type="text" /></div>"#;
785
786 let result = DomTree::parse(html);
787 let tree = result.dom_tree;
788
789 assert!(!tree.roots.is_empty());
790 }
792
793 #[test]
794 fn test_dom_tree_find_node_at_position() {
795 let html = r#"<div class="outer"><p id="para">Text</p></div>"#;
796
797 let result = DomTree::parse(html);
798 let tree = result.dom_tree;
799
800 let node = tree.find_node_at_position(5);
802 assert!(node.is_some());
803 }
804
805 #[test]
806 fn test_dom_tree_empty_html() {
807 let html = "";
808 let result = DomTree::parse(html);
809 let tree = result.dom_tree;
810 assert!(tree.roots.is_empty());
811 }
812
813 #[test]
814 fn test_dom_tree_malformed_html() {
815 let html = r#"<div><p>Text"#;
817 let result = DomTree::parse(html);
818 assert!(!result.dom_tree.roots.is_empty());
820 }
821
822 #[test]
823 fn test_parse_inline_styles() {
824 let html = r#"<div style="color: red; background: blue;"></div>"#;
825
826 let parsed = DomTree::parse(html);
827 assert_eq!(parsed.inline_styles.len(), 1);
828
829 let inline = &parsed.inline_styles[0];
830 assert!(inline.value.contains("color: red"));
831 assert!(inline.value.contains("background: blue"));
832 }
833
834 #[test]
835 fn test_parse_style_blocks() {
836 let html = r#"
837 <html>
838 <head>
839 <style>
840 .class { color: red; }
841 </style>
842 </head>
843 <body>
844 <style>
845 #id { background: blue; }
846 </style>
847 </body>
848 </html>
849 "#;
850
851 let parsed = DomTree::parse(html);
852 assert_eq!(parsed.style_blocks.len(), 2);
853
854 assert!(parsed.style_blocks[0].content.contains("color: red"));
855 assert!(parsed.style_blocks[1].content.contains("background: blue"));
856 }
857
858 #[test]
859 fn test_parse_nested_style_tags() {
860 let html = r#"<style>outer { color: red; }<style>inner</style></style>"#;
861
862 let parsed = DomTree::parse(html);
863 assert!(!parsed.style_blocks.is_empty());
865 }
866
867 #[test]
868 fn test_dom_tree_comment_handling() {
869 let html = r#"<div><!-- This is a comment --><p>Text</p></div>"#;
870
871 let result = DomTree::parse(html);
872 let tree = result.dom_tree;
873
874 assert!(!tree.roots.is_empty());
876 }
877
878 #[test]
879 fn test_attributes_with_quotes() {
880 let html = r#"<div class="test" id='myid' data-value=unquoted></div>"#;
881
882 let result = DomTree::parse(html);
883 let tree = result.dom_tree;
884
885 assert!(!tree.roots.is_empty());
887 }
888
889 #[test]
890 fn test_dom_tree_classname_alias() {
891 let html = r#"<div className="react-class">Content</div>"#;
892 let result = DomTree::parse(html);
893 let tree = result.dom_tree;
894 assert!(!tree.roots.is_empty());
895 let node = &tree.nodes[tree.roots[0]];
896 assert!(node.classes.contains(&"react-class".to_string()));
897 }
898
899 #[test]
907 fn test_dom_tree_html_entity_decoding() {
908 let test_cases = vec![
910 (" ", " "),
912 ("&", "&"),
913 ("<", "<"),
914 (">", ">"),
915 (""", "\""),
916 ("'", "'"),
917 ("'", "'"),
918 (" ", " "),
919 ("©", "©"),
920 ("®", "®"),
921 ];
922
923 for (entity, expected) in test_cases {
924 let html = format!(
925 r#"<div style="--test: '{}'; color: blue;">Content</div>"#,
926 entity
927 );
928
929 let result = DomTree::parse(&html);
930
931 let tree = result.dom_tree;
932 assert!(
933 !tree.roots.is_empty(),
934 "Tree should have nodes for: {}",
935 entity
936 );
937
938 let inline_styles = result.inline_styles;
941
942 assert!(
943 !inline_styles.is_empty(),
944 "Should have inline styles for: {}",
945 entity
946 );
947
948 let style = &inline_styles[0].value;
950
951 assert!(
955 style.contains(expected),
956 "Style should contain decoded entity '{}' but got: {} (for entity: {})",
957 expected,
958 style,
959 entity
960 );
961 }
962 }
963}