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