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