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