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