Skip to main content

css_variable_lsp/parsers/
html.rs

1use ls_types::Uri;
2
3use super::css::{parse_css_snippet, CssParseContext};
4use crate::dom_tree::DomTree;
5use crate::manager::CssVariableManager;
6use crate::types::DOMNodeInfo;
7
8/// Parse an HTML document and extract CSS from style blocks and inline styles
9pub async fn parse_html_document(
10    text: &str,
11    uri: &Uri,
12    manager: &CssVariableManager,
13) -> Result<(), String> {
14    let parsed = DomTree::parse(text);
15
16    manager
17        .set_dom_tree(uri.clone(), parsed.dom_tree.clone())
18        .await;
19
20    for block in parsed.style_blocks {
21        let context = CssParseContext {
22            css_text: &block.content,
23            full_text: text,
24            uri,
25            manager,
26            base_offset: block.content_start,
27            inline: false,
28            usage_context_override: None,
29            dom_node: None,
30        };
31        let _ = parse_css_snippet(context).await;
32    }
33
34    for inline in parsed.inline_styles {
35        let dom_node: Option<DOMNodeInfo> = parsed
36            .dom_tree
37            .find_node_at_position(inline.attribute_start);
38
39        let context = CssParseContext {
40            // CSS ranges must be measured against the raw attribute text. The decoded
41            // value can be shorter than an entity such as `&quot;`, shifting every range
42            // that follows it in the source document.
43            css_text: &inline.raw_value,
44            full_text: text,
45            uri,
46            manager,
47            base_offset: inline.value_start,
48            inline: true,
49            usage_context_override: Some("inline-style"),
50            dom_node,
51        };
52        let _ = parse_css_snippet(context).await;
53    }
54
55    Ok(())
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use crate::manager::CssVariableManager;
62    use crate::types::{offset_to_position, Config};
63    use std::collections::HashSet;
64    use std::str::FromStr;
65
66    #[tokio::test]
67    async fn parse_html_document_extracts_definitions_and_usages() {
68        let manager = CssVariableManager::new(Config::default());
69        let uri = Uri::from_str("file:///test.html").unwrap();
70        let text = r#"
71            <style>
72                :root { --primary: #fff; }
73                .card { color: var(--primary); }
74            </style>
75            <div style="--accent: blue; color: var(--primary, #000)"></div>
76        "#;
77
78        parse_html_document(text, &uri, &manager).await.unwrap();
79
80        let primary_defs = manager.get_variables("--primary").await;
81        assert_eq!(primary_defs.len(), 1);
82        assert_eq!(primary_defs[0].value, "#fff");
83
84        let accent_defs = manager.get_variables("--accent").await;
85        assert_eq!(accent_defs.len(), 1);
86        assert_eq!(accent_defs[0].value, "blue");
87
88        let usages = manager.get_usages("--primary").await;
89        assert_eq!(usages.len(), 2);
90
91        let contexts: HashSet<String> = usages.into_iter().map(|u| u.usage_context).collect();
92        assert!(contexts.contains(".card"));
93        assert!(contexts.contains("inline-style"));
94    }
95
96    #[tokio::test]
97    async fn parse_html_document_extracts_literal_colors_from_style_and_inline() {
98        let manager = CssVariableManager::new(Config::default());
99        let uri = Uri::from_str("file:///test.html").unwrap();
100        let text = r#"
101            <style>
102                .card { color: #fff; }
103            </style>
104            <div style="background: rgb(255, 0, 0); color: var(--primary, blue)"></div>
105        "#;
106
107        parse_html_document(text, &uri, &manager).await.unwrap();
108
109        let occurrences = manager.get_document_literal_colors(&uri).await;
110        let literals: HashSet<String> = occurrences.into_iter().map(|occ| occ.text).collect();
111        assert!(literals.contains("#fff"));
112        assert!(literals.contains("rgb(255, 0, 0)"));
113        assert!(!literals.contains("blue"));
114    }
115
116    #[tokio::test]
117    async fn parse_html_document_preserves_raw_offsets_after_inline_entities() {
118        let manager = CssVariableManager::new(Config::default());
119        let uri = Uri::from_str("file:///test.html").unwrap();
120        let text =
121            r#"<div style="content: &quot;x&quot;; color: var(--tone); --after: red"></div>"#;
122
123        parse_html_document(text, &uri, &manager).await.unwrap();
124
125        let usage = manager.get_usages("--tone").await.pop().unwrap();
126        let usage_start = text.find("var(--tone)").unwrap();
127        let name_start = text.find("--tone").unwrap();
128        assert_eq!(usage.range.start, offset_to_position(text, usage_start));
129        assert_eq!(
130            usage.name_range.unwrap().start,
131            offset_to_position(text, name_start),
132        );
133
134        let definition = manager.get_variables("--after").await.pop().unwrap();
135        let definition_start = text.find("--after").unwrap();
136        assert_eq!(definition.source_position, definition_start);
137        assert_eq!(
138            definition.name_range.unwrap().start,
139            offset_to_position(text, definition_start),
140        );
141    }
142}
143
144#[cfg(test)]
145mod edge_case_tests {
146    use super::*;
147    use crate::types::Config;
148    use ls_types::Uri;
149    use std::str::FromStr;
150
151    #[tokio::test]
152    async fn test_parse_empty_html() {
153        let manager = CssVariableManager::new(Config::default());
154        let uri = Uri::from_str("file:///empty.html").unwrap();
155
156        let result = parse_html_document("", &uri, &manager).await;
157        assert!(result.is_ok());
158    }
159
160    #[tokio::test]
161    async fn test_parse_html_with_multiple_style_blocks() {
162        let manager = CssVariableManager::new(Config::default());
163        let uri = Uri::from_str("file:///test.html").unwrap();
164
165        let html = r#"
166            <html>
167                <head>
168                    <style>:root { --head: blue; }</style>
169                </head>
170                <body>
171                    <style>.body { --body: red; }</style>
172                    <div>
173                        <style>.nested { --nested: green; }</style>
174                    </div>
175                </body>
176            </html>
177        "#;
178
179        parse_html_document(html, &uri, &manager).await.unwrap();
180
181        let vars = manager.get_all_variables().await;
182        assert!(vars.len() >= 3);
183    }
184
185    #[tokio::test]
186    async fn test_parse_html_with_inline_styles() {
187        let manager = CssVariableManager::new(Config::default());
188        let uri = Uri::from_str("file:///test.html").unwrap();
189
190        let html = r#"
191            <div style="--inline1: red; color: var(--inline1);">
192                <span style="--inline2: blue;">Test</span>
193            </div>
194        "#;
195
196        parse_html_document(html, &uri, &manager).await.unwrap();
197
198        let vars = manager.get_all_variables().await;
199        assert!(vars.len() >= 2);
200
201        // Check that inline variables are marked as inline
202        let inline_vars: Vec<_> = vars.iter().filter(|v| v.inline).collect();
203        assert!(inline_vars.len() >= 2);
204    }
205
206    #[tokio::test]
207    async fn test_parse_html_mixed_quotes() {
208        let manager = CssVariableManager::new(Config::default());
209        let uri = Uri::from_str("file:///test.html").unwrap();
210
211        let html = r#"
212            <div style="--var1: blue;">Single</div>
213            <div style='--var2: red;'>Double</div>
214        "#;
215
216        parse_html_document(html, &uri, &manager).await.unwrap();
217
218        let vars = manager.get_all_variables().await;
219        assert!(vars.len() >= 2);
220    }
221
222    #[tokio::test]
223    async fn test_parse_html_with_comments() {
224        let manager = CssVariableManager::new(Config::default());
225        let uri = Uri::from_str("file:///test.html").unwrap();
226
227        let html = r#"
228            <!-- HTML Comment -->
229            <style>
230                /* CSS Comment */
231                :root { --color: blue; }
232            </style>
233            <div style="--inline: red;"><!-- Inline comment --></div>
234        "#;
235
236        parse_html_document(html, &uri, &manager).await.unwrap();
237
238        let vars = manager.get_all_variables().await;
239        assert!(vars.len() >= 2);
240    }
241
242    #[tokio::test]
243    async fn test_parse_html_malformed() {
244        let manager = CssVariableManager::new(Config::default());
245        let uri = Uri::from_str("file:///test.html").unwrap();
246
247        // Missing closing tags
248        let html = r#"
249            <div style="--var: blue;">
250                <style>:root { --color: red; }
251        "#;
252
253        let result = parse_html_document(html, &uri, &manager).await;
254        assert!(result.is_ok()); // Should still parse what it can
255    }
256
257    #[tokio::test]
258    async fn test_parse_html_script_tags_ignored() {
259        let manager = CssVariableManager::new(Config::default());
260        let uri = Uri::from_str("file:///test.html").unwrap();
261
262        let html = r#"
263            <html>
264                <script>
265                    const style = "--color: red;";
266                </script>
267                <style>:root { --real-color: blue; }</style>
268            </html>
269        "#;
270
271        parse_html_document(html, &uri, &manager).await.unwrap();
272
273        // Should only find the real CSS variable, not the one in JavaScript
274        let vars = manager.get_all_variables().await;
275        assert_eq!(vars.len(), 1);
276        assert_eq!(vars[0].name, "--real-color");
277    }
278
279    #[tokio::test]
280    async fn test_parse_html_empty_style_attribute() {
281        let manager = CssVariableManager::new(Config::default());
282        let uri = Uri::from_str("file:///test.html").unwrap();
283
284        let html = r#"
285            <div style="">Empty</div>
286            <div style="  ">Whitespace</div>
287        "#;
288
289        let result = parse_html_document(html, &uri, &manager).await;
290        assert!(result.is_ok());
291    }
292
293    #[tokio::test]
294    async fn test_parse_html_style_with_media_queries() {
295        let manager = CssVariableManager::new(Config::default());
296        let uri = Uri::from_str("file:///test.html").unwrap();
297
298        let html = r#"
299            <style>
300                :root { --base: blue; }
301                @media (min-width: 768px) {
302                    :root { --responsive: red; }
303                }
304            </style>
305        "#;
306
307        parse_html_document(html, &uri, &manager).await.unwrap();
308
309        let vars = manager.get_all_variables().await;
310        assert!(vars.len() >= 2);
311    }
312
313    #[tokio::test]
314    async fn test_parse_html_nested_elements_with_styles() {
315        let manager = CssVariableManager::new(Config::default());
316        let uri = Uri::from_str("file:///test.html").unwrap();
317
318        let html = r#"
319            <div style="--outer: blue;">
320                <div style="--middle: red;">
321                    <div style="--inner: green;">
322                        Content
323                    </div>
324                </div>
325            </div>
326        "#;
327
328        parse_html_document(html, &uri, &manager).await.unwrap();
329
330        let vars = manager.get_all_variables().await;
331        assert_eq!(vars.len(), 3);
332    }
333
334    #[tokio::test]
335    async fn test_parse_html_special_characters_in_attributes() {
336        let manager = CssVariableManager::new(Config::default());
337        let uri = Uri::from_str("file:///test.html").unwrap();
338
339        let html = r#"
340            <div 
341                class="test-class" 
342                data-value="some&value" 
343                style="--color: rgb(255, 0, 0);">
344                Test
345            </div>
346        "#;
347
348        parse_html_document(html, &uri, &manager).await.unwrap();
349
350        let vars = manager.get_all_variables().await;
351        assert_eq!(vars.len(), 1);
352    }
353
354    #[tokio::test]
355    async fn test_parse_html_vue_component() {
356        let manager = CssVariableManager::new(Config::default());
357        let uri = Uri::from_str("file:///test.vue").unwrap();
358
359        let html = r#"
360            <template>
361                <div style="--vue-var: blue;">Vue Component</div>
362            </template>
363            <style scoped>
364                :root { --vue-style: red; }
365            </style>
366        "#;
367
368        parse_html_document(html, &uri, &manager).await.unwrap();
369
370        let vars = manager.get_all_variables().await;
371        assert!(vars.len() >= 2);
372    }
373
374    #[tokio::test]
375    async fn test_parse_html_svelte_component() {
376        let manager = CssVariableManager::new(Config::default());
377        let uri = Uri::from_str("file:///test.svelte").unwrap();
378
379        let html = r#"
380            <div style="--svelte-var: green;">
381                Svelte Component
382            </div>
383            <style>
384                :global(:root) { --svelte-global: purple; }
385            </style>
386        "#;
387
388        parse_html_document(html, &uri, &manager).await.unwrap();
389
390        let vars = manager.get_all_variables().await;
391        assert!(vars.len() >= 2);
392    }
393}