1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use regex::Regex;
6use tokio::sync::RwLock;
7use tower_lsp::lsp_types::{
8 ColorInformation, ColorPresentation, ColorPresentationParams, ColorProviderCapability,
9 CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse,
10 Diagnostic, DiagnosticSeverity, DidChangeTextDocumentParams, DidChangeWatchedFilesParams,
11 DidChangeWorkspaceFoldersParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
12 DocumentColorParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse,
13 FileChangeType, GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents,
14 HoverParams, InitializeParams, InitializeResult, Location, MarkupContent, MarkupKind,
15 MessageType, OneOf, Position, Range, ReferenceParams, RenameParams, ServerCapabilities,
16 SymbolInformation, SymbolKind, TextDocumentContentChangeEvent, TextDocumentSyncCapability,
17 TextDocumentSyncKind, TextEdit, Url, WorkDoneProgressOptions, WorkspaceEdit, WorkspaceFolder,
18 WorkspaceFoldersServerCapabilities, WorkspaceServerCapabilities, WorkspaceSymbolParams,
19};
20use tower_lsp::{Client, LanguageServer};
21
22use crate::color::{generate_color_presentations, parse_color};
23use crate::manager::CssVariableManager;
24use crate::parsers::{parse_css_document, parse_html_document};
25use crate::path_display::{format_uri_for_display, to_normalized_fs_path, PathDisplayOptions};
26use crate::runtime_config::RuntimeConfig;
27use crate::specificity::{
28 calculate_specificity, compare_specificity, format_specificity, matches_context,
29 sort_by_cascade,
30};
31use crate::types::{position_to_offset, Config};
32
33pub struct CssVariableLsp {
34 client: Client,
35 manager: Arc<CssVariableManager>,
36 document_map: Arc<RwLock<HashMap<Url, String>>>,
37 runtime_config: RuntimeConfig,
38 workspace_folder_paths: Arc<RwLock<Vec<PathBuf>>>,
39 root_folder_path: Arc<RwLock<Option<PathBuf>>>,
40 has_workspace_folder_capability: Arc<RwLock<bool>>,
41 has_diagnostic_related_information: Arc<RwLock<bool>>,
42 usage_regex: Regex,
43 var_usage_regex: Regex,
44 var_partial_regex: Regex,
45 style_attr_regex: Regex,
46}
47
48impl CssVariableLsp {
49 pub fn new(client: Client, runtime_config: RuntimeConfig) -> Self {
50 let config = Config::from_runtime(&runtime_config);
51 Self {
52 client,
53 manager: Arc::new(CssVariableManager::new(config)),
54 document_map: Arc::new(RwLock::new(HashMap::new())),
55 runtime_config,
56 workspace_folder_paths: Arc::new(RwLock::new(Vec::new())),
57 root_folder_path: Arc::new(RwLock::new(None)),
58 has_workspace_folder_capability: Arc::new(RwLock::new(false)),
59 has_diagnostic_related_information: Arc::new(RwLock::new(false)),
60 usage_regex: Regex::new(r"var\((--[\w-]+)(?:\s*,\s*[^)]+)?\)").unwrap(),
61 var_usage_regex: Regex::new(r"var\((--[\w-]+)\)").unwrap(),
62 var_partial_regex: Regex::new(r"var\(\s*(--[\w-]*)$").unwrap(),
63 style_attr_regex: Regex::new(r#"(?i)style\s*=\s*["'][^"']*:\s*[^"';]*$"#).unwrap(),
64 }
65 }
66
67 async fn update_workspace_folder_paths(&self, folders: Option<Vec<WorkspaceFolder>>) {
68 let mut paths = Vec::new();
69 if let Some(folders) = folders {
70 for folder in folders {
71 if let Some(path) = to_normalized_fs_path(&folder.uri) {
72 paths.push(path);
73 }
74 }
75 }
76 paths.sort_by_key(|b| std::cmp::Reverse(b.to_string_lossy().len()));
77 let mut stored = self.workspace_folder_paths.write().await;
78 *stored = paths;
79 }
80
81 async fn parse_document_text(&self, uri: &Url, text: &str) {
82 self.manager.remove_document(uri).await;
83
84 let path = uri.path().to_lowercase();
85 let result = if is_html_like(&path) {
86 parse_html_document(text, uri, &self.manager).await
87 } else if is_css_like(&path) {
88 parse_css_document(text, uri, &self.manager).await
89 } else {
90 return;
91 };
92
93 if let Err(e) = result {
94 self.client
95 .log_message(MessageType::ERROR, format!("Parse error: {}", e))
96 .await;
97 }
98 }
99
100 async fn validate_document_text(&self, uri: &Url, text: &str) {
101 let mut diagnostics = Vec::new();
102 let has_related_info = *self.has_diagnostic_related_information.read().await;
103
104 for captures in self.usage_regex.captures_iter(text) {
105 let match_all = captures.get(0).unwrap();
106 let name = captures.get(1).unwrap().as_str();
107 let definitions = self.manager.get_variables(name).await;
108 if !definitions.is_empty() {
109 continue;
110 }
111 let range = Range::new(
112 crate::types::offset_to_position(text, match_all.start()),
113 crate::types::offset_to_position(text, match_all.end()),
114 );
115 diagnostics.push(Diagnostic {
116 range,
117 severity: Some(DiagnosticSeverity::WARNING),
118 code: None,
119 code_description: None,
120 source: Some("css-variable-lsp".to_string()),
121 message: format!("CSS variable '{}' is not defined in the workspace", name),
122 related_information: if has_related_info {
123 Some(Vec::new())
124 } else {
125 None
126 },
127 tags: None,
128 data: None,
129 });
130 }
131
132 self.client
133 .publish_diagnostics(uri.clone(), diagnostics, None)
134 .await;
135 }
136
137 async fn validate_all_open_documents(&self) {
138 let docs_snapshot = {
139 let docs = self.document_map.read().await;
140 docs.iter()
141 .map(|(uri, text)| (uri.clone(), text.clone()))
142 .collect::<Vec<_>>()
143 };
144
145 for (uri, text) in docs_snapshot {
146 self.validate_document_text(&uri, &text).await;
147 }
148 }
149
150 async fn update_document_from_disk(&self, uri: &Url) {
151 let path = match to_normalized_fs_path(uri) {
152 Some(path) => path,
153 None => {
154 self.manager.remove_document(uri).await;
155 return;
156 }
157 };
158
159 match tokio::fs::read_to_string(&path).await {
160 Ok(text) => {
161 self.parse_document_text(uri, &text).await;
162 }
163 Err(_) => {
164 self.manager.remove_document(uri).await;
165 }
166 }
167 }
168
169 async fn apply_content_changes(
170 &self,
171 uri: &Url,
172 changes: Vec<TextDocumentContentChangeEvent>,
173 ) -> Option<String> {
174 let mut docs = self.document_map.write().await;
175 let mut text = if let Some(existing) = docs.get(uri) {
176 existing.clone()
177 } else {
178 if changes.len() == 1 && changes[0].range.is_none() {
179 let new_text = changes[0].text.clone();
180 docs.insert(uri.clone(), new_text.clone());
181 return Some(new_text);
182 }
183 return None;
184 };
185
186 for change in changes {
187 apply_change_to_text(&mut text, &change);
188 }
189
190 docs.insert(uri.clone(), text.clone());
191 Some(text)
192 }
193
194 fn get_word_at_position(&self, text: &str, position: Position) -> Option<String> {
195 let offset = position_to_offset(text, position)?;
196 let offset = clamp_to_char_boundary(text, offset);
197 let before = &text[..offset];
198 let after = &text[offset..];
199
200 let left = before
201 .rsplit(|c: char| !is_word_char(c))
202 .next()
203 .unwrap_or("");
204 let right = after.split(|c: char| !is_word_char(c)).next().unwrap_or("");
205 let word = format!("{}{}", left, right);
206 if word.starts_with("--") {
207 Some(word)
208 } else {
209 None
210 }
211 }
212
213 fn is_in_css_value_context(&self, text: &str, position: Position) -> bool {
214 let offset = match position_to_offset(text, position) {
215 Some(o) => o,
216 None => return false,
217 };
218 let start = clamp_to_char_boundary(text, offset.saturating_sub(200));
219 let offset = clamp_to_char_boundary(text, offset);
220 let before_cursor = &text[start..offset];
221
222 if self.var_partial_regex.is_match(before_cursor) {
223 return true;
224 }
225
226 if let Some(_property_name) = get_property_name_from_context(before_cursor) {
227 return true;
228 }
229
230 if self.style_attr_regex.is_match(before_cursor) {
231 return true;
232 }
233
234 false
235 }
236
237 fn get_property_name_from_context(&self, text: &str, position: Position) -> Option<String> {
238 let offset = position_to_offset(text, position)?;
239 let start = clamp_to_char_boundary(text, offset.saturating_sub(200));
240 let offset = clamp_to_char_boundary(text, offset);
241 let before_cursor = &text[start..offset];
242 get_property_name_from_context(before_cursor)
243 }
244
245 async fn is_document_open(&self, uri: &Url) -> bool {
246 let docs = self.document_map.read().await;
247 docs.contains_key(uri)
248 }
249}
250
251#[tower_lsp::async_trait]
252impl LanguageServer for CssVariableLsp {
253 async fn initialize(
254 &self,
255 params: InitializeParams,
256 ) -> tower_lsp::jsonrpc::Result<InitializeResult> {
257 self.client
258 .log_message(MessageType::INFO, "CSS Variable LSP (Rust) initializing...")
259 .await;
260
261 let has_workspace_folders = params
262 .capabilities
263 .workspace
264 .as_ref()
265 .and_then(|w| w.workspace_folders)
266 .unwrap_or(false);
267 let has_related_info = params
268 .capabilities
269 .text_document
270 .as_ref()
271 .and_then(|t| t.publish_diagnostics.as_ref())
272 .and_then(|p| p.related_information)
273 .unwrap_or(false);
274
275 {
276 let mut cap = self.has_workspace_folder_capability.write().await;
277 *cap = has_workspace_folders;
278 }
279 {
280 let mut rel = self.has_diagnostic_related_information.write().await;
281 *rel = has_related_info;
282 }
283
284 if let Some(root_uri) = params.root_uri.as_ref() {
285 let root_path = to_normalized_fs_path(root_uri);
286 let mut root = self.root_folder_path.write().await;
287 *root = root_path;
288 } else {
289 #[allow(deprecated)]
290 if let Some(root_path) = params.root_path.as_ref() {
291 let mut root = self.root_folder_path.write().await;
292 *root = Some(PathBuf::from(root_path));
293 }
294 }
295
296 self.update_workspace_folder_paths(params.workspace_folders.clone())
297 .await;
298
299 let mut capabilities = ServerCapabilities {
300 text_document_sync: Some(TextDocumentSyncCapability::Kind(
301 TextDocumentSyncKind::INCREMENTAL,
302 )),
303 completion_provider: Some(CompletionOptions {
304 resolve_provider: Some(true),
305 trigger_characters: Some(vec!["-".to_string()]),
306 work_done_progress_options: WorkDoneProgressOptions::default(),
307 all_commit_characters: None,
308 completion_item: None,
309 }),
310 hover_provider: Some(tower_lsp::lsp_types::HoverProviderCapability::Simple(true)),
311 definition_provider: Some(OneOf::Left(true)),
312 references_provider: Some(OneOf::Left(true)),
313 rename_provider: Some(OneOf::Left(true)),
314 document_symbol_provider: Some(OneOf::Left(true)),
315 workspace_symbol_provider: Some(OneOf::Left(true)),
316 color_provider: if self.runtime_config.enable_color_provider {
317 Some(ColorProviderCapability::Simple(true))
318 } else {
319 None
320 },
321 ..Default::default()
322 };
323
324 if has_workspace_folders {
325 capabilities.workspace = Some(WorkspaceServerCapabilities {
326 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
327 supported: Some(true),
328 change_notifications: None,
329 }),
330 file_operations: None,
331 });
332 }
333
334 Ok(InitializeResult {
335 capabilities,
336 server_info: Some(tower_lsp::lsp_types::ServerInfo {
337 name: "css-variable-lsp-rust".to_string(),
338 version: Some("0.1.0".to_string()),
339 }),
340 })
341 }
342
343 async fn initialized(&self, _params: tower_lsp::lsp_types::InitializedParams) {
344 self.client
345 .log_message(MessageType::INFO, "CSS Variable LSP (Rust) initialized!")
346 .await;
347
348 if let Ok(Some(folders)) = self.client.workspace_folders().await {
349 self.update_workspace_folder_paths(Some(folders.clone()))
350 .await;
351 self.scan_workspace_folders(folders).await;
352 }
353 }
354
355 async fn shutdown(&self) -> tower_lsp::jsonrpc::Result<()> {
356 Ok(())
357 }
358
359 async fn did_open(&self, params: DidOpenTextDocumentParams) {
360 let uri = params.text_document.uri;
361 let text = params.text_document.text;
362 {
363 let mut docs = self.document_map.write().await;
364 docs.insert(uri.clone(), text.clone());
365 }
366 self.parse_document_text(&uri, &text).await;
367 self.validate_document_text(&uri, &text).await;
368 }
369
370 async fn did_change(&self, params: DidChangeTextDocumentParams) {
371 let uri = params.text_document.uri;
372 let changes = params.content_changes;
373 let updated_text = match self.apply_content_changes(&uri, changes).await {
374 Some(text) => text,
375 None => return,
376 };
377 self.parse_document_text(&uri, &updated_text).await;
378 self.validate_document_text(&uri, &updated_text).await;
379 }
380
381 async fn did_close(&self, params: DidCloseTextDocumentParams) {
382 let uri = params.text_document.uri;
383 {
384 let mut docs = self.document_map.write().await;
385 docs.remove(&uri);
386 }
387 self.update_document_from_disk(&uri).await;
388 }
389
390 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
391 for change in params.changes {
392 match change.typ {
393 FileChangeType::DELETED => {
394 self.manager.remove_document(&change.uri).await;
395 }
396 FileChangeType::CREATED | FileChangeType::CHANGED => {
397 if !self.is_document_open(&change.uri).await {
398 self.update_document_from_disk(&change.uri).await;
399 }
400 }
401 _ => {}
402 }
403 }
404
405 self.validate_all_open_documents().await;
406 }
407
408 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
409 let mut current_paths = {
410 let paths = self.workspace_folder_paths.read().await;
411 paths.clone()
412 };
413
414 for removed in params.event.removed {
415 if let Some(path) = to_normalized_fs_path(&removed.uri) {
416 current_paths.retain(|p| p != &path);
417 }
418 }
419
420 for added in params.event.added {
421 if let Some(path) = to_normalized_fs_path(&added.uri) {
422 current_paths.push(path);
423 }
424 }
425
426 current_paths.sort_by_key(|b| std::cmp::Reverse(b.to_string_lossy().len()));
427
428 let mut stored = self.workspace_folder_paths.write().await;
429 *stored = current_paths;
430 }
431
432 async fn completion(
433 &self,
434 params: CompletionParams,
435 ) -> tower_lsp::jsonrpc::Result<Option<CompletionResponse>> {
436 let uri = params.text_document_position.text_document.uri;
437 let position = params.text_document_position.position;
438
439 let text = {
440 let docs = self.document_map.read().await;
441 docs.get(&uri).cloned()
442 };
443 let text = match text {
444 Some(text) => text,
445 None => return Ok(Some(CompletionResponse::Array(Vec::new()))),
446 };
447
448 if !self.is_in_css_value_context(&text, position) {
449 return Ok(Some(CompletionResponse::Array(Vec::new())));
450 }
451
452 let property_name = self.get_property_name_from_context(&text, position);
453 let variables = self.manager.get_all_variables().await;
454
455 let mut unique_vars = HashMap::new();
456 for var in variables {
457 unique_vars.entry(var.name.clone()).or_insert(var);
458 }
459
460 let mut scored_vars: Vec<(i32, _)> = unique_vars
461 .values()
462 .map(|var| {
463 let score = score_variable_relevance(&var.name, property_name.as_deref());
464 (score, var)
465 })
466 .collect();
467
468 scored_vars.retain(|(score, _)| *score != 0);
469 scored_vars.sort_by(|(score_a, var_a), (score_b, var_b)| {
470 if score_a != score_b {
471 return score_b.cmp(score_a);
472 }
473 var_a.name.cmp(&var_b.name)
474 });
475
476 let workspace_folder_paths = self.workspace_folder_paths.read().await.clone();
477 let root_folder_path = self.root_folder_path.read().await.clone();
478
479 let items = scored_vars
480 .into_iter()
481 .map(|(_, var)| {
482 let options = PathDisplayOptions {
483 mode: self.runtime_config.path_display_mode,
484 abbrev_length: self.runtime_config.path_display_abbrev_length,
485 workspace_folder_paths: &workspace_folder_paths,
486 root_folder_path: root_folder_path.as_ref(),
487 };
488 CompletionItem {
489 label: var.name.clone(),
490 kind: Some(CompletionItemKind::VARIABLE),
491 detail: Some(var.value.clone()),
492 documentation: Some(tower_lsp::lsp_types::Documentation::String(format!(
493 "Defined in {}",
494 format_uri_for_display(&var.uri, options)
495 ))),
496 ..Default::default()
497 }
498 })
499 .collect();
500
501 Ok(Some(CompletionResponse::Array(items)))
502 }
503
504 async fn hover(&self, params: HoverParams) -> tower_lsp::jsonrpc::Result<Option<Hover>> {
505 let uri = params.text_document_position_params.text_document.uri;
506 let position = params.text_document_position_params.position;
507
508 let text = {
509 let docs = self.document_map.read().await;
510 docs.get(&uri).cloned()
511 };
512 let text = match text {
513 Some(text) => text,
514 None => return Ok(None),
515 };
516
517 let word = match self.get_word_at_position(&text, position) {
518 Some(word) => word,
519 None => return Ok(None),
520 };
521
522 let mut definitions = self.manager.get_variables(&word).await;
523 if definitions.is_empty() {
524 return Ok(None);
525 }
526
527 let usages = self.manager.get_usages(&word).await;
528 let offset = match position_to_offset(&text, position) {
529 Some(offset) => offset,
530 None => return Ok(None),
531 };
532 let hover_usage = usages.iter().find(|usage| {
533 if usage.uri != uri {
534 return false;
535 }
536 let start = position_to_offset(&text, usage.range.start).unwrap_or(0);
537 let end = position_to_offset(&text, usage.range.end).unwrap_or(0);
538 offset >= start && offset <= end
539 });
540
541 let usage_context = hover_usage
542 .map(|u| u.usage_context.clone())
543 .unwrap_or_default();
544 let is_inline_style = usage_context == "inline-style";
545 let dom_tree = self.manager.get_dom_tree(&uri).await;
546 let dom_node = hover_usage.and_then(|u| u.dom_node.clone());
547
548 sort_by_cascade(&mut definitions);
549
550 let mut hover_text = format!("### CSS Variable: `{}`\n\n", word);
551
552 if definitions.len() == 1 {
553 let var = &definitions[0];
554 hover_text.push_str(&format!("**Value:** `{}`", var.value));
555 if var.important {
556 hover_text.push_str(" **!important**");
557 }
558 hover_text.push_str("\n\n");
559 if !var.selector.is_empty() {
560 hover_text.push_str(&format!("**Defined in:** `{}`\n", var.selector));
561 hover_text.push_str(&format!(
562 "**Specificity:** {}\n",
563 format_specificity(calculate_specificity(&var.selector))
564 ));
565 }
566 } else {
567 hover_text.push_str("**Definitions** (CSS cascade order):\n\n");
568
569 for (idx, var) in definitions.iter().enumerate() {
570 let spec = calculate_specificity(&var.selector);
571 let is_applicable = if usage_context.is_empty() {
572 true
573 } else {
574 matches_context(
575 &var.selector,
576 &usage_context,
577 dom_tree.as_ref(),
578 dom_node.as_ref(),
579 )
580 };
581 let is_winner = idx == 0 && (is_applicable || is_inline_style);
582
583 let mut line = format!("{}. `{}`", idx + 1, var.value);
584 if var.important {
585 line.push_str(" **!important**");
586 }
587 if !var.selector.is_empty() {
588 line.push_str(&format!(
589 " from `{}` {}",
590 var.selector,
591 format_specificity(spec)
592 ));
593 }
594
595 if is_winner && !usage_context.is_empty() {
596 if var.important {
597 line.push_str(" ✓ **Wins (!important)**");
598 } else if is_inline_style {
599 line.push_str(" ✓ **Would apply (inline style)**");
600 } else if dom_tree.is_some() && dom_node.is_some() {
601 line.push_str(" ✓ **Applies (DOM match)**");
602 } else {
603 line.push_str(" ✓ **Applies here**");
604 }
605 } else if !is_applicable && !usage_context.is_empty() && !is_inline_style {
606 line.push_str(" _(selector doesn't match)_");
607 } else if idx > 0 && !usage_context.is_empty() {
608 let winner = &definitions[0];
609 if winner.important && !var.important {
610 line.push_str(" _(overridden by !important)_");
611 } else {
612 let winner_spec = calculate_specificity(&winner.selector);
613 let cmp = compare_specificity(winner_spec, spec);
614 if cmp > 0 {
615 line.push_str(" _(lower specificity)_");
616 } else if cmp == 0 {
617 line.push_str(" _(earlier in source)_");
618 }
619 }
620 }
621
622 hover_text.push_str(&line);
623 hover_text.push('\n');
624 }
625
626 if !usage_context.is_empty() {
627 if is_inline_style {
628 hover_text.push_str("\n_Context: Inline style (highest priority)_");
629 } else if dom_tree.is_some() && dom_node.is_some() {
630 hover_text.push_str(&format!(
631 "\n_Context: `{}` (DOM-aware matching)_",
632 usage_context
633 ));
634 } else {
635 hover_text.push_str(&format!("\n_Context: `{}`_", usage_context));
636 }
637 }
638 }
639
640 Ok(Some(Hover {
641 contents: HoverContents::Markup(MarkupContent {
642 kind: MarkupKind::Markdown,
643 value: hover_text,
644 }),
645 range: None,
646 }))
647 }
648
649 async fn goto_definition(
650 &self,
651 params: GotoDefinitionParams,
652 ) -> tower_lsp::jsonrpc::Result<Option<GotoDefinitionResponse>> {
653 let uri = params.text_document_position_params.text_document.uri;
654 let position = params.text_document_position_params.position;
655
656 let text = {
657 let docs = self.document_map.read().await;
658 docs.get(&uri).cloned()
659 };
660 let text = match text {
661 Some(text) => text,
662 None => return Ok(None),
663 };
664
665 let word = match self.get_word_at_position(&text, position) {
666 Some(word) => word,
667 None => return Ok(None),
668 };
669
670 let definitions = self.manager.get_variables(&word).await;
671 let first = match definitions.first() {
672 Some(def) => def,
673 None => return Ok(None),
674 };
675
676 Ok(Some(GotoDefinitionResponse::Scalar(Location::new(
677 first.uri.clone(),
678 first.range,
679 ))))
680 }
681
682 async fn references(
683 &self,
684 params: ReferenceParams,
685 ) -> tower_lsp::jsonrpc::Result<Option<Vec<Location>>> {
686 let uri = params.text_document_position.text_document.uri;
687 let position = params.text_document_position.position;
688
689 let text = {
690 let docs = self.document_map.read().await;
691 docs.get(&uri).cloned()
692 };
693 let text = match text {
694 Some(text) => text,
695 None => return Ok(None),
696 };
697
698 let word = match self.get_word_at_position(&text, position) {
699 Some(word) => word,
700 None => return Ok(None),
701 };
702
703 let (definitions, usages) = self.manager.get_references(&word).await;
704 let mut locations = Vec::new();
705 for def in definitions {
706 locations.push(Location::new(def.uri, def.range));
707 }
708 for usage in usages {
709 locations.push(Location::new(usage.uri, usage.range));
710 }
711
712 Ok(Some(locations))
713 }
714
715 async fn document_color(
716 &self,
717 params: DocumentColorParams,
718 ) -> tower_lsp::jsonrpc::Result<Vec<ColorInformation>> {
719 let config = self.manager.get_config().await;
720 if !config.enable_color_provider {
721 return Ok(Vec::new());
722 }
723
724 let uri = params.text_document.uri;
725 let text = {
726 let docs = self.document_map.read().await;
727 docs.get(&uri).cloned()
728 };
729 let text = match text {
730 Some(text) => text,
731 None => return Ok(Vec::new()),
732 };
733
734 let mut colors = Vec::new();
735
736 if !config.color_only_on_variables {
737 let definitions = self.manager.get_document_variables(&uri).await;
738 for def in definitions {
739 if let Some(color) = parse_color(&def.value) {
740 if let Some(value_range) = def.value_range {
741 colors.push(ColorInformation {
742 range: value_range,
743 color,
744 });
745 } else if let Some(range) = find_value_range_in_definition(&text, &def) {
746 colors.push(ColorInformation { range, color });
747 }
748 }
749 }
750 }
751
752 for caps in self.var_usage_regex.captures_iter(&text) {
753 let match_all = caps.get(0).unwrap();
754 let var_name = caps.get(1).unwrap().as_str();
755 if let Some(color) = self.manager.resolve_variable_color(var_name).await {
756 let range = Range::new(
757 crate::types::offset_to_position(&text, match_all.start()),
758 crate::types::offset_to_position(&text, match_all.end()),
759 );
760 colors.push(ColorInformation { range, color });
761 }
762 }
763
764 Ok(colors)
765 }
766
767 async fn color_presentation(
768 &self,
769 params: ColorPresentationParams,
770 ) -> tower_lsp::jsonrpc::Result<Vec<ColorPresentation>> {
771 if !self.runtime_config.enable_color_provider {
772 return Ok(Vec::new());
773 }
774 Ok(generate_color_presentations(params.color, params.range))
775 }
776
777 async fn rename(
778 &self,
779 params: RenameParams,
780 ) -> tower_lsp::jsonrpc::Result<Option<WorkspaceEdit>> {
781 let uri = params.text_document_position.text_document.uri;
782 let position = params.text_document_position.position;
783 let new_name = params.new_name;
784
785 let text = {
786 let docs = self.document_map.read().await;
787 docs.get(&uri).cloned()
788 };
789 let text = match text {
790 Some(text) => text,
791 None => return Ok(None),
792 };
793
794 let old_name = match self.get_word_at_position(&text, position) {
795 Some(word) => word,
796 None => return Ok(None),
797 };
798
799 let (definitions, usages) = self.manager.get_references(&old_name).await;
800 let mut changes: HashMap<Url, Vec<TextEdit>> = HashMap::new();
801
802 for def in definitions {
803 let range = def.name_range.unwrap_or(def.range);
804 changes.entry(def.uri.clone()).or_default().push(TextEdit {
805 range,
806 new_text: new_name.clone(),
807 });
808 }
809
810 for usage in usages {
811 let range = usage.name_range.unwrap_or(usage.range);
812 changes
813 .entry(usage.uri.clone())
814 .or_default()
815 .push(TextEdit {
816 range,
817 new_text: new_name.clone(),
818 });
819 }
820
821 Ok(Some(WorkspaceEdit {
822 changes: Some(changes),
823 document_changes: None,
824 change_annotations: None,
825 }))
826 }
827
828 #[allow(deprecated)]
829 async fn document_symbol(
830 &self,
831 params: DocumentSymbolParams,
832 ) -> tower_lsp::jsonrpc::Result<Option<DocumentSymbolResponse>> {
833 let vars = self
834 .manager
835 .get_document_variables(¶ms.text_document.uri)
836 .await;
837 let symbols: Vec<DocumentSymbol> = vars
838 .into_iter()
839 .map(|var| DocumentSymbol {
840 name: var.name,
841 detail: Some(var.value),
842 kind: SymbolKind::VARIABLE,
843 tags: None,
844 deprecated: None,
845 range: var.range,
846 selection_range: var.range,
847 children: None,
848 })
849 .collect();
850
851 Ok(Some(DocumentSymbolResponse::Nested(symbols)))
852 }
853
854 #[allow(deprecated)]
855 async fn symbol(
856 &self,
857 params: WorkspaceSymbolParams,
858 ) -> tower_lsp::jsonrpc::Result<Option<Vec<SymbolInformation>>> {
859 let query = params.query.to_lowercase();
860 let vars = self.manager.get_all_variables().await;
861 let mut symbols = Vec::new();
862
863 for var in vars {
864 if !query.is_empty() && !var.name.to_lowercase().contains(&query) {
865 continue;
866 }
867 symbols.push(SymbolInformation {
868 name: var.name,
869 kind: SymbolKind::VARIABLE,
870 tags: None,
871 deprecated: None,
872 location: Location::new(var.uri, var.range),
873 container_name: None,
874 });
875 }
876
877 Ok(Some(symbols))
878 }
879}
880
881impl CssVariableLsp {
882 pub async fn scan_workspace_folders(&self, folders: Vec<WorkspaceFolder>) {
884 let folder_uris: Vec<Url> = folders.iter().map(|f| f.uri.clone()).collect();
885
886 self.client
887 .log_message(
888 MessageType::INFO,
889 format!("Scanning {} workspace folders...", folder_uris.len()),
890 )
891 .await;
892
893 let manager = self.manager.clone();
894 let client = self.client.clone();
895
896 let mut last_logged_percentage = 0;
897 let result = crate::workspace::scan_workspace(folder_uris, &manager, |current, total| {
898 if total == 0 {
899 return;
900 }
901 let percentage = ((current as f64 / total as f64) * 100.0).round() as i32;
902 if percentage - last_logged_percentage >= 20 || current == total {
903 last_logged_percentage = percentage;
904 let client = client.clone();
905 tokio::spawn(async move {
906 client
907 .log_message(
908 MessageType::INFO,
909 format!(
910 "Scanning CSS files: {}/{} ({}%)",
911 current, total, percentage
912 ),
913 )
914 .await;
915 });
916 }
917 })
918 .await;
919
920 match result {
921 Ok(_) => {
922 let total_vars = manager.get_all_variables().await.len();
923 self.client
924 .log_message(
925 MessageType::INFO,
926 format!(
927 "Workspace scan complete. Found {} CSS variables.",
928 total_vars
929 ),
930 )
931 .await;
932 }
933 Err(e) => {
934 self.client
935 .log_message(MessageType::ERROR, format!("Workspace scan failed: {}", e))
936 .await;
937 }
938 }
939
940 self.validate_all_open_documents().await;
941 }
942}
943
944fn is_css_like(path: &str) -> bool {
945 path.ends_with(".css")
946 || path.ends_with(".scss")
947 || path.ends_with(".sass")
948 || path.ends_with(".less")
949}
950
951fn is_html_like(path: &str) -> bool {
952 path.ends_with(".html")
953 || path.ends_with(".vue")
954 || path.ends_with(".svelte")
955 || path.ends_with(".astro")
956 || path.ends_with(".ripple")
957}
958
959fn clamp_to_char_boundary(text: &str, mut idx: usize) -> usize {
960 if idx > text.len() {
961 idx = text.len();
962 }
963 while idx > 0 && !text.is_char_boundary(idx) {
964 idx -= 1;
965 }
966 idx
967}
968
969fn is_word_char(c: char) -> bool {
970 c.is_ascii_alphanumeric() || c == '-' || c == '_'
971}
972
973fn find_context_colon(before_cursor: &str) -> Option<usize> {
974 let mut in_braces = 0i32;
975 let mut in_parens = 0i32;
976 let mut last_colon: i32 = -1;
977 let mut last_semicolon: i32 = -1;
978 let mut last_brace: i32 = -1;
979
980 for (idx, ch) in before_cursor.char_indices().rev() {
981 match ch {
982 ')' => in_parens += 1,
983 '(' => {
984 in_parens -= 1;
985 if in_parens < 0 {
986 break;
987 }
988 }
989 '}' => in_braces += 1,
990 '{' => {
991 in_braces -= 1;
992 if in_braces < 0 {
993 last_brace = idx as i32;
994 break;
995 }
996 }
997 ':' if in_parens == 0 && in_braces == 0 && last_colon == -1 => {
998 last_colon = idx as i32;
999 }
1000 ';' if in_parens == 0 && in_braces == 0 && last_semicolon == -1 => {
1001 last_semicolon = idx as i32;
1002 }
1003 _ => {}
1004 }
1005 }
1006
1007 if last_colon > last_semicolon && last_colon > last_brace {
1008 Some(last_colon as usize)
1009 } else {
1010 None
1011 }
1012}
1013
1014fn get_property_name_from_context(before_cursor: &str) -> Option<String> {
1015 let colon_pos = find_context_colon(before_cursor)?;
1016 let before_colon = before_cursor[..colon_pos].trim_end();
1017 if before_colon.is_empty() {
1018 return None;
1019 }
1020
1021 let mut start = before_colon.len();
1022 for (idx, ch) in before_colon.char_indices().rev() {
1023 if is_word_char(ch) {
1024 start = idx;
1025 } else {
1026 break;
1027 }
1028 }
1029
1030 if start >= before_colon.len() {
1031 return None;
1032 }
1033
1034 Some(before_colon[start..].to_lowercase())
1035}
1036
1037fn score_variable_relevance(var_name: &str, property_name: Option<&str>) -> i32 {
1038 let property_name = match property_name {
1039 Some(name) => name,
1040 None => return -1,
1041 };
1042
1043 let lower_var_name = var_name.to_lowercase();
1044
1045 let color_properties = [
1046 "color",
1047 "background-color",
1048 "background",
1049 "border-color",
1050 "outline-color",
1051 "text-decoration-color",
1052 "fill",
1053 "stroke",
1054 ];
1055 if color_properties.contains(&property_name) {
1056 if lower_var_name.contains("color")
1057 || lower_var_name.contains("bg")
1058 || lower_var_name.contains("background")
1059 || lower_var_name.contains("primary")
1060 || lower_var_name.contains("secondary")
1061 || lower_var_name.contains("accent")
1062 || lower_var_name.contains("text")
1063 || lower_var_name.contains("border")
1064 || lower_var_name.contains("link")
1065 {
1066 return 10;
1067 }
1068 if lower_var_name.contains("spacing")
1069 || lower_var_name.contains("margin")
1070 || lower_var_name.contains("padding")
1071 || lower_var_name.contains("size")
1072 || lower_var_name.contains("width")
1073 || lower_var_name.contains("height")
1074 || lower_var_name.contains("font")
1075 || lower_var_name.contains("weight")
1076 || lower_var_name.contains("radius")
1077 {
1078 return 0;
1079 }
1080 return 5;
1081 }
1082
1083 let spacing_properties = [
1084 "margin",
1085 "margin-top",
1086 "margin-right",
1087 "margin-bottom",
1088 "margin-left",
1089 "padding",
1090 "padding-top",
1091 "padding-right",
1092 "padding-bottom",
1093 "padding-left",
1094 "gap",
1095 "row-gap",
1096 "column-gap",
1097 ];
1098 if spacing_properties.contains(&property_name) {
1099 if lower_var_name.contains("spacing")
1100 || lower_var_name.contains("margin")
1101 || lower_var_name.contains("padding")
1102 || lower_var_name.contains("gap")
1103 {
1104 return 10;
1105 }
1106 if lower_var_name.contains("color")
1107 || lower_var_name.contains("bg")
1108 || lower_var_name.contains("background")
1109 {
1110 return 0;
1111 }
1112 return 5;
1113 }
1114
1115 let size_properties = [
1116 "width",
1117 "height",
1118 "max-width",
1119 "max-height",
1120 "min-width",
1121 "min-height",
1122 "font-size",
1123 ];
1124 if size_properties.contains(&property_name) {
1125 if lower_var_name.contains("width")
1126 || lower_var_name.contains("height")
1127 || lower_var_name.contains("size")
1128 {
1129 return 10;
1130 }
1131 if lower_var_name.contains("color")
1132 || lower_var_name.contains("bg")
1133 || lower_var_name.contains("background")
1134 {
1135 return 0;
1136 }
1137 return 5;
1138 }
1139
1140 if property_name.contains("radius") {
1141 if lower_var_name.contains("radius") || lower_var_name.contains("rounded") {
1142 return 10;
1143 }
1144 if lower_var_name.contains("color")
1145 || lower_var_name.contains("bg")
1146 || lower_var_name.contains("background")
1147 {
1148 return 0;
1149 }
1150 return 5;
1151 }
1152
1153 let font_properties = ["font-family", "font-weight", "font-style"];
1154 if font_properties.contains(&property_name) {
1155 if lower_var_name.contains("font") {
1156 return 10;
1157 }
1158 if lower_var_name.contains("color") || lower_var_name.contains("spacing") {
1159 return 0;
1160 }
1161 return 5;
1162 }
1163
1164 -1
1165}
1166
1167fn apply_change_to_text(text: &mut String, change: &TextDocumentContentChangeEvent) {
1168 if let Some(range) = change.range {
1169 let start = position_to_offset(text, range.start);
1170 let end = position_to_offset(text, range.end);
1171 if let (Some(start), Some(end)) = (start, end) {
1172 if start <= end && end <= text.len() {
1173 text.replace_range(start..end, &change.text);
1174 return;
1175 }
1176 }
1177 }
1178 *text = change.text.clone();
1179}
1180
1181fn find_value_range_in_definition(text: &str, def: &crate::types::CssVariable) -> Option<Range> {
1182 let start = position_to_offset(text, def.range.start)?;
1183 let end = position_to_offset(text, def.range.end)?;
1184 if start >= end || end > text.len() {
1185 return None;
1186 }
1187 let def_text = &text[start..end];
1188 let colon_index = def_text.find(':')?;
1189 let after_colon = &def_text[colon_index + 1..];
1190 let value_trim = def.value.trim();
1191 let value_index = after_colon.find(value_trim)?;
1192
1193 let absolute_start = start + colon_index + 1 + value_index;
1194 let absolute_end = absolute_start + value_trim.len();
1195
1196 Some(Range::new(
1197 crate::types::offset_to_position(text, absolute_start),
1198 crate::types::offset_to_position(text, absolute_end),
1199 ))
1200}
1201
1202#[cfg(test)]
1203mod tests {
1204 use super::*;
1205
1206 fn test_word_extraction(css: &str, cursor_pos: usize) -> Option<String> {
1207 use tower_lsp::lsp_types::Position;
1208 let position = Position {
1209 line: 0,
1210 character: cursor_pos as u32,
1211 };
1212
1213 let offset = position_to_offset(css, position)?;
1214 let offset = clamp_to_char_boundary(css, offset);
1215 let before = &css[..offset];
1216 let after = &css[offset..];
1217
1218 let left = before
1219 .rsplit(|c: char| !is_word_char(c))
1220 .next()
1221 .unwrap_or("");
1222 let right = after.split(|c: char| !is_word_char(c)).next().unwrap_or("");
1223 let word = format!("{}{}", left, right);
1224 if word.starts_with("--") {
1225 Some(word)
1226 } else {
1227 None
1228 }
1229 }
1230
1231 #[test]
1232 fn test_word_extraction_preserves_fallbacks() {
1233 let css = "background: var(--primary-color, blue);";
1235 let result = test_word_extraction(css, 20); assert_eq!(result, Some("--primary-color".to_string()));
1237
1238 let css2 = "color: var(--secondary-color, #ccc);";
1240 let result2 = test_word_extraction(css2, 15); assert_eq!(result2, Some("--secondary-color".to_string()));
1242
1243 let css3 = "border: var(--accent-color, var(--fallback, black));";
1245 let result3 = test_word_extraction(css3, 16); assert_eq!(result3, Some("--accent-color".to_string()));
1247
1248 let css4 = "--theme-color: red;";
1250 let result4 = test_word_extraction(css4, 5); assert_eq!(result4, Some("--theme-color".to_string()));
1252
1253 let css5 = "margin: var(--spacing);";
1255 let result5 = test_word_extraction(css5, 15); assert_eq!(result5, Some("--spacing".to_string()));
1257 }
1258}