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