css_variable_lsp/parsers/
css.rs

1use tower_lsp::lsp_types::{Range, Url};
2
3use crate::manager::CssVariableManager;
4use crate::types::{offset_to_position, CssVariable, CssVariableUsage, DOMNodeInfo};
5
6/// Configuration for parsing CSS snippets
7pub struct CssParseContext<'a> {
8    pub css_text: &'a str,
9    pub full_text: &'a str,
10    pub uri: &'a Url,
11    pub manager: &'a CssVariableManager,
12    pub base_offset: usize,
13    pub inline: bool,
14    pub usage_context_override: Option<&'a str>,
15    pub dom_node: Option<DOMNodeInfo>,
16}
17
18/// Parse a CSS document and extract variable definitions and usages
19pub async fn parse_css_document(
20    text: &str,
21    uri: &Url,
22    manager: &CssVariableManager,
23) -> Result<(), String> {
24    let context = CssParseContext {
25        css_text: text,
26        full_text: text,
27        uri,
28        manager,
29        base_offset: 0,
30        inline: false,
31        usage_context_override: None,
32        dom_node: None,
33    };
34    parse_css_snippet(context).await
35}
36
37/// Parse a CSS snippet with a base offset into the full document.
38pub async fn parse_css_snippet(context: CssParseContext<'_>) -> Result<(), String> {
39    extract_definitions(
40        context.css_text,
41        context.full_text,
42        context.uri,
43        context.manager,
44        context.base_offset,
45        context.inline,
46        context.usage_context_override,
47    )
48    .await;
49    extract_usages(
50        context.css_text,
51        context.full_text,
52        context.uri,
53        context.manager,
54        context.base_offset,
55        context.usage_context_override,
56        context.dom_node,
57    )
58    .await;
59    Ok(())
60}
61
62async fn extract_definitions(
63    css_text: &str,
64    full_text: &str,
65    uri: &Url,
66    manager: &CssVariableManager,
67    base_offset: usize,
68    inline: bool,
69    selector_override: Option<&str>,
70) {
71    let bytes = css_text.as_bytes();
72    let len = bytes.len();
73    let mut i = 0;
74    let mut in_comment = false;
75    let mut in_string: Option<u8> = None;
76    let mut brace_depth = 0;
77    let mut in_at_rule = false;
78
79    while i < len {
80        if in_comment {
81            if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'/' {
82                in_comment = false;
83                i += 2;
84                continue;
85            }
86            i += 1;
87            continue;
88        }
89
90        if let Some(quote) = in_string {
91            if bytes[i] == b'\\' {
92                i += 2;
93                continue;
94            }
95            if bytes[i] == quote {
96                in_string = None;
97            }
98            i += 1;
99            continue;
100        }
101
102        if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
103            in_comment = true;
104            i += 2;
105            continue;
106        }
107
108        if bytes[i] == b'"' || bytes[i] == b'\'' {
109            in_string = Some(bytes[i]);
110            i += 1;
111            continue;
112        }
113
114        // Track braces for scope
115        if bytes[i] == b'{' {
116            brace_depth += 1;
117        } else if bytes[i] == b'}' {
118            brace_depth -= 1;
119            if brace_depth < 0 {
120                brace_depth = 0;
121            }
122        }
123
124        // Track @-rules
125        if bytes[i] == b'@' && !in_comment && in_string.is_none() {
126            in_at_rule = true;
127        } else if bytes[i] == b'{' && in_at_rule {
128            in_at_rule = false;
129        }
130
131        if bytes[i] == b'-' && i + 1 < len && bytes[i + 1] == b'-' {
132            let name_start = i;
133            let mut j = i + 2;
134            while j < len && is_ident_char(bytes[j]) {
135                j += 1;
136            }
137            if j == name_start + 2 {
138                i += 2;
139                continue;
140            }
141
142            let name_end = j;
143            let mut k = j;
144            while k < len && bytes[k].is_ascii_whitespace() {
145                k += 1;
146            }
147            if k >= len || bytes[k] != b':' {
148                i = name_end;
149                continue;
150            }
151
152            let mut value_start = k + 1;
153            while value_start < len && bytes[value_start].is_ascii_whitespace() {
154                value_start += 1;
155            }
156
157            let mut value_end = value_start;
158            let mut depth = 0i32;
159            let mut val_in_comment = false;
160            let mut val_in_string: Option<u8> = None;
161            while value_end < len {
162                let b = bytes[value_end];
163                if val_in_comment {
164                    if value_end + 1 < len && b == b'*' && bytes[value_end + 1] == b'/' {
165                        val_in_comment = false;
166                        value_end += 2;
167                        continue;
168                    }
169                    value_end += 1;
170                    continue;
171                }
172                if let Some(q) = val_in_string {
173                    if b == b'\\' {
174                        value_end += 2;
175                        continue;
176                    }
177                    if b == q {
178                        val_in_string = None;
179                    }
180                    value_end += 1;
181                    continue;
182                }
183                if value_end + 1 < len && b == b'/' && bytes[value_end + 1] == b'*' {
184                    val_in_comment = true;
185                    value_end += 2;
186                    continue;
187                }
188                if b == b'"' || b == b'\'' {
189                    val_in_string = Some(b);
190                    value_end += 1;
191                    continue;
192                }
193                if b == b'(' {
194                    depth += 1;
195                    value_end += 1;
196                    continue;
197                }
198                if b == b')' && depth > 0 {
199                    depth -= 1;
200                    value_end += 1;
201                    continue;
202                }
203                if depth == 0 && (b == b';' || b == b'}') {
204                    break;
205                }
206                value_end += 1;
207            }
208
209            let mut value_end_trim = value_end;
210            while value_end_trim > value_start && bytes[value_end_trim - 1].is_ascii_whitespace() {
211                value_end_trim -= 1;
212            }
213
214            let name = css_text[name_start..name_end].to_string();
215            let value = css_text[value_start..value_end_trim].trim().to_string();
216            let selector = selector_override
217                .map(|s| s.to_string())
218                .unwrap_or_else(|| find_selector_before(css_text, name_start, in_at_rule));
219
220            let abs_name_start = base_offset + name_start;
221            let abs_name_end = base_offset + name_end;
222            let abs_value_start = base_offset + value_start;
223            let abs_value_end = base_offset + value_end_trim;
224
225            let variable = CssVariable {
226                name,
227                value: value.clone(),
228                uri: uri.clone(),
229                range: Range::new(
230                    offset_to_position(full_text, abs_name_start),
231                    offset_to_position(full_text, abs_value_end),
232                ),
233                name_range: Some(Range::new(
234                    offset_to_position(full_text, abs_name_start),
235                    offset_to_position(full_text, abs_name_end),
236                )),
237                value_range: Some(Range::new(
238                    offset_to_position(full_text, abs_value_start),
239                    offset_to_position(full_text, abs_value_end),
240                )),
241                selector,
242                important: value.to_lowercase().contains("!important"),
243                inline,
244                source_position: abs_name_start,
245            };
246
247            manager.add_variable(variable).await;
248            i = name_end;
249            continue;
250        }
251
252        i += 1;
253    }
254}
255
256async fn extract_usages(
257    css_text: &str,
258    full_text: &str,
259    uri: &Url,
260    manager: &CssVariableManager,
261    base_offset: usize,
262    usage_context_override: Option<&str>,
263    dom_node: Option<DOMNodeInfo>,
264) {
265    let bytes = css_text.as_bytes();
266    let len = bytes.len();
267    let mut i = 0;
268    let mut in_comment = false;
269    let mut in_string: Option<u8> = None;
270    let mut brace_depth = 0;
271    let mut in_at_rule = false;
272
273    while i < len {
274        if in_comment {
275            if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'/' {
276                in_comment = false;
277                i += 2;
278                continue;
279            }
280            i += 1;
281            continue;
282        }
283
284        if let Some(quote) = in_string {
285            if bytes[i] == b'\\' {
286                i += 2;
287                continue;
288            }
289            if bytes[i] == quote {
290                in_string = None;
291            }
292            i += 1;
293            continue;
294        }
295
296        if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
297            in_comment = true;
298            i += 2;
299            continue;
300        }
301
302        if bytes[i] == b'"' || bytes[i] == b'\'' {
303            in_string = Some(bytes[i]);
304            i += 1;
305            continue;
306        }
307
308        // Track braces for scope
309        if bytes[i] == b'{' {
310            brace_depth += 1;
311        } else if bytes[i] == b'}' {
312            brace_depth -= 1;
313            if brace_depth < 0 {
314                brace_depth = 0;
315            }
316        }
317
318        // Track @-rules
319        if bytes[i] == b'@' && !in_comment && in_string.is_none() {
320            in_at_rule = true;
321        } else if bytes[i] == b'{' && in_at_rule {
322            in_at_rule = false;
323        }
324
325        if is_var_function(bytes, i) {
326            let var_start = i;
327            let mut j = i + 3;
328            while j < len && bytes[j].is_ascii_whitespace() {
329                j += 1;
330            }
331            if j >= len || bytes[j] != b'(' {
332                i += 1;
333                continue;
334            }
335            let args_start = j + 1;
336            let mut name_start = None;
337            let mut name_end = None;
338            let mut k = args_start;
339            while k < len && bytes[k].is_ascii_whitespace() {
340                k += 1;
341            }
342            if k + 1 < len && bytes[k] == b'-' && bytes[k + 1] == b'-' {
343                name_start = Some(k);
344                k += 2;
345                while k < len && is_ident_char(bytes[k]) {
346                    k += 1;
347                }
348                name_end = Some(k);
349            }
350
351            let mut depth = 1i32;
352            let mut p = args_start;
353            let mut var_in_comment = false;
354            let mut var_in_string: Option<u8> = None;
355            while p < len && depth > 0 {
356                let b = bytes[p];
357                if var_in_comment {
358                    if p + 1 < len && b == b'*' && bytes[p + 1] == b'/' {
359                        var_in_comment = false;
360                        p += 2;
361                        continue;
362                    }
363                    p += 1;
364                    continue;
365                }
366                if let Some(q) = var_in_string {
367                    if b == b'\\' {
368                        p += 2;
369                        continue;
370                    }
371                    if b == q {
372                        var_in_string = None;
373                    }
374                    p += 1;
375                    continue;
376                }
377                if p + 1 < len && b == b'/' && bytes[p + 1] == b'*' {
378                    var_in_comment = true;
379                    p += 2;
380                    continue;
381                }
382                if b == b'"' || b == b'\'' {
383                    var_in_string = Some(b);
384                    p += 1;
385                    continue;
386                }
387                if b == b'(' {
388                    depth += 1;
389                    p += 1;
390                    continue;
391                }
392                if b == b')' {
393                    depth -= 1;
394                    p += 1;
395                    continue;
396                }
397                p += 1;
398            }
399
400            let var_end = p.min(len);
401            if let (Some(ns), Some(ne)) = (name_start, name_end) {
402                let name = css_text[ns..ne].to_string();
403                let usage_context = usage_context_override
404                    .map(|s| s.to_string())
405                    .unwrap_or_else(|| find_selector_before(css_text, var_start, in_at_rule));
406                let abs_start = base_offset + var_start;
407                let abs_end = base_offset + var_end;
408                let abs_name_start = base_offset + ns;
409                let abs_name_end = base_offset + ne;
410
411                let usage = CssVariableUsage {
412                    name,
413                    uri: uri.clone(),
414                    range: Range::new(
415                        offset_to_position(full_text, abs_start),
416                        offset_to_position(full_text, abs_end),
417                    ),
418                    name_range: Some(Range::new(
419                        offset_to_position(full_text, abs_name_start),
420                        offset_to_position(full_text, abs_name_end),
421                    )),
422                    usage_context,
423                    dom_node: dom_node.clone(),
424                };
425                manager.add_usage(usage).await;
426            }
427
428            i = var_end;
429            continue;
430        }
431
432        i += 1;
433    }
434}
435
436fn is_var_function(bytes: &[u8], idx: usize) -> bool {
437    if idx + 2 >= bytes.len() {
438        return false;
439    }
440    if !bytes[idx].eq_ignore_ascii_case(&b'v')
441        || !bytes[idx + 1].eq_ignore_ascii_case(&b'a')
442        || !bytes[idx + 2].eq_ignore_ascii_case(&b'r')
443    {
444        return false;
445    }
446    if idx > 0 && is_ident_char(bytes[idx - 1]) {
447        return false;
448    }
449    true
450}
451
452fn is_ident_char(b: u8) -> bool {
453    b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
454}
455
456fn find_selector_before(text: &str, offset: usize, in_at_rule: bool) -> String {
457    let before = &text[..offset];
458
459    if in_at_rule {
460        // For variables defined in @-rules, find the @-rule context
461        if let Some(at_pos) = before.rfind('@') {
462            let at_rule_end = before[at_pos..]
463                .find('{')
464                .map(|pos| pos + at_pos)
465                .unwrap_or(before.len());
466            let at_rule = before[at_pos..at_rule_end].trim();
467            return format!("@{}", at_rule);
468        }
469        return "@unknown".to_string();
470    }
471
472    if let Some(brace_pos) = before.rfind('{') {
473        let start = before[..brace_pos].rfind('}').map(|p| p + 1).unwrap_or(0);
474        let selector_block = before[start..brace_pos].trim();
475
476        // Handle complex selectors that might span multiple lines or have nested braces
477        let selector = extract_last_selector(selector_block);
478
479        if selector.is_empty() {
480            ":root".to_string()
481        } else {
482            selector
483        }
484    } else {
485        ":root".to_string()
486    }
487}
488
489/// Extract the last selector from a selector block, handling complex cases
490fn extract_last_selector(selector_block: &str) -> String {
491    // Split on commas to handle selector lists
492    let selectors: Vec<&str> = selector_block.split(',').map(|s| s.trim()).collect();
493
494    // For each selector, find the last meaningful one
495    for selector in selectors.into_iter().rev() {
496        let cleaned = selector.rsplit('{').next().unwrap_or(selector).trim();
497
498        // Skip empty selectors or CSS at-rules
499        if !cleaned.is_empty() && !cleaned.starts_with('@') {
500            // Clean up multi-line selectors
501            let lines: Vec<&str> = cleaned.lines().collect();
502            for line in lines.into_iter().rev() {
503                let trimmed = line.trim();
504                if !trimmed.is_empty() {
505                    return trimmed.to_string();
506                }
507            }
508        }
509    }
510
511    ":root".to_string()
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517    use crate::manager::CssVariableManager;
518    use crate::types::Config;
519    use std::collections::HashSet;
520
521    #[tokio::test]
522    async fn parse_css_document_extracts_definitions_and_usages() {
523        let manager = CssVariableManager::new(Config::default());
524        let uri = Url::parse("file:///test.css").unwrap();
525        let text = ":root { --primary: #fff; color: var(--primary); } \
526                    .button { --secondary: var(--primary, #000); }";
527
528        parse_css_document(text, &uri, &manager).await.unwrap();
529
530        let primary_defs = manager.get_variables("--primary").await;
531        assert_eq!(primary_defs.len(), 1);
532        assert_eq!(primary_defs[0].value, "#fff");
533
534        let secondary_defs = manager.get_variables("--secondary").await;
535        assert_eq!(secondary_defs.len(), 1);
536        assert_eq!(secondary_defs[0].value, "var(--primary, #000)");
537
538        let usages = manager.get_usages("--primary").await;
539        assert_eq!(usages.len(), 2);
540
541        let contexts: HashSet<String> = usages.into_iter().map(|u| u.usage_context).collect();
542        assert!(contexts.contains(":root"));
543        assert!(contexts.contains(".button"));
544    }
545
546    #[tokio::test]
547    async fn parse_css_document_skips_nested_var_fallback_usages() {
548        let manager = CssVariableManager::new(Config::default());
549        let uri = Url::parse("file:///test.css").unwrap();
550        let text = ".button { color: var(--primary, var(--fallback)); }";
551
552        parse_css_document(text, &uri, &manager).await.unwrap();
553
554        let primary_usages = manager.get_usages("--primary").await;
555        assert_eq!(primary_usages.len(), 1);
556
557        let fallback_usages = manager.get_usages("--fallback").await;
558        assert_eq!(fallback_usages.len(), 0);
559    }
560}
561
562#[cfg(test)]
563mod edge_case_tests {
564    use super::*;
565    use crate::types::Config;
566    use tower_lsp::lsp_types::Url;
567
568    #[tokio::test]
569    async fn test_parse_empty_css() {
570        let manager = CssVariableManager::new(Config::default());
571        let uri = Url::parse("file:///empty.css").unwrap();
572
573        let result = parse_css_document("", &uri, &manager).await;
574        assert!(result.is_ok());
575    }
576
577    #[tokio::test]
578    async fn test_parse_css_with_comments() {
579        let manager = CssVariableManager::new(Config::default());
580        let uri = Url::parse("file:///test.css").unwrap();
581
582        let css = r#"
583            /* Comment before */
584            :root {
585                /* Inline comment */
586                --primary: blue; /* End comment */
587                --secondary: red;
588            }
589            /* Comment after */
590        "#;
591
592        let result = parse_css_document(css, &uri, &manager).await;
593        assert!(result.is_ok());
594
595        let vars = manager.get_all_variables().await;
596        assert_eq!(vars.len(), 2);
597    }
598
599    #[tokio::test]
600    async fn test_parse_css_with_important() {
601        let manager = CssVariableManager::new(Config::default());
602        let uri = Url::parse("file:///test.css").unwrap();
603
604        let css = r#"
605            :root {
606                --color: red !important;
607                --spacing: 1rem;
608            }
609        "#;
610
611        parse_css_document(css, &uri, &manager).await.unwrap();
612
613        let vars = manager.get_variables("--color").await;
614        assert_eq!(vars.len(), 1);
615        assert!(vars[0].important);
616
617        let spacing = manager.get_variables("--spacing").await;
618        assert!(!spacing[0].important);
619    }
620
621    #[tokio::test]
622    async fn test_parse_css_var_with_fallback() {
623        let manager = CssVariableManager::new(Config::default());
624        let uri = Url::parse("file:///test.css").unwrap();
625
626        let css = r#"
627            .button {
628                color: var(--primary, blue);
629                background: var(--bg, var(--fallback, #fff));
630            }
631        "#;
632
633        parse_css_document(css, &uri, &manager).await.unwrap();
634
635        let primary_usages = manager.get_usages("--primary").await;
636        assert_eq!(primary_usages.len(), 1);
637        // Fallback values are parsed but not stored in the usage struct
638
639        let bg_usages = manager.get_usages("--bg").await;
640        assert_eq!(bg_usages.len(), 1);
641    }
642
643    #[tokio::test]
644    async fn test_parse_css_complex_selectors() {
645        let manager = CssVariableManager::new(Config::default());
646        let uri = Url::parse("file:///test.css").unwrap();
647
648        let css = r#"
649            #id .class > div[data-attr="value"]:hover::before {
650                --complex: value;
651            }
652            
653            @media (min-width: 768px) {
654                .responsive {
655                    --media: query;
656                }
657            }
658        "#;
659
660        parse_css_document(css, &uri, &manager).await.unwrap();
661
662        let vars = manager.get_all_variables().await;
663        assert!(vars.len() >= 2);
664    }
665
666    #[tokio::test]
667    async fn test_parse_css_multiline_values() {
668        let manager = CssVariableManager::new(Config::default());
669        let uri = Url::parse("file:///test.css").unwrap();
670
671        let css = r#"
672            :root {
673                --gradient: linear-gradient(
674                    to bottom,
675                    red,
676                    blue
677                );
678            }
679        "#;
680
681        parse_css_document(css, &uri, &manager).await.unwrap();
682
683        let vars = manager.get_variables("--gradient").await;
684        assert_eq!(vars.len(), 1);
685        assert!(vars[0].value.contains("linear-gradient"));
686    }
687
688    #[tokio::test]
689    async fn test_parse_css_variable_names_with_dashes() {
690        let manager = CssVariableManager::new(Config::default());
691        let uri = Url::parse("file:///test.css").unwrap();
692
693        let css = r#"
694            :root {
695                --primary-color: blue;
696                --bg-color-dark: #333;
697                --font-size-xl: 2rem;
698            }
699        "#;
700
701        parse_css_document(css, &uri, &manager).await.unwrap();
702
703        let vars = manager.get_all_variables().await;
704        assert_eq!(vars.len(), 3);
705        assert!(vars.iter().any(|v| v.name == "--primary-color"));
706        assert!(vars.iter().any(|v| v.name == "--bg-color-dark"));
707        assert!(vars.iter().any(|v| v.name == "--font-size-xl"));
708    }
709
710    #[tokio::test]
711    async fn test_parse_css_special_characters_in_values() {
712        let manager = CssVariableManager::new(Config::default());
713        let uri = Url::parse("file:///test.css").unwrap();
714
715        let css = r#"
716            :root {
717                --shadow: 0 2px 4px rgba(0,0,0,0.1);
718                --calc: calc(100% - 20px);
719                --url: url("https://example.com/image.jpg");
720                --content: "Hello, World!";
721            }
722        "#;
723
724        parse_css_document(css, &uri, &manager).await.unwrap();
725
726        let vars = manager.get_all_variables().await;
727        assert_eq!(vars.len(), 4);
728    }
729
730    #[tokio::test]
731    async fn test_parse_css_nested_var_calls() {
732        let manager = CssVariableManager::new(Config::default());
733        let uri = Url::parse("file:///test.css").unwrap();
734
735        let css = r#"
736            .element {
737                color: var(--primary);
738                background: var(--bg);
739                border: 1px solid var(--border-color);
740            }
741        "#;
742
743        parse_css_document(css, &uri, &manager).await.unwrap();
744
745        assert_eq!(manager.get_usages("--primary").await.len(), 1);
746        assert_eq!(manager.get_usages("--bg").await.len(), 1);
747        assert_eq!(manager.get_usages("--border-color").await.len(), 1);
748    }
749
750    #[tokio::test]
751    async fn test_parse_css_whitespace_variations() {
752        let manager = CssVariableManager::new(Config::default());
753        let uri = Url::parse("file:///test.css").unwrap();
754
755        let css = r#"
756            :root{--no-space:value;}
757            :root { --normal-space: value; }
758            :root  {  --extra-space  :  value  ;  }
759        "#;
760
761        parse_css_document(css, &uri, &manager).await.unwrap();
762
763        let vars = manager.get_all_variables().await;
764        assert_eq!(vars.len(), 3);
765    }
766
767    #[tokio::test]
768    async fn test_parse_css_malformed_but_parseable() {
769        let manager = CssVariableManager::new(Config::default());
770        let uri = Url::parse("file:///test.css").unwrap();
771
772        // Missing closing brace, but should still parse what it can
773        let css = r#"
774            :root {
775                --valid: blue;
776        "#;
777
778        let result = parse_css_document(css, &uri, &manager).await;
779        assert!(result.is_ok());
780    }
781}