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            // Continue through the arguments so nested var() calls used as fallbacks are
686            // indexed as usages too. Starting after the opening parenthesis avoids
687            // re-indexing the outer call while retaining the normal comment/string guards.
688            i = args_start;
689            continue;
690        }
691
692        i += 1;
693    }
694}
695
696fn is_var_function(bytes: &[u8], idx: usize) -> bool {
697    if idx + 2 >= bytes.len() {
698        return false;
699    }
700    if !bytes[idx].eq_ignore_ascii_case(&b'v')
701        || !bytes[idx + 1].eq_ignore_ascii_case(&b'a')
702        || !bytes[idx + 2].eq_ignore_ascii_case(&b'r')
703    {
704        return false;
705    }
706    if idx > 0 && is_ident_char(bytes[idx - 1]) {
707        return false;
708    }
709    true
710}
711
712fn has_non_whitespace_outside_comments(segment: &str) -> bool {
713    let bytes = segment.as_bytes();
714    let mut i = 0usize;
715    let mut in_comment = false;
716    let mut in_string: Option<u8> = None;
717
718    while i < bytes.len() {
719        if in_comment {
720            if i + 1 < bytes.len() && bytes[i] == b'*' && bytes[i + 1] == b'/' {
721                in_comment = false;
722                i += 2;
723                continue;
724            }
725            i += 1;
726            continue;
727        }
728
729        if let Some(quote) = in_string {
730            if bytes[i] == b'\\' {
731                i = i.saturating_add(2);
732                continue;
733            }
734            if bytes[i] == quote {
735                in_string = None;
736            }
737            i += 1;
738            continue;
739        }
740
741        if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
742            in_comment = true;
743            i += 2;
744            continue;
745        }
746
747        if bytes[i] == b'"' || bytes[i] == b'\'' {
748            in_string = Some(bytes[i]);
749            i += 1;
750            continue;
751        }
752
753        if !bytes[i].is_ascii_whitespace() {
754            return true;
755        }
756
757        i += 1;
758    }
759
760    false
761}
762
763fn extract_literal_colors_from_value(
764    value: &str,
765) -> Vec<(usize, usize, crate::color::NormalizedColorKey)> {
766    let bytes = value.as_bytes();
767    let ignored_ranges = find_ignored_var_ranges(value);
768    let mut colors = Vec::new();
769    let mut i = 0usize;
770    let mut ignored_idx = 0usize;
771    let mut in_string: Option<u8> = None;
772
773    while i < bytes.len() {
774        while ignored_idx < ignored_ranges.len() && i >= ignored_ranges[ignored_idx].1 {
775            ignored_idx += 1;
776        }
777        if ignored_idx < ignored_ranges.len() && i >= ignored_ranges[ignored_idx].0 {
778            i = ignored_ranges[ignored_idx].1;
779            continue;
780        }
781
782        if let Some(quote) = in_string {
783            if bytes[i] == b'\\' {
784                i = i.saturating_add(2);
785                continue;
786            }
787            if bytes[i] == quote {
788                in_string = None;
789            }
790            i += 1;
791            continue;
792        }
793
794        if bytes[i] == b'"' || bytes[i] == b'\'' {
795            in_string = Some(bytes[i]);
796            i += 1;
797            continue;
798        }
799
800        if bytes[i] == b'#' {
801            let mut end = i + 1;
802            while end < bytes.len() && bytes[end].is_ascii_hexdigit() {
803                end += 1;
804            }
805            let len = end - i;
806            if matches!(len, 3..=9) {
807                if let Some(color) = normalized_color_key(&value[i..end]) {
808                    colors.push((i, end, color));
809                }
810            }
811            i = end;
812            continue;
813        }
814
815        if bytes[i].is_ascii_alphabetic() {
816            let start = i;
817            let mut end = i + 1;
818            while end < bytes.len() && is_ident_char(bytes[end]) {
819                end += 1;
820            }
821
822            let mut j = end;
823            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
824                j += 1;
825            }
826
827            if j < bytes.len() && bytes[j] == b'(' {
828                let ident = value[start..end].to_ascii_lowercase();
829                if matches!(ident.as_str(), "rgb" | "rgba" | "hsl" | "hsla") {
830                    if let Some(func_end) = find_balanced_call_end(value, j) {
831                        if let Some(color) = normalized_color_key(&value[start..func_end]) {
832                            colors.push((start, func_end, color));
833                        }
834                        i = func_end;
835                        continue;
836                    }
837                }
838            } else if let Some(color) = normalized_color_key(&value[start..end]) {
839                colors.push((start, end, color));
840            }
841
842            i = end;
843            continue;
844        }
845
846        i += 1;
847    }
848
849    colors
850}
851
852fn find_ignored_var_ranges(value: &str) -> Vec<(usize, usize)> {
853    let bytes = value.as_bytes();
854    let mut ranges = Vec::new();
855    let mut i = 0usize;
856    let mut in_string: Option<u8> = None;
857
858    while i < bytes.len() {
859        if let Some(quote) = in_string {
860            if bytes[i] == b'\\' {
861                i = i.saturating_add(2);
862                continue;
863            }
864            if bytes[i] == quote {
865                in_string = None;
866            }
867            i += 1;
868            continue;
869        }
870
871        if bytes[i] == b'"' || bytes[i] == b'\'' {
872            in_string = Some(bytes[i]);
873            i += 1;
874            continue;
875        }
876
877        if is_var_function(bytes, i) {
878            let mut j = i + 3;
879            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
880                j += 1;
881            }
882            if j < bytes.len() && bytes[j] == b'(' {
883                if let Some(end) = find_balanced_call_end(value, j) {
884                    ranges.push((i, end));
885                    i = end;
886                    continue;
887                }
888            }
889        }
890
891        i += 1;
892    }
893
894    ranges
895}
896
897fn find_balanced_call_end(value: &str, open_paren_idx: usize) -> Option<usize> {
898    let bytes = value.as_bytes();
899    let mut depth = 0i32;
900    let mut i = open_paren_idx;
901    let mut in_string: Option<u8> = None;
902
903    while i < bytes.len() {
904        let b = bytes[i];
905        if let Some(q) = in_string {
906            if b == b'\\' {
907                i = i.saturating_add(2);
908                continue;
909            }
910            if b == q {
911                in_string = None;
912            }
913            i += 1;
914            continue;
915        }
916
917        if b == b'"' || b == b'\'' {
918            in_string = Some(b);
919            i += 1;
920            continue;
921        }
922
923        if b == b'(' {
924            depth += 1;
925        } else if b == b')' {
926            depth -= 1;
927            if depth == 0 {
928                return Some(i + 1);
929            }
930        }
931        i += 1;
932    }
933
934    None
935}
936
937fn resolve_block_selector(
938    text: &str,
939    brace_pos: usize,
940    in_at_rule: bool,
941    parent_selector: Option<&String>,
942) -> String {
943    if in_at_rule {
944        return parent_selector
945            .cloned()
946            .or_else(|| find_selector_before(text, brace_pos, true))
947            .unwrap_or_else(|| UNKNOWN_SELECTOR.to_string());
948    }
949
950    let before = &text[..brace_pos];
951    let start = before
952        .rfind(['{', '}', ';'])
953        .map(|pos| pos + 1)
954        .unwrap_or(0);
955    extract_last_selector(before[start..].trim()).unwrap_or_else(|| UNKNOWN_SELECTOR.to_string())
956}
957
958fn find_selector_before(text: &str, offset: usize, in_at_rule: bool) -> Option<String> {
959    let before = &text[..offset];
960
961    if in_at_rule {
962        // For variables defined in @-rules, find the @-rule context
963        if let Some(at_pos) = before.rfind('@') {
964            let at_rule_end = before[at_pos..]
965                .find('{')
966                .map(|pos| pos + at_pos)
967                .unwrap_or(before.len());
968            let at_rule = before[at_pos..at_rule_end].trim();
969            return Some(format!("@{}", at_rule));
970        }
971        return None;
972    }
973
974    if let Some(brace_pos) = before.rfind('{') {
975        let start = before[..brace_pos].rfind('}').map(|p| p + 1).unwrap_or(0);
976        let selector_block = before[start..brace_pos].trim();
977
978        // If the selector block contains a nested `{` (from an @-rule), the actual
979        // selector lives between the innermost `{` and the outer `{`.
980        // e.g. "@media (min-width: 768px) { .responsive" → ".responsive"
981        let inner_brace = before[start..brace_pos].rfind('{');
982        let effective_block = if let Some(pos) = inner_brace {
983            before[start + pos + 1..brace_pos].trim()
984        } else {
985            selector_block
986        };
987
988        // Handle complex selectors that might span multiple lines or have nested braces
989        extract_last_selector(effective_block)
990    } else {
991        None
992    }
993}
994
995/// Extract the last selector from a selector block, handling complex cases
996fn extract_last_selector(selector_block: &str) -> Option<String> {
997    // Find the last complete selector by tracking balanced parentheses and commas
998    let bytes = selector_block.as_bytes();
999    let len = bytes.len();
1000    let mut paren_depth: usize = 0;
1001    let mut last_selector_start = 0;
1002    let last_selector_end = len;
1003
1004    for (i, &b) in bytes.iter().enumerate() {
1005        match b {
1006            b'(' => {
1007                paren_depth += 1;
1008            }
1009            b')' => {
1010                paren_depth = paren_depth.saturating_sub(1);
1011            }
1012            b',' if paren_depth == 0 => {
1013                // This is a selector list separator
1014                // The next character (if any) starts a new selector
1015                last_selector_start = i + 1;
1016            }
1017            _ => {}
1018        }
1019    }
1020
1021    // Extract the last selector
1022    let last_selector = selector_block[last_selector_start..last_selector_end].trim();
1023
1024    // Clean up the selector - remove any trailing braces or CSS at-rules
1025    let cleaned = last_selector
1026        .split('{')
1027        .next()
1028        .unwrap_or(last_selector)
1029        .trim();
1030
1031    // Handle CSS at-rules by finding the actual selector part
1032    let selector = if cleaned.starts_with('@') {
1033        // This is an at-rule like @media, find the selector inside
1034        if let Some(open_brace) = cleaned.find('{') {
1035            cleaned[..open_brace].trim().to_string()
1036        } else {
1037            cleaned.to_string()
1038        }
1039    } else {
1040        cleaned.to_string()
1041    };
1042
1043    if selector.is_empty() {
1044        None
1045    } else {
1046        Some(selector)
1047    }
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052    use super::*;
1053    use crate::manager::CssVariableManager;
1054    use crate::types::Config;
1055    use std::collections::HashSet;
1056    use std::str::FromStr;
1057
1058    #[tokio::test]
1059    async fn parse_css_document_extracts_definitions_and_usages() {
1060        let manager = CssVariableManager::new(Config::default());
1061        let uri = Uri::from_str("file:///test.css").unwrap();
1062        let text = ":root { --primary: #fff; color: var(--primary); } \
1063                    .button { --secondary: var(--primary, #000); }";
1064
1065        parse_css_document(text, &uri, &manager).await.unwrap();
1066
1067        let primary_defs = manager.get_variables("--primary").await;
1068        assert_eq!(primary_defs.len(), 1);
1069        assert_eq!(primary_defs[0].value, "#fff");
1070
1071        let secondary_defs = manager.get_variables("--secondary").await;
1072        assert_eq!(secondary_defs.len(), 1);
1073        assert_eq!(secondary_defs[0].value, "var(--primary, #000)");
1074
1075        let usages = manager.get_usages("--primary").await;
1076        assert_eq!(usages.len(), 2);
1077
1078        let contexts: HashSet<String> = usages.into_iter().map(|u| u.usage_context).collect();
1079        assert!(contexts.contains(":root"));
1080        assert!(contexts.contains(".button"));
1081    }
1082
1083    #[tokio::test]
1084    async fn parse_css_document_indexes_nested_var_fallback_usages() {
1085        let manager = CssVariableManager::new(Config::default());
1086        let uri = Uri::from_str("file:///test.css").unwrap();
1087        let text = ".button { color: var(--primary, var(--fallback, var(--deep))); }";
1088
1089        parse_css_document(text, &uri, &manager).await.unwrap();
1090
1091        let primary_usages = manager.get_usages("--primary").await;
1092        assert_eq!(primary_usages.len(), 1);
1093
1094        let fallback_usages = manager.get_usages("--fallback").await;
1095        assert_eq!(fallback_usages.len(), 1);
1096        let fallback_start = text.find("var(--fallback").unwrap();
1097        assert_eq!(
1098            fallback_usages[0].range.start,
1099            offset_to_position(text, fallback_start),
1100        );
1101
1102        let deep_usages = manager.get_usages("--deep").await;
1103        assert_eq!(deep_usages.len(), 1);
1104        let deep_name_start = text.find("--deep").unwrap();
1105        assert_eq!(
1106            deep_usages[0].name_range.unwrap().start,
1107            offset_to_position(text, deep_name_start),
1108        );
1109    }
1110
1111    #[tokio::test]
1112    async fn parse_css_document_extracts_literal_colors_in_compound_values() {
1113        let manager = CssVariableManager::new(Config::default());
1114        let uri = Uri::from_str("file:///test.css").unwrap();
1115        let text = r#"
1116            .button {
1117                color: #fff;
1118                background: linear-gradient(red, rgb(255 255 255));
1119                box-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
1120            }
1121        "#;
1122
1123        parse_css_document(text, &uri, &manager).await.unwrap();
1124
1125        let occurrences = manager.get_document_literal_colors(&uri).await;
1126        let literals: HashSet<String> = occurrences.into_iter().map(|occ| occ.text).collect();
1127        assert!(literals.contains("#fff"));
1128        assert!(literals.contains("red"));
1129        assert!(literals.contains("rgb(255 255 255)"));
1130        assert!(literals.contains("rgba(0, 0, 0, 0.5)"));
1131    }
1132
1133    #[tokio::test]
1134    async fn parse_css_document_ignores_literal_colors_inside_var_calls() {
1135        let manager = CssVariableManager::new(Config::default());
1136        let uri = Uri::from_str("file:///test.css").unwrap();
1137        let text = r#"
1138            .button {
1139                color: var(--primary, #fff);
1140                background: linear-gradient(var(--from, red), blue);
1141            }
1142        "#;
1143
1144        parse_css_document(text, &uri, &manager).await.unwrap();
1145
1146        let occurrences = manager.get_document_literal_colors(&uri).await;
1147        let literals: HashSet<String> = occurrences.into_iter().map(|occ| occ.text).collect();
1148        assert!(!literals.contains("#fff"));
1149        assert!(!literals.contains("red"));
1150        assert!(literals.contains("blue"));
1151    }
1152}
1153
1154#[cfg(test)]
1155mod edge_case_tests {
1156    use super::*;
1157    use crate::types::Config;
1158    use ls_types::Uri;
1159    use std::str::FromStr;
1160
1161    #[tokio::test]
1162    async fn test_parse_empty_css() {
1163        let manager = CssVariableManager::new(Config::default());
1164        let uri = Uri::from_str("file:///empty.css").unwrap();
1165
1166        let result = parse_css_document("", &uri, &manager).await;
1167        assert!(result.is_ok());
1168    }
1169
1170    #[tokio::test]
1171    async fn test_parse_css_with_comments() {
1172        let manager = CssVariableManager::new(Config::default());
1173        let uri = Uri::from_str("file:///test.css").unwrap();
1174
1175        let css = r#"
1176            /* Comment before */
1177            :root {
1178                /* Inline comment */
1179                --primary: blue; /* End comment */
1180                --secondary: red;
1181            }
1182            /* Comment after */
1183        "#;
1184
1185        let result = parse_css_document(css, &uri, &manager).await;
1186        assert!(result.is_ok());
1187
1188        let vars = manager.get_all_variables().await;
1189        assert_eq!(vars.len(), 2);
1190    }
1191
1192    #[tokio::test]
1193    async fn test_parse_css_with_important() {
1194        let manager = CssVariableManager::new(Config::default());
1195        let uri = Uri::from_str("file:///test.css").unwrap();
1196
1197        let css = r#"
1198            :root {
1199                --color: red !important;
1200                --spacing: 1rem;
1201            }
1202        "#;
1203
1204        parse_css_document(css, &uri, &manager).await.unwrap();
1205
1206        let vars = manager.get_variables("--color").await;
1207        assert_eq!(vars.len(), 1);
1208        assert!(vars[0].important);
1209
1210        let spacing = manager.get_variables("--spacing").await;
1211        assert!(!spacing[0].important);
1212    }
1213
1214    #[tokio::test]
1215    async fn test_parse_css_var_with_fallback() {
1216        let manager = CssVariableManager::new(Config::default());
1217        let uri = Uri::from_str("file:///test.css").unwrap();
1218
1219        let css = r#"
1220            .button {
1221                color: var(--primary, blue);
1222                background: var(--bg, var(--fallback, #fff));
1223            }
1224        "#;
1225
1226        parse_css_document(css, &uri, &manager).await.unwrap();
1227
1228        let primary_usages = manager.get_usages("--primary").await;
1229        assert_eq!(primary_usages.len(), 1);
1230        // Fallback values are parsed but not stored in the usage struct
1231
1232        let bg_usages = manager.get_usages("--bg").await;
1233        assert_eq!(bg_usages.len(), 1);
1234    }
1235
1236    #[tokio::test]
1237    async fn test_parse_css_complex_selectors() {
1238        let manager = CssVariableManager::new(Config::default());
1239        let uri = Uri::from_str("file:///test.css").unwrap();
1240
1241        let css = r#"
1242            #id .class > div[data-attr="value"]:hover::before {
1243                --complex: value;
1244            }
1245            
1246            @media (min-width: 768px) {
1247                .responsive {
1248                    --media: query;
1249                }
1250            }
1251        "#;
1252
1253        parse_css_document(css, &uri, &manager).await.unwrap();
1254
1255        let vars = manager.get_all_variables().await;
1256        assert!(vars.len() >= 2);
1257    }
1258
1259    #[tokio::test]
1260    async fn test_parse_css_multiline_values() {
1261        let manager = CssVariableManager::new(Config::default());
1262        let uri = Uri::from_str("file:///test.css").unwrap();
1263
1264        let css = r#"
1265            :root {
1266                --gradient: linear-gradient(
1267                    to bottom,
1268                    red,
1269                    blue
1270                );
1271            }
1272        "#;
1273
1274        parse_css_document(css, &uri, &manager).await.unwrap();
1275
1276        let vars = manager.get_variables("--gradient").await;
1277        assert_eq!(vars.len(), 1);
1278        assert!(vars[0].value.contains("linear-gradient"));
1279    }
1280
1281    #[tokio::test]
1282    async fn test_parse_css_variable_names_with_dashes() {
1283        let manager = CssVariableManager::new(Config::default());
1284        let uri = Uri::from_str("file:///test.css").unwrap();
1285
1286        let css = r#"
1287            :root {
1288                --primary-color: blue;
1289                --bg-color-dark: #333;
1290                --font-size-xl: 2rem;
1291            }
1292        "#;
1293
1294        parse_css_document(css, &uri, &manager).await.unwrap();
1295
1296        let vars = manager.get_all_variables().await;
1297        assert_eq!(vars.len(), 3);
1298        assert!(vars.iter().any(|v| v.name == "--primary-color"));
1299        assert!(vars.iter().any(|v| v.name == "--bg-color-dark"));
1300        assert!(vars.iter().any(|v| v.name == "--font-size-xl"));
1301    }
1302
1303    #[tokio::test]
1304    async fn test_parse_css_special_characters_in_values() {
1305        let manager = CssVariableManager::new(Config::default());
1306        let uri = Uri::from_str("file:///test.css").unwrap();
1307
1308        let css = r#"
1309            :root {
1310                --shadow: 0 2px 4px rgba(0,0,0,0.1);
1311                --calc: calc(100% - 20px);
1312                --url: url("https://example.com/image.jpg");
1313                --content: "Hello, World!";
1314            }
1315        "#;
1316
1317        parse_css_document(css, &uri, &manager).await.unwrap();
1318
1319        let vars = manager.get_all_variables().await;
1320        assert_eq!(vars.len(), 4);
1321    }
1322
1323    #[tokio::test]
1324    async fn test_parse_css_nested_var_calls() {
1325        let manager = CssVariableManager::new(Config::default());
1326        let uri = Uri::from_str("file:///test.css").unwrap();
1327
1328        let css = r#"
1329            .element {
1330                color: var(--primary);
1331                background: var(--bg);
1332                border: 1px solid var(--border-color);
1333            }
1334        "#;
1335
1336        parse_css_document(css, &uri, &manager).await.unwrap();
1337
1338        assert_eq!(manager.get_usages("--primary").await.len(), 1);
1339        assert_eq!(manager.get_usages("--bg").await.len(), 1);
1340        assert_eq!(manager.get_usages("--border-color").await.len(), 1);
1341    }
1342
1343    #[tokio::test]
1344    async fn test_parse_css_whitespace_variations() {
1345        let manager = CssVariableManager::new(Config::default());
1346        let uri = Uri::from_str("file:///test.css").unwrap();
1347
1348        let css = r#"
1349            :root{--no-space:value;}
1350            :root { --normal-space: value; }
1351            :root  {  --extra-space  :  value  ;  }
1352        "#;
1353
1354        parse_css_document(css, &uri, &manager).await.unwrap();
1355
1356        let vars = manager.get_all_variables().await;
1357        assert_eq!(vars.len(), 3);
1358    }
1359
1360    #[tokio::test]
1361    async fn test_parse_css_variables_after_nested_media_inside_root() {
1362        let manager = CssVariableManager::new(Config::default());
1363        let uri = Uri::from_str("file:///test.css").unwrap();
1364
1365        let css = r#"
1366            :root {
1367                --before: blue;
1368
1369                @media (prefers-color-scheme: dark) {
1370                    --during: red;
1371                }
1372
1373                --after: green;
1374            }
1375        "#;
1376
1377        parse_css_document(css, &uri, &manager).await.unwrap();
1378
1379        let before = manager.get_variables("--before").await;
1380        let during = manager.get_variables("--during").await;
1381        let after = manager.get_variables("--after").await;
1382
1383        assert_eq!(before.len(), 1);
1384        assert_eq!(during.len(), 1);
1385        assert_eq!(after.len(), 1);
1386        assert_eq!(before[0].selector, ":root");
1387        assert_eq!(during[0].selector, ":root");
1388        assert_eq!(after[0].selector, ":root");
1389    }
1390
1391    #[tokio::test]
1392    async fn test_parse_css_malformed_but_parseable() {
1393        let manager = CssVariableManager::new(Config::default());
1394        let uri = Uri::from_str("file:///test.css").unwrap();
1395
1396        // Missing closing brace, but should still parse what it can
1397        let css = r#"
1398            :root {
1399                --valid: blue;
1400        "#;
1401
1402        let result = parse_css_document(css, &uri, &manager).await;
1403        assert!(result.is_ok());
1404    }
1405
1406    #[test]
1407    fn test_find_selector_in_at_rule_block() {
1408        // Bug: @-rule prelude is returned instead of the actual selector
1409        let css = "@media (min-width: 768px) { .responsive { color: var(--x); } }";
1410        let var_pos = css.find("var").unwrap();
1411        let result = find_selector_before(css, var_pos, false);
1412        assert_eq!(
1413            result,
1414            Some(".responsive".to_string()),
1415            "Expected selector '.responsive' inside @media block, got: '{}'",
1416            result.as_deref().unwrap_or("<none>")
1417        );
1418    }
1419
1420    #[test]
1421    fn test_find_selector_deeply_nested_at_rule() {
1422        let css = "@media (min-width: 768px) { @supports (display: grid) { .grid-item { color: var(--x); } } }";
1423        let var_pos = css.find("var").unwrap();
1424        let result = find_selector_before(css, var_pos, false);
1425        assert_eq!(
1426            result,
1427            Some(".grid-item".to_string()),
1428            "Expected selector '.grid-item' inside nested @-rules, got: '{}'",
1429            result.as_deref().unwrap_or("<none>")
1430        );
1431    }
1432
1433    #[test]
1434    fn test_find_selector_definition_in_at_rule() {
1435        let css = "@media (min-width: 768px) { .responsive { --responsive: value; } }";
1436        let decl_pos = css.find("--responsive").unwrap();
1437        let result = find_selector_before(css, decl_pos, false);
1438        assert_eq!(
1439            result,
1440            Some(".responsive".to_string()),
1441            "Expected selector '.responsive' for definition inside @media, got: '{}'",
1442            result.as_deref().unwrap_or("<none>")
1443        );
1444    }
1445
1446    /// Bug demonstration: Complex pseudo-selectors are not parsed correctly
1447    ///
1448    /// ISSUE: The extract_last_selector function may have issues with:
1449    /// - Complex pseudo-selectors like :nth-child(2n+1)
1450    /// - Attribute selectors with complex values
1451    /// - Nested parentheses
1452    ///
1453    /// EXPECTED TO FAIL: This test proves edge cases are not handled.
1454    /// After fix: Complex selectors should be extracted correctly.
1455    #[test]
1456    fn test_extract_last_selector_complex_pseudo() {
1457        use crate::specificity::calculate_specificity;
1458
1459        let test_cases = vec![
1460            // (input, expected selector that should be present)
1461            (":root", "root"),
1462            (":host", "host"),
1463            (".class", "class"),
1464            ("#id", "id"),
1465            ("div.class", "div.class"),
1466            ("div::before", "div::before"),
1467            // Complex pseudo-selectors that may fail
1468            (":nth-child(2n)", "nth-child"),
1469            (":nth-child(2n+1)", "nth-child"),
1470            (":nth-child(odd)", "nth-child"),
1471            (":nth-child(3n-1)", "nth-child"),
1472            (":nth-of-type(2n)", "nth-of-type"),
1473            (":not(.hidden)", "not"),
1474            (":is(div, span)", "is"),
1475            (":where(.theme)", "where"),
1476            (":has(+ div)", "has"),
1477            (":first-letter", "first-letter"),
1478            (":first-line", "first-line"),
1479            (":placeholder-shown", "placeholder-shown"),
1480            (":focus-visible", "focus-visible"),
1481            (":focus-within", "focus-within"),
1482            // Complex attribute selectors
1483            ("[data-value^=\"test\"]", "data-value"),
1484            ("[class~=\"token\"]", "class"),
1485            ("[lang|=\"en\"]", "lang"),
1486        ];
1487
1488        for (input, expected_contains) in test_cases {
1489            // Find selector before a position (simulating cursor at end)
1490            let css = format!("{} {{ color: red; }}", input);
1491            let position = css.len() - 1; // Position after selector
1492
1493            let result = find_selector_before(&css, position, false);
1494            let result = result.expect("selector should be present");
1495
1496            assert!(
1497                result.contains(expected_contains),
1498                "Selector '{}' should contain '{}' (from input: {})",
1499                result,
1500                expected_contains,
1501                input
1502            );
1503
1504            // Also verify specificity calculation doesn't panic
1505            let specificity = calculate_specificity(&result);
1506
1507            // For complex selectors, specificity should still be calculable
1508            let _ = specificity; // verify calculate_specificity doesn't panic
1509        }
1510
1511        // Additional edge case: selector with nested pseudo-classes
1512        let nested = ".container:not(:has(.hidden)):nth-child(2n+1)";
1513        let result = find_selector_before(
1514            &format!("{} {{ color: red; }}", nested),
1515            nested.len() + 5,
1516            false,
1517        )
1518        .expect("selector should be present");
1519
1520        // BUG: Currently this assertion may FAIL because nested selectors are not handled
1521        // After fix: Should extract the full compound selector
1522        assert!(
1523            result.contains("container") && result.contains("not") && result.contains("nth-child"),
1524            "Nested selector '{}' should contain all parts, got: {}",
1525            nested,
1526            result
1527        );
1528    }
1529
1530    #[test]
1531    fn test_find_selector_before_returns_none_without_selector_context() {
1532        assert_eq!(find_selector_before("--x: red;", 4, false), None);
1533    }
1534}