Skip to main content

css_variable_lsp/
dom_tree.rs

1use crate::types::DOMNodeInfo;
2
3/// Decode HTML entities in a string
4fn 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            // Check for numeric entities
12            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                    // Check for hex (x) or decimal
26                    let value = if entity.starts_with('x') || entity.starts_with('X') {
27                        // Hexadecimal
28                        let hex = &entity[1..];
29                        u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
30                    } else {
31                        // Decimal
32                        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                // Check for named entities
43                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() // & + name + ;
67                        && 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                // end tag
173                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                // doctype or other markup, skip to >
193                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'; // t=tag, c=class, i=id
543
544    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        // Should be able to find nodes
754        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        // Tree should have nodes
767        assert!(!tree.roots.is_empty());
768
769        // We can verify the parse result has the expected structure
770        assert_eq!(tree.nodes.len(), 3); // div, div, span
771    }
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        // Classes are parsed correctly
780    }
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        // Self-closing tags are handled
791    }
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        // Position in div tag
801        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        // Missing closing tag
816        let html = r#"<div><p>Text"#;
817        let result = DomTree::parse(html);
818        // Should still parse what it can
819        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        // Should handle nested style tags
864        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        // Comments should be handled properly
875        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        // Should parse attributes correctly
886        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    /// Bug demonstration: HTML entities are not decoded in attribute values
900    ///
901    /// ISSUE: The parser doesn't handle HTML entities (e.g., &nbsp;, &#x27;)
902    /// in attribute values. This could cause issues with inline styles containing entities.
903    ///
904    /// EXPECTED TO FAIL: This test proves entity decoding is not implemented.
905    /// After fix: Entities should be properly decoded.
906    #[test]
907    fn test_dom_tree_html_entity_decoding() {
908        // Test various HTML entities in inline styles
909        let test_cases = vec![
910            // (entity, expected decoded value)
911            ("&nbsp;", " "),
912            ("&amp;", "&"),
913            ("&lt;", "<"),
914            ("&gt;", ">"),
915            ("&quot;", "\""),
916            ("&#39;", "'"),
917            ("&#x27;", "'"),
918            ("&#x20;", " "),
919            ("&copy;", "©"),
920            ("&reg;", "®"),
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            // Inline styles are stored in the HtmlParseResult, not in DomNode
939            // We need to check the inline_styles collection
940            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            // The inline style should contain the attribute
949            let style = &inline_styles[0].value;
950
951            // BUG: Currently this assertion will FAIL because entities are not decoded
952            // The style value will contain the raw entity string, not the decoded value
953            // After fix: style should contain the decoded entity
954            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}