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
12const MAX_INPUT_SIZE_BYTES: usize = 10 * 1024 * 1024;
14
15const BLOCK_LIST: &[&str] = &[
17 "@font-face",
18 "@property",
19 "@keyframes",
20 "@counter-style",
21 "@font-feature-values",
22 "@scroll-timeline",
23];
24
25fn extract_at_rule_name(bytes: &[u8], start: usize) -> Option<String> {
28 let remaining = &bytes[start + 1..]; 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; }
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; }
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#[inline]
59fn is_ident_char(b: u8) -> bool {
60 b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
61}
62
63fn should_block_variables(at_rule: &str) -> bool {
66 let at_rule_lower = at_rule.to_ascii_lowercase();
67
68 if BLOCK_LIST.iter().any(|name| *name == at_rule_lower) {
70 return true;
71 }
72
73 if at_rule_lower.contains("keyframes") {
76 return true;
77 }
78
79 false
80}
81
82pub 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
94pub async fn parse_css_document(
96 text: &str,
97 uri: &Uri,
98 manager: &CssVariableManager,
99) -> Result<(), String> {
100 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
122pub 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 current_at_rule = extract_at_rule_name(bytes, i);
316 }
317
318 if bytes[i] == b'{' {
319 brace_depth += 1;
320
321 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 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 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 if !selector_stack.is_empty() {
352 selector_stack.pop();
353 }
354 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 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 if bytes[i] == b'{' {
538 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 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 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 if !selector_stack.is_empty() {
565 selector_stack.pop();
566 }
567 if blocking_at_rule.is_some() && brace_depth == 0 {
569 blocking_at_rule = None;
570 }
571 }
572
573 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 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 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 extract_last_selector(effective_block)
987 } else {
988 None
989 }
990}
991
992fn extract_last_selector(selector_block: &str) -> Option<String> {
994 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 last_selector_start = i + 1;
1013 }
1014 _ => {}
1015 }
1016 }
1017
1018 let last_selector = selector_block[last_selector_start..last_selector_end].trim();
1020
1021 let cleaned = last_selector
1023 .split('{')
1024 .next()
1025 .unwrap_or(last_selector)
1026 .trim();
1027
1028 let selector = if cleaned.starts_with('@') {
1030 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 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 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 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 #[test]
1440 fn test_extract_last_selector_complex_pseudo() {
1441 use crate::specificity::calculate_specificity;
1442
1443 let test_cases = vec![
1444 (":root", "root"),
1446 (":host", "host"),
1447 (".class", "class"),
1448 ("#id", "id"),
1449 ("div.class", "div.class"),
1450 ("div::before", "div::before"),
1451 (":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 ("[data-value^=\"test\"]", "data-value"),
1468 ("[class~=\"token\"]", "class"),
1469 ("[lang|=\"en\"]", "lang"),
1470 ];
1471
1472 for (input, expected_contains) in test_cases {
1473 let css = format!("{} {{ color: red; }}", input);
1475 let position = css.len() - 1; 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 let specificity = calculate_specificity(&result);
1490
1491 let _ = specificity; }
1494
1495 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 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}