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 = 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 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 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 extract_last_selector(effective_block)
990 } else {
991 None
992 }
993}
994
995fn extract_last_selector(selector_block: &str) -> Option<String> {
997 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 last_selector_start = i + 1;
1016 }
1017 _ => {}
1018 }
1019 }
1020
1021 let last_selector = selector_block[last_selector_start..last_selector_end].trim();
1023
1024 let cleaned = last_selector
1026 .split('{')
1027 .next()
1028 .unwrap_or(last_selector)
1029 .trim();
1030
1031 let selector = if cleaned.starts_with('@') {
1033 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 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 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 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 #[test]
1456 fn test_extract_last_selector_complex_pseudo() {
1457 use crate::specificity::calculate_specificity;
1458
1459 let test_cases = vec![
1460 (":root", "root"),
1462 (":host", "host"),
1463 (".class", "class"),
1464 ("#id", "id"),
1465 ("div.class", "div.class"),
1466 ("div::before", "div::before"),
1467 (":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 ("[data-value^=\"test\"]", "data-value"),
1484 ("[class~=\"token\"]", "class"),
1485 ("[lang|=\"en\"]", "lang"),
1486 ];
1487
1488 for (input, expected_contains) in test_cases {
1489 let css = format!("{} {{ color: red; }}", input);
1491 let position = css.len() - 1; 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 let specificity = calculate_specificity(&result);
1506
1507 let _ = specificity; }
1510
1511 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 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}