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