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