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