Skip to main content

css_variable_lsp/parsers/
css.rs

1use ls_types::{Range, Uri};
2use tracing::warn;
3
4use crate::color::normalized_color_key;
5use crate::manager::CssVariableManager;
6use crate::types::{
7    offset_to_position, CssVariable, CssVariableUsage, DOMNodeInfo, LiteralColorOccurrence,
8};
9
10/// Configuration for parsing CSS snippets
11pub struct CssParseContext<'a> {
12    pub css_text: &'a str,
13    pub full_text: &'a str,
14    pub uri: &'a Uri,
15    pub manager: &'a CssVariableManager,
16    pub base_offset: usize,
17    pub inline: bool,
18    pub usage_context_override: Option<&'a str>,
19    pub dom_node: Option<DOMNodeInfo>,
20}
21
22/// Parse a CSS document and extract variable definitions and usages
23pub async fn parse_css_document(
24    text: &str,
25    uri: &Uri,
26    manager: &CssVariableManager,
27) -> Result<(), String> {
28    let context = CssParseContext {
29        css_text: text,
30        full_text: text,
31        uri,
32        manager,
33        base_offset: 0,
34        inline: false,
35        usage_context_override: None,
36        dom_node: None,
37    };
38    parse_css_snippet(context).await
39}
40
41/// Parse a CSS snippet with a base offset into the full document.
42pub async fn parse_css_snippet(context: CssParseContext<'_>) -> Result<(), String> {
43    extract_definitions(
44        context.css_text,
45        context.full_text,
46        context.uri,
47        context.manager,
48        context.base_offset,
49        context.inline,
50        context.usage_context_override,
51    )
52    .await;
53    extract_usages(
54        context.css_text,
55        context.full_text,
56        context.uri,
57        context.manager,
58        context.base_offset,
59        context.usage_context_override,
60        context.dom_node,
61    )
62    .await;
63    extract_literal_colors(
64        context.css_text,
65        context.full_text,
66        context.uri,
67        context.manager,
68        context.base_offset,
69        context.usage_context_override,
70    )
71    .await;
72    Ok(())
73}
74
75async fn extract_definitions(
76    css_text: &str,
77    full_text: &str,
78    uri: &Uri,
79    manager: &CssVariableManager,
80    base_offset: usize,
81    inline: bool,
82    selector_override: Option<&str>,
83) {
84    for_each_declaration(
85        css_text,
86        selector_override,
87        |property_name,
88         property_name_start,
89         property_name_end,
90         value_start,
91         value_end,
92         selector| {
93            if !property_name.starts_with("--") {
94                return None;
95            }
96
97            let value = css_text[value_start..value_end].trim().to_string();
98            let abs_name_start = base_offset + property_name_start;
99            let abs_name_end = base_offset + property_name_end;
100            let abs_value_start = base_offset + value_start;
101            let abs_value_end = base_offset + value_end;
102
103            Some(CssVariable {
104                name: property_name.to_string(),
105                value: value.clone(),
106                uri: uri.clone(),
107                range: Range::new(
108                    offset_to_position(full_text, abs_name_start),
109                    offset_to_position(full_text, abs_value_end),
110                ),
111                name_range: Some(Range::new(
112                    offset_to_position(full_text, abs_name_start),
113                    offset_to_position(full_text, abs_name_end),
114                )),
115                value_range: Some(Range::new(
116                    offset_to_position(full_text, abs_value_start),
117                    offset_to_position(full_text, abs_value_end),
118                )),
119                selector,
120                important: value.to_lowercase().contains("!important"),
121                inline,
122                source_position: abs_name_start,
123            })
124        },
125        |variable| async move {
126            if let Err(e) = manager.add_variable(variable).await {
127                warn!("Failed to add CSS variable: {}", e);
128            }
129        },
130    )
131    .await;
132}
133
134async fn extract_literal_colors(
135    css_text: &str,
136    full_text: &str,
137    uri: &Uri,
138    manager: &CssVariableManager,
139    base_offset: usize,
140    selector_override: Option<&str>,
141) {
142    for_each_declaration(
143        css_text,
144        selector_override,
145        |_, _, _, value_start, value_end, selector| {
146            let value = &css_text[value_start..value_end];
147            let colors = extract_literal_colors_from_value(value)
148                .into_iter()
149                .map(
150                    |(relative_start, relative_end, normalized_color)| LiteralColorOccurrence {
151                        text: value[relative_start..relative_end].to_string(),
152                        uri: uri.clone(),
153                        range: Range::new(
154                            offset_to_position(
155                                full_text,
156                                base_offset + value_start + relative_start,
157                            ),
158                            offset_to_position(full_text, base_offset + value_start + relative_end),
159                        ),
160                        usage_context: selector.clone(),
161                        normalized_color,
162                    },
163                )
164                .collect::<Vec<_>>();
165            Some(colors)
166        },
167        |occurrences| async move {
168            for occurrence in occurrences {
169                manager.add_literal_color(occurrence).await;
170            }
171        },
172    )
173    .await;
174}
175
176async fn for_each_declaration<T, F, Fut>(
177    css_text: &str,
178    selector_override: Option<&str>,
179    mut build: F,
180    mut on_item: impl FnMut(T) -> Fut,
181) where
182    F: FnMut(&str, usize, usize, usize, usize, String) -> Option<T>,
183    Fut: std::future::Future<Output = ()>,
184{
185    let bytes = css_text.as_bytes();
186    let len = bytes.len();
187    let mut i = 0;
188    let mut in_comment = false;
189    let mut in_string: Option<u8> = None;
190    let mut brace_depth = 0;
191    let mut in_at_rule = false;
192    let mut declaration_start = 0usize;
193    let allow_without_braces = selector_override.is_some();
194
195    while i < len {
196        if in_comment {
197            if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'/' {
198                in_comment = false;
199                i += 2;
200                continue;
201            }
202            i += 1;
203            continue;
204        }
205
206        if let Some(quote) = in_string {
207            if bytes[i] == b'\\' {
208                i += 2;
209                continue;
210            }
211            if bytes[i] == quote {
212                in_string = None;
213            }
214            i += 1;
215            continue;
216        }
217
218        if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
219            in_comment = true;
220            i += 2;
221            continue;
222        }
223
224        if bytes[i] == b'"' || bytes[i] == b'\'' {
225            in_string = Some(bytes[i]);
226            i += 1;
227            continue;
228        }
229
230        if bytes[i] == b'@' {
231            in_at_rule = true;
232        }
233
234        if bytes[i] == b'{' {
235            brace_depth += 1;
236            if in_at_rule {
237                in_at_rule = false;
238            }
239            declaration_start = i + 1;
240            i += 1;
241            continue;
242        }
243
244        if bytes[i] == b'}' {
245            brace_depth -= 1;
246            if brace_depth < 0 {
247                brace_depth = 0;
248            }
249            declaration_start = i + 1;
250            i += 1;
251            continue;
252        }
253
254        if bytes[i] == b';' {
255            declaration_start = i + 1;
256            i += 1;
257            continue;
258        }
259
260        if bytes[i] != b':' || (brace_depth == 0 && !allow_without_braces) {
261            i += 1;
262            continue;
263        }
264
265        let mut name_end = i;
266        while name_end > declaration_start && bytes[name_end - 1].is_ascii_whitespace() {
267            name_end -= 1;
268        }
269
270        let mut name_start = name_end;
271        while name_start > declaration_start && is_ident_char(bytes[name_start - 1]) {
272            name_start -= 1;
273        }
274
275        if name_end <= name_start {
276            i += 1;
277            continue;
278        }
279
280        let property_name = &css_text[name_start..name_end];
281        let mut value_start = i + 1;
282        while value_start < len && bytes[value_start].is_ascii_whitespace() {
283            value_start += 1;
284        }
285
286        let mut value_end = value_start;
287        let mut depth = 0i32;
288        let mut val_in_comment = false;
289        let mut val_in_string: Option<u8> = None;
290        while value_end < len {
291            let b = bytes[value_end];
292            if val_in_comment {
293                if value_end + 1 < len && b == b'*' && bytes[value_end + 1] == b'/' {
294                    val_in_comment = false;
295                    value_end += 2;
296                    continue;
297                }
298                value_end += 1;
299                continue;
300            }
301            if let Some(q) = val_in_string {
302                if b == b'\\' {
303                    value_end += 2;
304                    continue;
305                }
306                if b == q {
307                    val_in_string = None;
308                }
309                value_end += 1;
310                continue;
311            }
312            if value_end + 1 < len && b == b'/' && bytes[value_end + 1] == b'*' {
313                val_in_comment = true;
314                value_end += 2;
315                continue;
316            }
317            if b == b'"' || b == b'\'' {
318                val_in_string = Some(b);
319                value_end += 1;
320                continue;
321            }
322            if b == b'(' {
323                depth += 1;
324                value_end += 1;
325                continue;
326            }
327            if b == b')' && depth > 0 {
328                depth -= 1;
329                value_end += 1;
330                continue;
331            }
332            if depth == 0 && (b == b';' || b == b'}') {
333                break;
334            }
335            value_end += 1;
336        }
337
338        let mut value_end_trim = value_end;
339        while value_end_trim > value_start && bytes[value_end_trim - 1].is_ascii_whitespace() {
340            value_end_trim -= 1;
341        }
342
343        let selector = selector_override
344            .map(|s| s.to_string())
345            .unwrap_or_else(|| find_selector_before(css_text, name_start, in_at_rule));
346
347        if let Some(item) = build(
348            property_name,
349            name_start,
350            name_end,
351            value_start,
352            value_end_trim,
353            selector,
354        ) {
355            on_item(item).await;
356        }
357
358        i = value_end;
359    }
360}
361
362async fn extract_usages(
363    css_text: &str,
364    full_text: &str,
365    uri: &Uri,
366    manager: &CssVariableManager,
367    base_offset: usize,
368    usage_context_override: Option<&str>,
369    dom_node: Option<DOMNodeInfo>,
370) {
371    let bytes = css_text.as_bytes();
372    let len = bytes.len();
373    let mut i = 0;
374    let mut in_comment = false;
375    let mut in_string: Option<u8> = None;
376    let mut brace_depth = 0;
377    let mut in_at_rule = false;
378
379    while i < len {
380        if in_comment {
381            if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'/' {
382                in_comment = false;
383                i += 2;
384                continue;
385            }
386            i += 1;
387            continue;
388        }
389
390        if let Some(quote) = in_string {
391            if bytes[i] == b'\\' {
392                i += 2;
393                continue;
394            }
395            if bytes[i] == quote {
396                in_string = None;
397            }
398            i += 1;
399            continue;
400        }
401
402        if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
403            in_comment = true;
404            i += 2;
405            continue;
406        }
407
408        if bytes[i] == b'"' || bytes[i] == b'\'' {
409            in_string = Some(bytes[i]);
410            i += 1;
411            continue;
412        }
413
414        // Track braces for scope
415        if bytes[i] == b'{' {
416            brace_depth += 1;
417        } else if bytes[i] == b'}' {
418            brace_depth -= 1;
419            if brace_depth < 0 {
420                brace_depth = 0;
421            }
422        }
423
424        // Track @-rules
425        if bytes[i] == b'@' && !in_comment && in_string.is_none() {
426            in_at_rule = true;
427        } else if bytes[i] == b'{' && in_at_rule {
428            in_at_rule = false;
429        }
430
431        if is_var_function(bytes, i) {
432            let var_start = i;
433            let mut j = i + 3;
434            while j < len && bytes[j].is_ascii_whitespace() {
435                j += 1;
436            }
437            if j >= len || bytes[j] != b'(' {
438                i += 1;
439                continue;
440            }
441            let args_start = j + 1;
442            let mut name_start = None;
443            let mut name_end = None;
444            let mut k = args_start;
445            while k < len && bytes[k].is_ascii_whitespace() {
446                k += 1;
447            }
448            if k + 1 < len && bytes[k] == b'-' && bytes[k + 1] == b'-' {
449                name_start = Some(k);
450                k += 2;
451                while k < len && is_ident_char(bytes[k]) {
452                    k += 1;
453                }
454                name_end = Some(k);
455            }
456
457            let mut depth = 1i32;
458            let mut p = args_start;
459            let mut var_in_comment = false;
460            let mut var_in_string: Option<u8> = None;
461            while p < len && depth > 0 {
462                let b = bytes[p];
463                if var_in_comment {
464                    if p + 1 < len && b == b'*' && bytes[p + 1] == b'/' {
465                        var_in_comment = false;
466                        p += 2;
467                        continue;
468                    }
469                    p += 1;
470                    continue;
471                }
472                if let Some(q) = var_in_string {
473                    if b == b'\\' {
474                        p += 2;
475                        continue;
476                    }
477                    if b == q {
478                        var_in_string = None;
479                    }
480                    p += 1;
481                    continue;
482                }
483                if p + 1 < len && b == b'/' && bytes[p + 1] == b'*' {
484                    var_in_comment = true;
485                    p += 2;
486                    continue;
487                }
488                if b == b'"' || b == b'\'' {
489                    var_in_string = Some(b);
490                    p += 1;
491                    continue;
492                }
493                if b == b'(' {
494                    depth += 1;
495                    p += 1;
496                    continue;
497                }
498                if b == b')' {
499                    depth -= 1;
500                    p += 1;
501                    continue;
502                }
503                p += 1;
504            }
505
506            let var_end = p.min(len);
507            if let (Some(ns), Some(ne)) = (name_start, name_end) {
508                let name = css_text[ns..ne].to_string();
509                let usage_context = usage_context_override
510                    .map(|s| s.to_string())
511                    .unwrap_or_else(|| find_selector_before(css_text, var_start, in_at_rule));
512                let abs_start = base_offset + var_start;
513                let abs_end = base_offset + var_end;
514                let abs_name_start = base_offset + ns;
515                let abs_name_end = base_offset + ne;
516
517                let usage = CssVariableUsage {
518                    name,
519                    uri: uri.clone(),
520                    range: Range::new(
521                        offset_to_position(full_text, abs_start),
522                        offset_to_position(full_text, abs_end),
523                    ),
524                    name_range: Some(Range::new(
525                        offset_to_position(full_text, abs_name_start),
526                        offset_to_position(full_text, abs_name_end),
527                    )),
528                    usage_context,
529                    dom_node: dom_node.clone(),
530                };
531                manager.add_usage(usage).await;
532            }
533
534            i = var_end;
535            continue;
536        }
537
538        i += 1;
539    }
540}
541
542fn is_var_function(bytes: &[u8], idx: usize) -> bool {
543    if idx + 2 >= bytes.len() {
544        return false;
545    }
546    if !bytes[idx].eq_ignore_ascii_case(&b'v')
547        || !bytes[idx + 1].eq_ignore_ascii_case(&b'a')
548        || !bytes[idx + 2].eq_ignore_ascii_case(&b'r')
549    {
550        return false;
551    }
552    if idx > 0 && is_ident_char(bytes[idx - 1]) {
553        return false;
554    }
555    true
556}
557
558fn is_ident_char(b: u8) -> bool {
559    b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
560}
561
562fn extract_literal_colors_from_value(
563    value: &str,
564) -> Vec<(usize, usize, crate::color::NormalizedColorKey)> {
565    let bytes = value.as_bytes();
566    let ignored_ranges = find_ignored_var_ranges(value);
567    let mut colors = Vec::new();
568    let mut i = 0usize;
569    let mut ignored_idx = 0usize;
570    let mut in_string: Option<u8> = None;
571
572    while i < bytes.len() {
573        while ignored_idx < ignored_ranges.len() && i >= ignored_ranges[ignored_idx].1 {
574            ignored_idx += 1;
575        }
576        if ignored_idx < ignored_ranges.len() && i >= ignored_ranges[ignored_idx].0 {
577            i = ignored_ranges[ignored_idx].1;
578            continue;
579        }
580
581        if let Some(quote) = in_string {
582            if bytes[i] == b'\\' {
583                i = i.saturating_add(2);
584                continue;
585            }
586            if bytes[i] == quote {
587                in_string = None;
588            }
589            i += 1;
590            continue;
591        }
592
593        if bytes[i] == b'"' || bytes[i] == b'\'' {
594            in_string = Some(bytes[i]);
595            i += 1;
596            continue;
597        }
598
599        if bytes[i] == b'#' {
600            let mut end = i + 1;
601            while end < bytes.len() && bytes[end].is_ascii_hexdigit() {
602                end += 1;
603            }
604            let len = end - i;
605            if matches!(len, 3..=9) {
606                if let Some(color) = normalized_color_key(&value[i..end]) {
607                    colors.push((i, end, color));
608                }
609            }
610            i = end;
611            continue;
612        }
613
614        if bytes[i].is_ascii_alphabetic() {
615            let start = i;
616            let mut end = i + 1;
617            while end < bytes.len() && is_ident_char(bytes[end]) {
618                end += 1;
619            }
620
621            let mut j = end;
622            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
623                j += 1;
624            }
625
626            if j < bytes.len() && bytes[j] == b'(' {
627                let ident = value[start..end].to_ascii_lowercase();
628                if matches!(ident.as_str(), "rgb" | "rgba" | "hsl" | "hsla") {
629                    if let Some(func_end) = find_balanced_call_end(value, j) {
630                        if let Some(color) = normalized_color_key(&value[start..func_end]) {
631                            colors.push((start, func_end, color));
632                        }
633                        i = func_end;
634                        continue;
635                    }
636                }
637            } else if let Some(color) = normalized_color_key(&value[start..end]) {
638                colors.push((start, end, color));
639            }
640
641            i = end;
642            continue;
643        }
644
645        i += 1;
646    }
647
648    colors
649}
650
651fn find_ignored_var_ranges(value: &str) -> Vec<(usize, usize)> {
652    let bytes = value.as_bytes();
653    let mut ranges = Vec::new();
654    let mut i = 0usize;
655    let mut in_string: Option<u8> = None;
656
657    while i < bytes.len() {
658        if let Some(quote) = in_string {
659            if bytes[i] == b'\\' {
660                i = i.saturating_add(2);
661                continue;
662            }
663            if bytes[i] == quote {
664                in_string = None;
665            }
666            i += 1;
667            continue;
668        }
669
670        if bytes[i] == b'"' || bytes[i] == b'\'' {
671            in_string = Some(bytes[i]);
672            i += 1;
673            continue;
674        }
675
676        if is_var_function(bytes, i) {
677            let mut j = i + 3;
678            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
679                j += 1;
680            }
681            if j < bytes.len() && bytes[j] == b'(' {
682                if let Some(end) = find_balanced_call_end(value, j) {
683                    ranges.push((i, end));
684                    i = end;
685                    continue;
686                }
687            }
688        }
689
690        i += 1;
691    }
692
693    ranges
694}
695
696fn find_balanced_call_end(value: &str, open_paren_idx: usize) -> Option<usize> {
697    let bytes = value.as_bytes();
698    let mut depth = 0i32;
699    let mut i = open_paren_idx;
700    let mut in_string: Option<u8> = None;
701
702    while i < bytes.len() {
703        let b = bytes[i];
704        if let Some(q) = in_string {
705            if b == b'\\' {
706                i = i.saturating_add(2);
707                continue;
708            }
709            if b == q {
710                in_string = None;
711            }
712            i += 1;
713            continue;
714        }
715
716        if b == b'"' || b == b'\'' {
717            in_string = Some(b);
718            i += 1;
719            continue;
720        }
721
722        if b == b'(' {
723            depth += 1;
724        } else if b == b')' {
725            depth -= 1;
726            if depth == 0 {
727                return Some(i + 1);
728            }
729        }
730        i += 1;
731    }
732
733    None
734}
735
736fn find_selector_before(text: &str, offset: usize, in_at_rule: bool) -> String {
737    let before = &text[..offset];
738
739    if in_at_rule {
740        // For variables defined in @-rules, find the @-rule context
741        if let Some(at_pos) = before.rfind('@') {
742            let at_rule_end = before[at_pos..]
743                .find('{')
744                .map(|pos| pos + at_pos)
745                .unwrap_or(before.len());
746            let at_rule = before[at_pos..at_rule_end].trim();
747            return format!("@{}", at_rule);
748        }
749        return "@unknown".to_string();
750    }
751
752    if let Some(brace_pos) = before.rfind('{') {
753        let start = before[..brace_pos].rfind('}').map(|p| p + 1).unwrap_or(0);
754        let selector_block = before[start..brace_pos].trim();
755
756        // Handle complex selectors that might span multiple lines or have nested braces
757        let selector = extract_last_selector(selector_block);
758
759        if selector.is_empty() {
760            ":root".to_string()
761        } else {
762            selector
763        }
764    } else {
765        ":root".to_string()
766    }
767}
768
769/// Extract the last selector from a selector block, handling complex cases
770fn extract_last_selector(selector_block: &str) -> String {
771    // Find the last complete selector by tracking balanced parentheses and commas
772    let bytes = selector_block.as_bytes();
773    let len = bytes.len();
774    let mut paren_depth: usize = 0;
775    let mut last_selector_start = 0;
776    let last_selector_end = len;
777
778    for (i, &b) in bytes.iter().enumerate() {
779        match b {
780            b'(' => {
781                paren_depth += 1;
782            }
783            b')' => {
784                paren_depth = paren_depth.saturating_sub(1);
785            }
786            b',' if paren_depth == 0 => {
787                // This is a selector list separator
788                // The next character (if any) starts a new selector
789                last_selector_start = i + 1;
790            }
791            _ => {}
792        }
793    }
794
795    // Extract the last selector
796    let last_selector = selector_block[last_selector_start..last_selector_end].trim();
797
798    // Clean up the selector - remove any trailing braces or CSS at-rules
799    let cleaned = last_selector
800        .split('{')
801        .next()
802        .unwrap_or(last_selector)
803        .trim();
804
805    // Handle CSS at-rules by finding the actual selector part
806    let selector = if cleaned.starts_with('@') {
807        // This is an at-rule like @media, find the selector inside
808        if let Some(open_brace) = cleaned.find('{') {
809            cleaned[..open_brace].trim().to_string()
810        } else {
811            cleaned.to_string()
812        }
813    } else {
814        cleaned.to_string()
815    };
816
817    if selector.is_empty() {
818        ":root".to_string()
819    } else {
820        selector
821    }
822}
823
824#[cfg(test)]
825mod tests {
826    use super::*;
827    use crate::manager::CssVariableManager;
828    use crate::types::Config;
829    use std::collections::HashSet;
830    use std::str::FromStr;
831
832    #[tokio::test]
833    async fn parse_css_document_extracts_definitions_and_usages() {
834        let manager = CssVariableManager::new(Config::default());
835        let uri = Uri::from_str("file:///test.css").unwrap();
836        let text = ":root { --primary: #fff; color: var(--primary); } \
837                    .button { --secondary: var(--primary, #000); }";
838
839        parse_css_document(text, &uri, &manager).await.unwrap();
840
841        let primary_defs = manager.get_variables("--primary").await;
842        assert_eq!(primary_defs.len(), 1);
843        assert_eq!(primary_defs[0].value, "#fff");
844
845        let secondary_defs = manager.get_variables("--secondary").await;
846        assert_eq!(secondary_defs.len(), 1);
847        assert_eq!(secondary_defs[0].value, "var(--primary, #000)");
848
849        let usages = manager.get_usages("--primary").await;
850        assert_eq!(usages.len(), 2);
851
852        let contexts: HashSet<String> = usages.into_iter().map(|u| u.usage_context).collect();
853        assert!(contexts.contains(":root"));
854        assert!(contexts.contains(".button"));
855    }
856
857    #[tokio::test]
858    async fn parse_css_document_skips_nested_var_fallback_usages() {
859        let manager = CssVariableManager::new(Config::default());
860        let uri = Uri::from_str("file:///test.css").unwrap();
861        let text = ".button { color: var(--primary, var(--fallback)); }";
862
863        parse_css_document(text, &uri, &manager).await.unwrap();
864
865        let primary_usages = manager.get_usages("--primary").await;
866        assert_eq!(primary_usages.len(), 1);
867
868        let fallback_usages = manager.get_usages("--fallback").await;
869        assert_eq!(fallback_usages.len(), 0);
870    }
871
872    #[tokio::test]
873    async fn parse_css_document_extracts_literal_colors_in_compound_values() {
874        let manager = CssVariableManager::new(Config::default());
875        let uri = Uri::from_str("file:///test.css").unwrap();
876        let text = r#"
877            .button {
878                color: #fff;
879                background: linear-gradient(red, rgb(255 255 255));
880                box-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
881            }
882        "#;
883
884        parse_css_document(text, &uri, &manager).await.unwrap();
885
886        let occurrences = manager.get_document_literal_colors(&uri).await;
887        let literals: HashSet<String> = occurrences.into_iter().map(|occ| occ.text).collect();
888        assert!(literals.contains("#fff"));
889        assert!(literals.contains("red"));
890        assert!(literals.contains("rgb(255 255 255)"));
891        assert!(literals.contains("rgba(0, 0, 0, 0.5)"));
892    }
893
894    #[tokio::test]
895    async fn parse_css_document_ignores_literal_colors_inside_var_calls() {
896        let manager = CssVariableManager::new(Config::default());
897        let uri = Uri::from_str("file:///test.css").unwrap();
898        let text = r#"
899            .button {
900                color: var(--primary, #fff);
901                background: linear-gradient(var(--from, red), blue);
902            }
903        "#;
904
905        parse_css_document(text, &uri, &manager).await.unwrap();
906
907        let occurrences = manager.get_document_literal_colors(&uri).await;
908        let literals: HashSet<String> = occurrences.into_iter().map(|occ| occ.text).collect();
909        assert!(!literals.contains("#fff"));
910        assert!(!literals.contains("red"));
911        assert!(literals.contains("blue"));
912    }
913}
914
915#[cfg(test)]
916mod edge_case_tests {
917    use super::*;
918    use crate::types::Config;
919    use ls_types::Uri;
920    use std::str::FromStr;
921
922    #[tokio::test]
923    async fn test_parse_empty_css() {
924        let manager = CssVariableManager::new(Config::default());
925        let uri = Uri::from_str("file:///empty.css").unwrap();
926
927        let result = parse_css_document("", &uri, &manager).await;
928        assert!(result.is_ok());
929    }
930
931    #[tokio::test]
932    async fn test_parse_css_with_comments() {
933        let manager = CssVariableManager::new(Config::default());
934        let uri = Uri::from_str("file:///test.css").unwrap();
935
936        let css = r#"
937            /* Comment before */
938            :root {
939                /* Inline comment */
940                --primary: blue; /* End comment */
941                --secondary: red;
942            }
943            /* Comment after */
944        "#;
945
946        let result = parse_css_document(css, &uri, &manager).await;
947        assert!(result.is_ok());
948
949        let vars = manager.get_all_variables().await;
950        assert_eq!(vars.len(), 2);
951    }
952
953    #[tokio::test]
954    async fn test_parse_css_with_important() {
955        let manager = CssVariableManager::new(Config::default());
956        let uri = Uri::from_str("file:///test.css").unwrap();
957
958        let css = r#"
959            :root {
960                --color: red !important;
961                --spacing: 1rem;
962            }
963        "#;
964
965        parse_css_document(css, &uri, &manager).await.unwrap();
966
967        let vars = manager.get_variables("--color").await;
968        assert_eq!(vars.len(), 1);
969        assert!(vars[0].important);
970
971        let spacing = manager.get_variables("--spacing").await;
972        assert!(!spacing[0].important);
973    }
974
975    #[tokio::test]
976    async fn test_parse_css_var_with_fallback() {
977        let manager = CssVariableManager::new(Config::default());
978        let uri = Uri::from_str("file:///test.css").unwrap();
979
980        let css = r#"
981            .button {
982                color: var(--primary, blue);
983                background: var(--bg, var(--fallback, #fff));
984            }
985        "#;
986
987        parse_css_document(css, &uri, &manager).await.unwrap();
988
989        let primary_usages = manager.get_usages("--primary").await;
990        assert_eq!(primary_usages.len(), 1);
991        // Fallback values are parsed but not stored in the usage struct
992
993        let bg_usages = manager.get_usages("--bg").await;
994        assert_eq!(bg_usages.len(), 1);
995    }
996
997    #[tokio::test]
998    async fn test_parse_css_complex_selectors() {
999        let manager = CssVariableManager::new(Config::default());
1000        let uri = Uri::from_str("file:///test.css").unwrap();
1001
1002        let css = r#"
1003            #id .class > div[data-attr="value"]:hover::before {
1004                --complex: value;
1005            }
1006            
1007            @media (min-width: 768px) {
1008                .responsive {
1009                    --media: query;
1010                }
1011            }
1012        "#;
1013
1014        parse_css_document(css, &uri, &manager).await.unwrap();
1015
1016        let vars = manager.get_all_variables().await;
1017        assert!(vars.len() >= 2);
1018    }
1019
1020    #[tokio::test]
1021    async fn test_parse_css_multiline_values() {
1022        let manager = CssVariableManager::new(Config::default());
1023        let uri = Uri::from_str("file:///test.css").unwrap();
1024
1025        let css = r#"
1026            :root {
1027                --gradient: linear-gradient(
1028                    to bottom,
1029                    red,
1030                    blue
1031                );
1032            }
1033        "#;
1034
1035        parse_css_document(css, &uri, &manager).await.unwrap();
1036
1037        let vars = manager.get_variables("--gradient").await;
1038        assert_eq!(vars.len(), 1);
1039        assert!(vars[0].value.contains("linear-gradient"));
1040    }
1041
1042    #[tokio::test]
1043    async fn test_parse_css_variable_names_with_dashes() {
1044        let manager = CssVariableManager::new(Config::default());
1045        let uri = Uri::from_str("file:///test.css").unwrap();
1046
1047        let css = r#"
1048            :root {
1049                --primary-color: blue;
1050                --bg-color-dark: #333;
1051                --font-size-xl: 2rem;
1052            }
1053        "#;
1054
1055        parse_css_document(css, &uri, &manager).await.unwrap();
1056
1057        let vars = manager.get_all_variables().await;
1058        assert_eq!(vars.len(), 3);
1059        assert!(vars.iter().any(|v| v.name == "--primary-color"));
1060        assert!(vars.iter().any(|v| v.name == "--bg-color-dark"));
1061        assert!(vars.iter().any(|v| v.name == "--font-size-xl"));
1062    }
1063
1064    #[tokio::test]
1065    async fn test_parse_css_special_characters_in_values() {
1066        let manager = CssVariableManager::new(Config::default());
1067        let uri = Uri::from_str("file:///test.css").unwrap();
1068
1069        let css = r#"
1070            :root {
1071                --shadow: 0 2px 4px rgba(0,0,0,0.1);
1072                --calc: calc(100% - 20px);
1073                --url: url("https://example.com/image.jpg");
1074                --content: "Hello, World!";
1075            }
1076        "#;
1077
1078        parse_css_document(css, &uri, &manager).await.unwrap();
1079
1080        let vars = manager.get_all_variables().await;
1081        assert_eq!(vars.len(), 4);
1082    }
1083
1084    #[tokio::test]
1085    async fn test_parse_css_nested_var_calls() {
1086        let manager = CssVariableManager::new(Config::default());
1087        let uri = Uri::from_str("file:///test.css").unwrap();
1088
1089        let css = r#"
1090            .element {
1091                color: var(--primary);
1092                background: var(--bg);
1093                border: 1px solid var(--border-color);
1094            }
1095        "#;
1096
1097        parse_css_document(css, &uri, &manager).await.unwrap();
1098
1099        assert_eq!(manager.get_usages("--primary").await.len(), 1);
1100        assert_eq!(manager.get_usages("--bg").await.len(), 1);
1101        assert_eq!(manager.get_usages("--border-color").await.len(), 1);
1102    }
1103
1104    #[tokio::test]
1105    async fn test_parse_css_whitespace_variations() {
1106        let manager = CssVariableManager::new(Config::default());
1107        let uri = Uri::from_str("file:///test.css").unwrap();
1108
1109        let css = r#"
1110            :root{--no-space:value;}
1111            :root { --normal-space: value; }
1112            :root  {  --extra-space  :  value  ;  }
1113        "#;
1114
1115        parse_css_document(css, &uri, &manager).await.unwrap();
1116
1117        let vars = manager.get_all_variables().await;
1118        assert_eq!(vars.len(), 3);
1119    }
1120
1121    #[tokio::test]
1122    async fn test_parse_css_malformed_but_parseable() {
1123        let manager = CssVariableManager::new(Config::default());
1124        let uri = Uri::from_str("file:///test.css").unwrap();
1125
1126        // Missing closing brace, but should still parse what it can
1127        let css = r#"
1128            :root {
1129                --valid: blue;
1130        "#;
1131
1132        let result = parse_css_document(css, &uri, &manager).await;
1133        assert!(result.is_ok());
1134    }
1135
1136    /// Bug demonstration: Complex pseudo-selectors are not parsed correctly
1137    ///
1138    /// ISSUE: The extract_last_selector function may have issues with:
1139    /// - Complex pseudo-selectors like :nth-child(2n+1)
1140    /// - Attribute selectors with complex values
1141    /// - Nested parentheses
1142    ///
1143    /// EXPECTED TO FAIL: This test proves edge cases are not handled.
1144    /// After fix: Complex selectors should be extracted correctly.
1145    #[test]
1146    fn test_extract_last_selector_complex_pseudo() {
1147        use crate::specificity::calculate_specificity;
1148
1149        let test_cases = vec![
1150            // (input, expected selector that should be present)
1151            (":root", "root"),
1152            (":host", "host"),
1153            (".class", "class"),
1154            ("#id", "id"),
1155            ("div.class", "div.class"),
1156            ("div::before", "div::before"),
1157            // Complex pseudo-selectors that may fail
1158            (":nth-child(2n)", "nth-child"),
1159            (":nth-child(2n+1)", "nth-child"),
1160            (":nth-child(odd)", "nth-child"),
1161            (":nth-child(3n-1)", "nth-child"),
1162            (":nth-of-type(2n)", "nth-of-type"),
1163            (":not(.hidden)", "not"),
1164            (":is(div, span)", "is"),
1165            (":where(.theme)", "where"),
1166            (":has(+ div)", "has"),
1167            (":first-letter", "first-letter"),
1168            (":first-line", "first-line"),
1169            (":placeholder-shown", "placeholder-shown"),
1170            (":focus-visible", "focus-visible"),
1171            (":focus-within", "focus-within"),
1172            // Complex attribute selectors
1173            ("[data-value^=\"test\"]", "data-value"),
1174            ("[class~=\"token\"]", "class"),
1175            ("[lang|=\"en\"]", "lang"),
1176        ];
1177
1178        for (input, expected_contains) in test_cases {
1179            // Find selector before a position (simulating cursor at end)
1180            let css = format!("{} {{ color: red; }}", input);
1181            let position = css.len() - 1; // Position after selector
1182
1183            let result = find_selector_before(&css, position, false);
1184
1185            assert!(
1186                result.contains(expected_contains),
1187                "Selector '{}' should contain '{}' (from input: {})",
1188                result,
1189                expected_contains,
1190                input
1191            );
1192
1193            // Also verify specificity calculation doesn't panic
1194            let specificity = calculate_specificity(&result);
1195
1196            // For complex selectors, specificity should still be calculable
1197            let _ = specificity; // verify calculate_specificity doesn't panic
1198        }
1199
1200        // Additional edge case: selector with nested pseudo-classes
1201        let nested = ".container:not(:has(.hidden)):nth-child(2n+1)";
1202        let result = find_selector_before(
1203            &format!("{} {{ color: red; }}", nested),
1204            nested.len() + 5,
1205            false,
1206        );
1207
1208        // BUG: Currently this assertion may FAIL because nested selectors are not handled
1209        // After fix: Should extract the full compound selector
1210        assert!(
1211            result.contains("container") && result.contains("not") && result.contains("nth-child"),
1212            "Nested selector '{}' should contain all parts, got: {}",
1213            nested,
1214            result
1215        );
1216    }
1217}