css_variable_lsp/parsers/
css.rs1use ls_types::{Range, Uri};
2
3use crate::manager::CssVariableManager;
4use crate::types::{offset_to_position, CssVariable, CssVariableUsage, DOMNodeInfo};
5
6pub struct CssParseContext<'a> {
8 pub css_text: &'a str,
9 pub full_text: &'a str,
10 pub uri: &'a Uri,
11 pub manager: &'a CssVariableManager,
12 pub base_offset: usize,
13 pub inline: bool,
14 pub usage_context_override: Option<&'a str>,
15 pub dom_node: Option<DOMNodeInfo>,
16}
17
18pub async fn parse_css_document(
20 text: &str,
21 uri: &Uri,
22 manager: &CssVariableManager,
23) -> Result<(), String> {
24 let context = CssParseContext {
25 css_text: text,
26 full_text: text,
27 uri,
28 manager,
29 base_offset: 0,
30 inline: false,
31 usage_context_override: None,
32 dom_node: None,
33 };
34 parse_css_snippet(context).await
35}
36
37pub async fn parse_css_snippet(context: CssParseContext<'_>) -> Result<(), String> {
39 extract_definitions(
40 context.css_text,
41 context.full_text,
42 context.uri,
43 context.manager,
44 context.base_offset,
45 context.inline,
46 context.usage_context_override,
47 )
48 .await;
49 extract_usages(
50 context.css_text,
51 context.full_text,
52 context.uri,
53 context.manager,
54 context.base_offset,
55 context.usage_context_override,
56 context.dom_node,
57 )
58 .await;
59 Ok(())
60}
61
62async fn extract_definitions(
63 css_text: &str,
64 full_text: &str,
65 uri: &Uri,
66 manager: &CssVariableManager,
67 base_offset: usize,
68 inline: bool,
69 selector_override: Option<&str>,
70) {
71 let bytes = css_text.as_bytes();
72 let len = bytes.len();
73 let mut i = 0;
74 let mut in_comment = false;
75 let mut in_string: Option<u8> = None;
76 let mut brace_depth = 0;
77 let mut in_at_rule = false;
78
79 while i < len {
80 if in_comment {
81 if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'/' {
82 in_comment = false;
83 i += 2;
84 continue;
85 }
86 i += 1;
87 continue;
88 }
89
90 if let Some(quote) = in_string {
91 if bytes[i] == b'\\' {
92 i += 2;
93 continue;
94 }
95 if bytes[i] == quote {
96 in_string = None;
97 }
98 i += 1;
99 continue;
100 }
101
102 if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
103 in_comment = true;
104 i += 2;
105 continue;
106 }
107
108 if bytes[i] == b'"' || bytes[i] == b'\'' {
109 in_string = Some(bytes[i]);
110 i += 1;
111 continue;
112 }
113
114 if bytes[i] == b'{' {
116 brace_depth += 1;
117 } else if bytes[i] == b'}' {
118 brace_depth -= 1;
119 if brace_depth < 0 {
120 brace_depth = 0;
121 }
122 }
123
124 if bytes[i] == b'@' && !in_comment && in_string.is_none() {
126 in_at_rule = true;
127 } else if bytes[i] == b'{' && in_at_rule {
128 in_at_rule = false;
129 }
130
131 if bytes[i] == b'-' && i + 1 < len && bytes[i + 1] == b'-' {
132 let name_start = i;
133 let mut j = i + 2;
134 while j < len && is_ident_char(bytes[j]) {
135 j += 1;
136 }
137 if j == name_start + 2 {
138 i += 2;
139 continue;
140 }
141
142 let name_end = j;
143 let mut k = j;
144 while k < len && bytes[k].is_ascii_whitespace() {
145 k += 1;
146 }
147 if k >= len || bytes[k] != b':' {
148 i = name_end;
149 continue;
150 }
151
152 let mut value_start = k + 1;
153 while value_start < len && bytes[value_start].is_ascii_whitespace() {
154 value_start += 1;
155 }
156
157 let mut value_end = value_start;
158 let mut depth = 0i32;
159 let mut val_in_comment = false;
160 let mut val_in_string: Option<u8> = None;
161 while value_end < len {
162 let b = bytes[value_end];
163 if val_in_comment {
164 if value_end + 1 < len && b == b'*' && bytes[value_end + 1] == b'/' {
165 val_in_comment = false;
166 value_end += 2;
167 continue;
168 }
169 value_end += 1;
170 continue;
171 }
172 if let Some(q) = val_in_string {
173 if b == b'\\' {
174 value_end += 2;
175 continue;
176 }
177 if b == q {
178 val_in_string = None;
179 }
180 value_end += 1;
181 continue;
182 }
183 if value_end + 1 < len && b == b'/' && bytes[value_end + 1] == b'*' {
184 val_in_comment = true;
185 value_end += 2;
186 continue;
187 }
188 if b == b'"' || b == b'\'' {
189 val_in_string = Some(b);
190 value_end += 1;
191 continue;
192 }
193 if b == b'(' {
194 depth += 1;
195 value_end += 1;
196 continue;
197 }
198 if b == b')' && depth > 0 {
199 depth -= 1;
200 value_end += 1;
201 continue;
202 }
203 if depth == 0 && (b == b';' || b == b'}') {
204 break;
205 }
206 value_end += 1;
207 }
208
209 let mut value_end_trim = value_end;
210 while value_end_trim > value_start && bytes[value_end_trim - 1].is_ascii_whitespace() {
211 value_end_trim -= 1;
212 }
213
214 let name = css_text[name_start..name_end].to_string();
215 let value = css_text[value_start..value_end_trim].trim().to_string();
216 let selector = selector_override
217 .map(|s| s.to_string())
218 .unwrap_or_else(|| find_selector_before(css_text, name_start, in_at_rule));
219
220 let abs_name_start = base_offset + name_start;
221 let abs_name_end = base_offset + name_end;
222 let abs_value_start = base_offset + value_start;
223 let abs_value_end = base_offset + value_end_trim;
224
225 let variable = CssVariable {
226 name,
227 value: value.clone(),
228 uri: uri.clone(),
229 range: Range::new(
230 offset_to_position(full_text, abs_name_start),
231 offset_to_position(full_text, abs_value_end),
232 ),
233 name_range: Some(Range::new(
234 offset_to_position(full_text, abs_name_start),
235 offset_to_position(full_text, abs_name_end),
236 )),
237 value_range: Some(Range::new(
238 offset_to_position(full_text, abs_value_start),
239 offset_to_position(full_text, abs_value_end),
240 )),
241 selector,
242 important: value.to_lowercase().contains("!important"),
243 inline,
244 source_position: abs_name_start,
245 };
246
247 manager.add_variable(variable).await;
248 i = name_end;
249 continue;
250 }
251
252 i += 1;
253 }
254}
255
256async fn extract_usages(
257 css_text: &str,
258 full_text: &str,
259 uri: &Uri,
260 manager: &CssVariableManager,
261 base_offset: usize,
262 usage_context_override: Option<&str>,
263 dom_node: Option<DOMNodeInfo>,
264) {
265 let bytes = css_text.as_bytes();
266 let len = bytes.len();
267 let mut i = 0;
268 let mut in_comment = false;
269 let mut in_string: Option<u8> = None;
270 let mut brace_depth = 0;
271 let mut in_at_rule = false;
272
273 while i < len {
274 if in_comment {
275 if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'/' {
276 in_comment = false;
277 i += 2;
278 continue;
279 }
280 i += 1;
281 continue;
282 }
283
284 if let Some(quote) = in_string {
285 if bytes[i] == b'\\' {
286 i += 2;
287 continue;
288 }
289 if bytes[i] == quote {
290 in_string = None;
291 }
292 i += 1;
293 continue;
294 }
295
296 if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
297 in_comment = true;
298 i += 2;
299 continue;
300 }
301
302 if bytes[i] == b'"' || bytes[i] == b'\'' {
303 in_string = Some(bytes[i]);
304 i += 1;
305 continue;
306 }
307
308 if bytes[i] == b'{' {
310 brace_depth += 1;
311 } else if bytes[i] == b'}' {
312 brace_depth -= 1;
313 if brace_depth < 0 {
314 brace_depth = 0;
315 }
316 }
317
318 if bytes[i] == b'@' && !in_comment && in_string.is_none() {
320 in_at_rule = true;
321 } else if bytes[i] == b'{' && in_at_rule {
322 in_at_rule = false;
323 }
324
325 if is_var_function(bytes, i) {
326 let var_start = i;
327 let mut j = i + 3;
328 while j < len && bytes[j].is_ascii_whitespace() {
329 j += 1;
330 }
331 if j >= len || bytes[j] != b'(' {
332 i += 1;
333 continue;
334 }
335 let args_start = j + 1;
336 let mut name_start = None;
337 let mut name_end = None;
338 let mut k = args_start;
339 while k < len && bytes[k].is_ascii_whitespace() {
340 k += 1;
341 }
342 if k + 1 < len && bytes[k] == b'-' && bytes[k + 1] == b'-' {
343 name_start = Some(k);
344 k += 2;
345 while k < len && is_ident_char(bytes[k]) {
346 k += 1;
347 }
348 name_end = Some(k);
349 }
350
351 let mut depth = 1i32;
352 let mut p = args_start;
353 let mut var_in_comment = false;
354 let mut var_in_string: Option<u8> = None;
355 while p < len && depth > 0 {
356 let b = bytes[p];
357 if var_in_comment {
358 if p + 1 < len && b == b'*' && bytes[p + 1] == b'/' {
359 var_in_comment = false;
360 p += 2;
361 continue;
362 }
363 p += 1;
364 continue;
365 }
366 if let Some(q) = var_in_string {
367 if b == b'\\' {
368 p += 2;
369 continue;
370 }
371 if b == q {
372 var_in_string = None;
373 }
374 p += 1;
375 continue;
376 }
377 if p + 1 < len && b == b'/' && bytes[p + 1] == b'*' {
378 var_in_comment = true;
379 p += 2;
380 continue;
381 }
382 if b == b'"' || b == b'\'' {
383 var_in_string = Some(b);
384 p += 1;
385 continue;
386 }
387 if b == b'(' {
388 depth += 1;
389 p += 1;
390 continue;
391 }
392 if b == b')' {
393 depth -= 1;
394 p += 1;
395 continue;
396 }
397 p += 1;
398 }
399
400 let var_end = p.min(len);
401 if let (Some(ns), Some(ne)) = (name_start, name_end) {
402 let name = css_text[ns..ne].to_string();
403 let usage_context = usage_context_override
404 .map(|s| s.to_string())
405 .unwrap_or_else(|| find_selector_before(css_text, var_start, in_at_rule));
406 let abs_start = base_offset + var_start;
407 let abs_end = base_offset + var_end;
408 let abs_name_start = base_offset + ns;
409 let abs_name_end = base_offset + ne;
410
411 let usage = CssVariableUsage {
412 name,
413 uri: uri.clone(),
414 range: Range::new(
415 offset_to_position(full_text, abs_start),
416 offset_to_position(full_text, abs_end),
417 ),
418 name_range: Some(Range::new(
419 offset_to_position(full_text, abs_name_start),
420 offset_to_position(full_text, abs_name_end),
421 )),
422 usage_context,
423 dom_node: dom_node.clone(),
424 };
425 manager.add_usage(usage).await;
426 }
427
428 i = var_end;
429 continue;
430 }
431
432 i += 1;
433 }
434}
435
436fn is_var_function(bytes: &[u8], idx: usize) -> bool {
437 if idx + 2 >= bytes.len() {
438 return false;
439 }
440 if !bytes[idx].eq_ignore_ascii_case(&b'v')
441 || !bytes[idx + 1].eq_ignore_ascii_case(&b'a')
442 || !bytes[idx + 2].eq_ignore_ascii_case(&b'r')
443 {
444 return false;
445 }
446 if idx > 0 && is_ident_char(bytes[idx - 1]) {
447 return false;
448 }
449 true
450}
451
452fn is_ident_char(b: u8) -> bool {
453 b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
454}
455
456fn find_selector_before(text: &str, offset: usize, in_at_rule: bool) -> String {
457 let before = &text[..offset];
458
459 if in_at_rule {
460 if let Some(at_pos) = before.rfind('@') {
462 let at_rule_end = before[at_pos..]
463 .find('{')
464 .map(|pos| pos + at_pos)
465 .unwrap_or(before.len());
466 let at_rule = before[at_pos..at_rule_end].trim();
467 return format!("@{}", at_rule);
468 }
469 return "@unknown".to_string();
470 }
471
472 if let Some(brace_pos) = before.rfind('{') {
473 let start = before[..brace_pos].rfind('}').map(|p| p + 1).unwrap_or(0);
474 let selector_block = before[start..brace_pos].trim();
475
476 let selector = extract_last_selector(selector_block);
478
479 if selector.is_empty() {
480 ":root".to_string()
481 } else {
482 selector
483 }
484 } else {
485 ":root".to_string()
486 }
487}
488
489fn extract_last_selector(selector_block: &str) -> String {
491 let selectors: Vec<&str> = selector_block.split(',').map(|s| s.trim()).collect();
493
494 for selector in selectors.into_iter().rev() {
496 let cleaned = selector.rsplit('{').next().unwrap_or(selector).trim();
497
498 if !cleaned.is_empty() && !cleaned.starts_with('@') {
500 let lines: Vec<&str> = cleaned.lines().collect();
502 for line in lines.into_iter().rev() {
503 let trimmed = line.trim();
504 if !trimmed.is_empty() {
505 return trimmed.to_string();
506 }
507 }
508 }
509 }
510
511 ":root".to_string()
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517 use crate::manager::CssVariableManager;
518 use crate::types::Config;
519 use std::collections::HashSet;
520 use std::str::FromStr;
521
522 #[tokio::test]
523 async fn parse_css_document_extracts_definitions_and_usages() {
524 let manager = CssVariableManager::new(Config::default());
525 let uri = Uri::from_str("file:///test.css").unwrap();
526 let text = ":root { --primary: #fff; color: var(--primary); } \
527 .button { --secondary: var(--primary, #000); }";
528
529 parse_css_document(text, &uri, &manager).await.unwrap();
530
531 let primary_defs = manager.get_variables("--primary").await;
532 assert_eq!(primary_defs.len(), 1);
533 assert_eq!(primary_defs[0].value, "#fff");
534
535 let secondary_defs = manager.get_variables("--secondary").await;
536 assert_eq!(secondary_defs.len(), 1);
537 assert_eq!(secondary_defs[0].value, "var(--primary, #000)");
538
539 let usages = manager.get_usages("--primary").await;
540 assert_eq!(usages.len(), 2);
541
542 let contexts: HashSet<String> = usages.into_iter().map(|u| u.usage_context).collect();
543 assert!(contexts.contains(":root"));
544 assert!(contexts.contains(".button"));
545 }
546
547 #[tokio::test]
548 async fn parse_css_document_skips_nested_var_fallback_usages() {
549 let manager = CssVariableManager::new(Config::default());
550 let uri = Uri::from_str("file:///test.css").unwrap();
551 let text = ".button { color: var(--primary, var(--fallback)); }";
552
553 parse_css_document(text, &uri, &manager).await.unwrap();
554
555 let primary_usages = manager.get_usages("--primary").await;
556 assert_eq!(primary_usages.len(), 1);
557
558 let fallback_usages = manager.get_usages("--fallback").await;
559 assert_eq!(fallback_usages.len(), 0);
560 }
561}
562
563#[cfg(test)]
564mod edge_case_tests {
565 use super::*;
566 use crate::types::Config;
567 use ls_types::Uri;
568 use std::str::FromStr;
569
570 #[tokio::test]
571 async fn test_parse_empty_css() {
572 let manager = CssVariableManager::new(Config::default());
573 let uri = Uri::from_str("file:///empty.css").unwrap();
574
575 let result = parse_css_document("", &uri, &manager).await;
576 assert!(result.is_ok());
577 }
578
579 #[tokio::test]
580 async fn test_parse_css_with_comments() {
581 let manager = CssVariableManager::new(Config::default());
582 let uri = Uri::from_str("file:///test.css").unwrap();
583
584 let css = r#"
585 /* Comment before */
586 :root {
587 /* Inline comment */
588 --primary: blue; /* End comment */
589 --secondary: red;
590 }
591 /* Comment after */
592 "#;
593
594 let result = parse_css_document(css, &uri, &manager).await;
595 assert!(result.is_ok());
596
597 let vars = manager.get_all_variables().await;
598 assert_eq!(vars.len(), 2);
599 }
600
601 #[tokio::test]
602 async fn test_parse_css_with_important() {
603 let manager = CssVariableManager::new(Config::default());
604 let uri = Uri::from_str("file:///test.css").unwrap();
605
606 let css = r#"
607 :root {
608 --color: red !important;
609 --spacing: 1rem;
610 }
611 "#;
612
613 parse_css_document(css, &uri, &manager).await.unwrap();
614
615 let vars = manager.get_variables("--color").await;
616 assert_eq!(vars.len(), 1);
617 assert!(vars[0].important);
618
619 let spacing = manager.get_variables("--spacing").await;
620 assert!(!spacing[0].important);
621 }
622
623 #[tokio::test]
624 async fn test_parse_css_var_with_fallback() {
625 let manager = CssVariableManager::new(Config::default());
626 let uri = Uri::from_str("file:///test.css").unwrap();
627
628 let css = r#"
629 .button {
630 color: var(--primary, blue);
631 background: var(--bg, var(--fallback, #fff));
632 }
633 "#;
634
635 parse_css_document(css, &uri, &manager).await.unwrap();
636
637 let primary_usages = manager.get_usages("--primary").await;
638 assert_eq!(primary_usages.len(), 1);
639 let bg_usages = manager.get_usages("--bg").await;
642 assert_eq!(bg_usages.len(), 1);
643 }
644
645 #[tokio::test]
646 async fn test_parse_css_complex_selectors() {
647 let manager = CssVariableManager::new(Config::default());
648 let uri = Uri::from_str("file:///test.css").unwrap();
649
650 let css = r#"
651 #id .class > div[data-attr="value"]:hover::before {
652 --complex: value;
653 }
654
655 @media (min-width: 768px) {
656 .responsive {
657 --media: query;
658 }
659 }
660 "#;
661
662 parse_css_document(css, &uri, &manager).await.unwrap();
663
664 let vars = manager.get_all_variables().await;
665 assert!(vars.len() >= 2);
666 }
667
668 #[tokio::test]
669 async fn test_parse_css_multiline_values() {
670 let manager = CssVariableManager::new(Config::default());
671 let uri = Uri::from_str("file:///test.css").unwrap();
672
673 let css = r#"
674 :root {
675 --gradient: linear-gradient(
676 to bottom,
677 red,
678 blue
679 );
680 }
681 "#;
682
683 parse_css_document(css, &uri, &manager).await.unwrap();
684
685 let vars = manager.get_variables("--gradient").await;
686 assert_eq!(vars.len(), 1);
687 assert!(vars[0].value.contains("linear-gradient"));
688 }
689
690 #[tokio::test]
691 async fn test_parse_css_variable_names_with_dashes() {
692 let manager = CssVariableManager::new(Config::default());
693 let uri = Uri::from_str("file:///test.css").unwrap();
694
695 let css = r#"
696 :root {
697 --primary-color: blue;
698 --bg-color-dark: #333;
699 --font-size-xl: 2rem;
700 }
701 "#;
702
703 parse_css_document(css, &uri, &manager).await.unwrap();
704
705 let vars = manager.get_all_variables().await;
706 assert_eq!(vars.len(), 3);
707 assert!(vars.iter().any(|v| v.name == "--primary-color"));
708 assert!(vars.iter().any(|v| v.name == "--bg-color-dark"));
709 assert!(vars.iter().any(|v| v.name == "--font-size-xl"));
710 }
711
712 #[tokio::test]
713 async fn test_parse_css_special_characters_in_values() {
714 let manager = CssVariableManager::new(Config::default());
715 let uri = Uri::from_str("file:///test.css").unwrap();
716
717 let css = r#"
718 :root {
719 --shadow: 0 2px 4px rgba(0,0,0,0.1);
720 --calc: calc(100% - 20px);
721 --url: url("https://example.com/image.jpg");
722 --content: "Hello, World!";
723 }
724 "#;
725
726 parse_css_document(css, &uri, &manager).await.unwrap();
727
728 let vars = manager.get_all_variables().await;
729 assert_eq!(vars.len(), 4);
730 }
731
732 #[tokio::test]
733 async fn test_parse_css_nested_var_calls() {
734 let manager = CssVariableManager::new(Config::default());
735 let uri = Uri::from_str("file:///test.css").unwrap();
736
737 let css = r#"
738 .element {
739 color: var(--primary);
740 background: var(--bg);
741 border: 1px solid var(--border-color);
742 }
743 "#;
744
745 parse_css_document(css, &uri, &manager).await.unwrap();
746
747 assert_eq!(manager.get_usages("--primary").await.len(), 1);
748 assert_eq!(manager.get_usages("--bg").await.len(), 1);
749 assert_eq!(manager.get_usages("--border-color").await.len(), 1);
750 }
751
752 #[tokio::test]
753 async fn test_parse_css_whitespace_variations() {
754 let manager = CssVariableManager::new(Config::default());
755 let uri = Uri::from_str("file:///test.css").unwrap();
756
757 let css = r#"
758 :root{--no-space:value;}
759 :root { --normal-space: value; }
760 :root { --extra-space : value ; }
761 "#;
762
763 parse_css_document(css, &uri, &manager).await.unwrap();
764
765 let vars = manager.get_all_variables().await;
766 assert_eq!(vars.len(), 3);
767 }
768
769 #[tokio::test]
770 async fn test_parse_css_malformed_but_parseable() {
771 let manager = CssVariableManager::new(Config::default());
772 let uri = Uri::from_str("file:///test.css").unwrap();
773
774 let css = r#"
776 :root {
777 --valid: blue;
778 "#;
779
780 let result = parse_css_document(css, &uri, &manager).await;
781 assert!(result.is_ok());
782 }
783}