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