css_variable_lsp/parsers/
css.rs1use tower_lsp::lsp_types::{Range, Url};
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 Url,
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: &Url,
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: &Url,
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: &Url,
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
521 #[tokio::test]
522 async fn parse_css_document_extracts_definitions_and_usages() {
523 let manager = CssVariableManager::new(Config::default());
524 let uri = Url::parse("file:///test.css").unwrap();
525 let text = ":root { --primary: #fff; color: var(--primary); } \
526 .button { --secondary: var(--primary, #000); }";
527
528 parse_css_document(text, &uri, &manager).await.unwrap();
529
530 let primary_defs = manager.get_variables("--primary").await;
531 assert_eq!(primary_defs.len(), 1);
532 assert_eq!(primary_defs[0].value, "#fff");
533
534 let secondary_defs = manager.get_variables("--secondary").await;
535 assert_eq!(secondary_defs.len(), 1);
536 assert_eq!(secondary_defs[0].value, "var(--primary, #000)");
537
538 let usages = manager.get_usages("--primary").await;
539 assert_eq!(usages.len(), 2);
540
541 let contexts: HashSet<String> = usages.into_iter().map(|u| u.usage_context).collect();
542 assert!(contexts.contains(":root"));
543 assert!(contexts.contains(".button"));
544 }
545
546 #[tokio::test]
547 async fn parse_css_document_skips_nested_var_fallback_usages() {
548 let manager = CssVariableManager::new(Config::default());
549 let uri = Url::parse("file:///test.css").unwrap();
550 let text = ".button { color: var(--primary, var(--fallback)); }";
551
552 parse_css_document(text, &uri, &manager).await.unwrap();
553
554 let primary_usages = manager.get_usages("--primary").await;
555 assert_eq!(primary_usages.len(), 1);
556
557 let fallback_usages = manager.get_usages("--fallback").await;
558 assert_eq!(fallback_usages.len(), 0);
559 }
560}
561
562#[cfg(test)]
563mod edge_case_tests {
564 use super::*;
565 use crate::types::Config;
566 use tower_lsp::lsp_types::Url;
567
568 #[tokio::test]
569 async fn test_parse_empty_css() {
570 let manager = CssVariableManager::new(Config::default());
571 let uri = Url::parse("file:///empty.css").unwrap();
572
573 let result = parse_css_document("", &uri, &manager).await;
574 assert!(result.is_ok());
575 }
576
577 #[tokio::test]
578 async fn test_parse_css_with_comments() {
579 let manager = CssVariableManager::new(Config::default());
580 let uri = Url::parse("file:///test.css").unwrap();
581
582 let css = r#"
583 /* Comment before */
584 :root {
585 /* Inline comment */
586 --primary: blue; /* End comment */
587 --secondary: red;
588 }
589 /* Comment after */
590 "#;
591
592 let result = parse_css_document(css, &uri, &manager).await;
593 assert!(result.is_ok());
594
595 let vars = manager.get_all_variables().await;
596 assert_eq!(vars.len(), 2);
597 }
598
599 #[tokio::test]
600 async fn test_parse_css_with_important() {
601 let manager = CssVariableManager::new(Config::default());
602 let uri = Url::parse("file:///test.css").unwrap();
603
604 let css = r#"
605 :root {
606 --color: red !important;
607 --spacing: 1rem;
608 }
609 "#;
610
611 parse_css_document(css, &uri, &manager).await.unwrap();
612
613 let vars = manager.get_variables("--color").await;
614 assert_eq!(vars.len(), 1);
615 assert!(vars[0].important);
616
617 let spacing = manager.get_variables("--spacing").await;
618 assert!(!spacing[0].important);
619 }
620
621 #[tokio::test]
622 async fn test_parse_css_var_with_fallback() {
623 let manager = CssVariableManager::new(Config::default());
624 let uri = Url::parse("file:///test.css").unwrap();
625
626 let css = r#"
627 .button {
628 color: var(--primary, blue);
629 background: var(--bg, var(--fallback, #fff));
630 }
631 "#;
632
633 parse_css_document(css, &uri, &manager).await.unwrap();
634
635 let primary_usages = manager.get_usages("--primary").await;
636 assert_eq!(primary_usages.len(), 1);
637 let bg_usages = manager.get_usages("--bg").await;
640 assert_eq!(bg_usages.len(), 1);
641 }
642
643 #[tokio::test]
644 async fn test_parse_css_complex_selectors() {
645 let manager = CssVariableManager::new(Config::default());
646 let uri = Url::parse("file:///test.css").unwrap();
647
648 let css = r#"
649 #id .class > div[data-attr="value"]:hover::before {
650 --complex: value;
651 }
652
653 @media (min-width: 768px) {
654 .responsive {
655 --media: query;
656 }
657 }
658 "#;
659
660 parse_css_document(css, &uri, &manager).await.unwrap();
661
662 let vars = manager.get_all_variables().await;
663 assert!(vars.len() >= 2);
664 }
665
666 #[tokio::test]
667 async fn test_parse_css_multiline_values() {
668 let manager = CssVariableManager::new(Config::default());
669 let uri = Url::parse("file:///test.css").unwrap();
670
671 let css = r#"
672 :root {
673 --gradient: linear-gradient(
674 to bottom,
675 red,
676 blue
677 );
678 }
679 "#;
680
681 parse_css_document(css, &uri, &manager).await.unwrap();
682
683 let vars = manager.get_variables("--gradient").await;
684 assert_eq!(vars.len(), 1);
685 assert!(vars[0].value.contains("linear-gradient"));
686 }
687
688 #[tokio::test]
689 async fn test_parse_css_variable_names_with_dashes() {
690 let manager = CssVariableManager::new(Config::default());
691 let uri = Url::parse("file:///test.css").unwrap();
692
693 let css = r#"
694 :root {
695 --primary-color: blue;
696 --bg-color-dark: #333;
697 --font-size-xl: 2rem;
698 }
699 "#;
700
701 parse_css_document(css, &uri, &manager).await.unwrap();
702
703 let vars = manager.get_all_variables().await;
704 assert_eq!(vars.len(), 3);
705 assert!(vars.iter().any(|v| v.name == "--primary-color"));
706 assert!(vars.iter().any(|v| v.name == "--bg-color-dark"));
707 assert!(vars.iter().any(|v| v.name == "--font-size-xl"));
708 }
709
710 #[tokio::test]
711 async fn test_parse_css_special_characters_in_values() {
712 let manager = CssVariableManager::new(Config::default());
713 let uri = Url::parse("file:///test.css").unwrap();
714
715 let css = r#"
716 :root {
717 --shadow: 0 2px 4px rgba(0,0,0,0.1);
718 --calc: calc(100% - 20px);
719 --url: url("https://example.com/image.jpg");
720 --content: "Hello, World!";
721 }
722 "#;
723
724 parse_css_document(css, &uri, &manager).await.unwrap();
725
726 let vars = manager.get_all_variables().await;
727 assert_eq!(vars.len(), 4);
728 }
729
730 #[tokio::test]
731 async fn test_parse_css_nested_var_calls() {
732 let manager = CssVariableManager::new(Config::default());
733 let uri = Url::parse("file:///test.css").unwrap();
734
735 let css = r#"
736 .element {
737 color: var(--primary);
738 background: var(--bg);
739 border: 1px solid var(--border-color);
740 }
741 "#;
742
743 parse_css_document(css, &uri, &manager).await.unwrap();
744
745 assert_eq!(manager.get_usages("--primary").await.len(), 1);
746 assert_eq!(manager.get_usages("--bg").await.len(), 1);
747 assert_eq!(manager.get_usages("--border-color").await.len(), 1);
748 }
749
750 #[tokio::test]
751 async fn test_parse_css_whitespace_variations() {
752 let manager = CssVariableManager::new(Config::default());
753 let uri = Url::parse("file:///test.css").unwrap();
754
755 let css = r#"
756 :root{--no-space:value;}
757 :root { --normal-space: value; }
758 :root { --extra-space : value ; }
759 "#;
760
761 parse_css_document(css, &uri, &manager).await.unwrap();
762
763 let vars = manager.get_all_variables().await;
764 assert_eq!(vars.len(), 3);
765 }
766
767 #[tokio::test]
768 async fn test_parse_css_malformed_but_parseable() {
769 let manager = CssVariableManager::new(Config::default());
770 let uri = Url::parse("file:///test.css").unwrap();
771
772 let css = r#"
774 :root {
775 --valid: blue;
776 "#;
777
778 let result = parse_css_document(css, &uri, &manager).await;
779 assert!(result.is_ok());
780 }
781}