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