1use ls_types::Uri;
2use std::collections::{HashMap, HashSet};
3use std::path::PathBuf;
4use std::str::FromStr;
5use std::sync::Arc;
6
7use ls_types::{
8 CodeAction, CodeActionContext, CodeActionKind, CodeActionOrCommand, CodeActionParams,
9 CodeActionProviderCapability, CodeActionResponse, ColorInformation, ColorPresentation,
10 ColorPresentationParams, ColorProviderCapability, CompletionItem, CompletionItemKind,
11 CompletionOptions, CompletionParams, CompletionResponse, CreateFilesParams, DeleteFilesParams,
12 Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams, DidChangeTextDocumentParams,
13 DidChangeWatchedFilesParams, DidChangeWorkspaceFoldersParams, DidCloseTextDocumentParams,
14 DidOpenTextDocumentParams, DidSaveTextDocumentParams, DocumentColorParams, DocumentSymbol,
15 DocumentSymbolParams, DocumentSymbolResponse, FileChangeType, GotoDefinitionParams,
16 GotoDefinitionResponse, Hover, HoverContents, HoverParams, InitializeParams, InitializeResult,
17 Location, MarkupContent, MarkupKind, MessageType, OneOf, Position, PrepareRenameResponse,
18 Range, ReferenceParams, RenameFilesParams, RenameOptions, RenameParams, ServerCapabilities,
19 SymbolInformation, SymbolKind, TextDocumentContentChangeEvent, TextDocumentPositionParams,
20 TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit, WillSaveTextDocumentParams,
21 WorkDoneProgressOptions, WorkspaceEdit, WorkspaceFolder, WorkspaceFoldersServerCapabilities,
22 WorkspaceServerCapabilities, WorkspaceSymbolParams, WorkspaceSymbolResponse,
23};
24use regex::Regex;
25use tokio::sync::RwLock;
26use tower_lsp_server::{Client, LanguageServer};
27
28use crate::color::{generate_color_presentations, parse_color, NormalizedColorKey};
29use crate::completion_context::{
30 completion_value_context_slice, get_value_context_info, score_variable_relevance,
31};
32#[cfg(test)]
33use crate::completion_context::{
34 find_html_style_attribute_slice, find_html_style_block_slice, find_js_string_segment,
35};
36use crate::document_kind::{
37 apply_config_patch, build_lookup_extension_map, resolve_document_kind, ClientConfigPatch,
38 DocumentKind,
39};
40use crate::manager::CssVariableManager;
41use crate::parsers::{parse_css_document, parse_html_document};
42use crate::path_display::{format_uri_for_display, to_normalized_fs_path, PathDisplayOptions};
43use crate::runtime_config::{RuntimeConfig, UndefinedVarFallbackMode};
44use crate::specificity::{
45 calculate_specificity, compare_specificity, format_specificity, matches_context,
46 sort_by_cascade,
47};
48use crate::text_utils::{
49 apply_change_to_text, clamp_to_char_boundary, find_value_range_in_definition, is_word_byte,
50 is_word_char, range_contains, range_contains_position,
51};
52use crate::types::{position_to_offset, Config};
53
54fn code_actions_for_undefined_variables(
55 uri: &Uri,
56 text: &str,
57 context: &CodeActionContext,
58 runtime_config: &RuntimeConfig,
59) -> Vec<CodeActionOrCommand> {
60 let mut actions = Vec::new();
61
62 for diag in &context.diagnostics {
63 let code = match diag.code.as_ref() {
64 Some(ls_types::NumberOrString::String(code)) => code.as_str(),
65 _ => continue,
66 };
67 if code != "css-variable-lsp.undefined-variable" {
68 continue;
69 }
70
71 let name = diag
72 .data
73 .as_ref()
74 .and_then(|d| d.get("name"))
75 .and_then(|v| v.as_str())
76 .map(|s| s.to_string());
77 let name = match name {
78 Some(name) => name,
79 None => continue,
80 };
81
82 let insert_text = format!(":root {{\n {}: ;\n}}\n\n", name);
85
86 let edit = WorkspaceEdit {
87 changes: Some(HashMap::from([(
88 uri.clone(),
89 vec![TextEdit {
90 range: Range::new(Position::new(0, 0), Position::new(0, 0)),
91 new_text: insert_text,
92 }],
93 )])),
94 document_changes: None,
95 change_annotations: None,
96 };
97
98 let action = CodeAction {
99 title: format!("Create {} in :root", name),
100 kind: Some(CodeActionKind::QUICKFIX),
101 diagnostics: Some(vec![diag.clone()]),
102 edit: Some(edit),
103 command: None,
104 is_preferred: Some(true),
105 disabled: None,
106 data: None,
107 };
108
109 actions.push(CodeActionOrCommand::CodeAction(action));
110
111 if runtime_config.suggest_add_fallback {
115 if let (Some(start), Some(end)) = (
116 crate::types::position_to_offset(text, diag.range.start),
117 crate::types::position_to_offset(text, diag.range.end),
118 ) {
119 if start < end && end <= text.len() {
120 let slice = &text[start..end];
121 if slice.starts_with("var(") && slice.ends_with(')') && !slice.contains(',') {
122 let new_text = slice.trim_end_matches(')').to_string() + ", )";
123 let edit = WorkspaceEdit {
124 changes: Some(HashMap::from([(
125 uri.clone(),
126 vec![TextEdit {
127 range: diag.range,
128 new_text,
129 }],
130 )])),
131 document_changes: None,
132 change_annotations: None,
133 };
134
135 let action = CodeAction {
136 title: format!("Add fallback to {}", name),
137 kind: Some(CodeActionKind::QUICKFIX),
138 diagnostics: Some(vec![diag.clone()]),
139 edit: Some(edit),
140 command: None,
141 is_preferred: Some(false),
142 disabled: None,
143 data: None,
144 };
145 actions.push(CodeActionOrCommand::CodeAction(action));
146 }
147 }
148 }
149 }
150 }
151
152 actions
153}
154
155fn code_actions_for_replaceable_literal_colors(
156 uri: &Uri,
157 context: &CodeActionContext,
158 runtime_config: &RuntimeConfig,
159) -> Vec<CodeActionOrCommand> {
160 if !runtime_config.suggest_exact_color_variables {
161 return Vec::new();
162 }
163
164 let mut actions = Vec::new();
165
166 for diag in &context.diagnostics {
167 let code = match diag.code.as_ref() {
168 Some(ls_types::NumberOrString::String(code)) => code.as_str(),
169 _ => continue,
170 };
171 if code != "css-variable-lsp.literal-color-replaceable" {
172 continue;
173 }
174
175 let replacements = diag
176 .data
177 .as_ref()
178 .and_then(|d| d.get("replacements"))
179 .and_then(|v| v.as_array())
180 .cloned()
181 .unwrap_or_default();
182
183 for replacement in replacements {
184 let name = match replacement.get("name").and_then(|v| v.as_str()) {
185 Some(name) => name,
186 None => continue,
187 };
188
189 let edit = WorkspaceEdit {
190 changes: Some(HashMap::from([(
191 uri.clone(),
192 vec![TextEdit {
193 range: diag.range,
194 new_text: format!("var({})", name),
195 }],
196 )])),
197 document_changes: None,
198 change_annotations: None,
199 };
200
201 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
202 title: format!("Replace with var({})", name),
203 kind: Some(CodeActionKind::QUICKFIX),
204 diagnostics: Some(vec![diag.clone()]),
205 edit: Some(edit),
206 command: None,
207 is_preferred: Some(false),
208 disabled: None,
209 data: None,
210 }));
211 }
212 }
213
214 actions
215}
216
217pub struct CssVariableLsp {
218 client: Client,
219 manager: Arc<CssVariableManager>,
220 document_map: Arc<RwLock<HashMap<Uri, String>>>,
221 runtime_config: RuntimeConfig,
222 workspace_folder_paths: Arc<RwLock<Vec<PathBuf>>>,
223 root_folder_path: Arc<RwLock<Option<PathBuf>>>,
224 has_workspace_folder_capability: Arc<RwLock<bool>>,
225 has_diagnostic_related_information: Arc<RwLock<bool>>,
226 usage_regex: Regex,
227 var_usage_regex: Regex,
228 lookup_extension_map: Arc<RwLock<HashMap<String, DocumentKind>>>,
229 live_config: Arc<RwLock<Config>>,
230 document_language_map: Arc<RwLock<HashMap<Uri, String>>>,
231 document_usage_map: Arc<RwLock<HashMap<Uri, HashSet<String>>>>,
232 usage_index: Arc<RwLock<HashMap<String, HashSet<Uri>>>>,
233 color_occurrence_uris: Arc<RwLock<HashSet<Uri>>>,
235}
236
237impl CssVariableLsp {
238 pub fn new(client: Client, runtime_config: RuntimeConfig) -> Self {
239 let config = Config::from_runtime(&runtime_config);
240 let lookup_extension_map = build_lookup_extension_map(&config.lookup_files);
241 let live_config = config.clone();
242 Self {
243 client,
244 manager: Arc::new(CssVariableManager::new(config)),
245 document_map: Arc::new(RwLock::new(HashMap::new())),
246 runtime_config,
247 workspace_folder_paths: Arc::new(RwLock::new(Vec::new())),
248 root_folder_path: Arc::new(RwLock::new(None)),
249 has_workspace_folder_capability: Arc::new(RwLock::new(false)),
250 has_diagnostic_related_information: Arc::new(RwLock::new(false)),
251 usage_regex: Regex::new(r"var\((--[\w-]+)(?:\s*,\s*([^)]+))?\)").unwrap(),
252 var_usage_regex: Regex::new(r"var\((--[\w-]+)\)").unwrap(),
253 lookup_extension_map: Arc::new(RwLock::new(lookup_extension_map)),
254 live_config: Arc::new(RwLock::new(live_config)),
255 document_language_map: Arc::new(RwLock::new(HashMap::new())),
256 document_usage_map: Arc::new(RwLock::new(HashMap::new())),
257 usage_index: Arc::new(RwLock::new(HashMap::new())),
258 color_occurrence_uris: Arc::new(RwLock::new(HashSet::new())),
259 }
260 }
261
262 async fn update_workspace_folder_paths(&self, folders: Option<Vec<WorkspaceFolder>>) {
263 let mut paths = Vec::new();
264 if let Some(folders) = folders {
265 for folder in folders {
266 if let Some(path) = to_normalized_fs_path(&folder.uri) {
267 paths.push(path);
268 }
269 }
270 }
271 paths.sort_by_key(|b| std::cmp::Reverse(b.to_string_lossy().len()));
272 let mut stored = self.workspace_folder_paths.write().await;
273 *stored = paths;
274 }
275
276 async fn parse_document_text(&self, uri: &Uri, text: &str, language_id: Option<&str>) {
277 self.manager.remove_document(uri).await;
278
279 let path = uri.path().as_str();
280 let lookup_map = self.lookup_extension_map.read().await.clone();
281 let kind = resolve_document_kind(path, language_id, &lookup_map);
282 let result = match kind {
283 Some(DocumentKind::Html) => parse_html_document(text, uri, &self.manager).await,
284 Some(DocumentKind::Css) => parse_css_document(text, uri, &self.manager).await,
285 None => return,
286 };
287
288 if let Err(e) = result {
289 self.client
290 .log_message(MessageType::ERROR, format!("Parse error: {}", e))
291 .await;
292 }
293
294 self.manager.rebuild_color_index().await;
295 }
296
297 async fn validate_document_text(&self, uri: &Uri, text: &str) {
298 let has_related_info = *self.has_diagnostic_related_information.read().await;
299 validate_document_text_with(
300 &self.client,
301 self.manager.as_ref(),
302 &self.usage_regex,
303 self.runtime_config.undefined_var_fallback,
304 has_related_info,
305 uri,
306 text,
307 &self.document_usage_map,
308 &self.usage_index,
309 Some(&self.color_occurrence_uris),
310 )
311 .await;
312 }
313
314 async fn validate_all_open_documents(&self) {
315 let has_related_info = *self.has_diagnostic_related_information.read().await;
316 let docs_snapshot = {
317 let docs = self.document_map.read().await;
318 docs.iter()
319 .map(|(uri, text)| (uri.clone(), text.clone()))
320 .collect::<Vec<_>>()
321 };
322
323 for (uri, text) in docs_snapshot {
324 validate_document_text_with(
325 &self.client,
326 self.manager.as_ref(),
327 &self.usage_regex,
328 self.runtime_config.undefined_var_fallback,
329 has_related_info,
330 &uri,
331 &text,
332 &self.document_usage_map,
333 &self.usage_index,
334 Some(&self.color_occurrence_uris),
335 )
336 .await;
337 }
338 }
339
340 async fn revalidate_affected_documents(
341 &self,
342 changed_names: &HashSet<String>,
343 exclude_uri: Option<&Uri>,
344 revalidate_color_occurrences: bool,
345 ) {
346 if changed_names.is_empty() && !revalidate_color_occurrences {
347 return;
348 }
349
350 let mut affected_uris = HashSet::new();
351 {
352 let index = self.usage_index.read().await;
353 for name in changed_names {
354 if let Some(uris) = index.get(name) {
355 for uri in uris {
356 if exclude_uri != Some(uri) {
357 affected_uris.insert(uri.clone());
358 }
359 }
360 }
361 }
362 }
363
364 if revalidate_color_occurrences {
366 let color_uris = self.color_occurrence_uris.read().await;
367 for uri in color_uris.iter() {
368 if exclude_uri != Some(uri) {
369 affected_uris.insert(uri.clone());
370 }
371 }
372 }
373
374 if affected_uris.is_empty() {
375 return;
376 }
377
378 let has_related_info = *self.has_diagnostic_related_information.read().await;
379 let affected_snapshot = {
380 let docs = self.document_map.read().await;
381 affected_uris
382 .into_iter()
383 .filter_map(|uri| docs.get(&uri).map(|text| (uri.clone(), text.clone())))
384 .collect::<Vec<_>>()
385 };
386
387 for (uri, text) in affected_snapshot {
388 validate_document_text_with(
389 &self.client,
390 self.manager.as_ref(),
391 &self.usage_regex,
392 self.runtime_config.undefined_var_fallback,
393 has_related_info,
394 &uri,
395 &text,
396 &self.document_usage_map,
397 &self.usage_index,
398 Some(&self.color_occurrence_uris),
399 )
400 .await;
401 }
402 }
403
404 async fn update_document_from_disk(&self, uri: &Uri) {
405 let path = match to_normalized_fs_path(uri) {
406 Some(path) => path,
407 None => {
408 self.manager.remove_document(uri).await;
409 return;
410 }
411 };
412
413 match tokio::fs::read_to_string(&path).await {
414 Ok(text) => {
415 self.parse_document_text(uri, &text, None).await;
416 }
417 Err(_) => {
418 self.manager.remove_document(uri).await;
419 self.manager.rebuild_color_index().await;
420 }
421 }
422 }
423
424 async fn apply_content_changes(
425 &self,
426 uri: &Uri,
427 changes: Vec<TextDocumentContentChangeEvent>,
428 ) -> Option<String> {
429 let mut docs = self.document_map.write().await;
430 let mut text = if let Some(existing) = docs.get(uri) {
431 existing.clone()
432 } else {
433 if changes.len() == 1 && changes[0].range.is_none() {
434 let new_text = changes[0].text.clone();
435 docs.insert(uri.clone(), new_text.clone());
436 return Some(new_text);
437 }
438 return None;
439 };
440
441 for change in changes {
442 apply_change_to_text(&mut text, &change);
443 }
444
445 docs.insert(uri.clone(), text.clone());
446 Some(text)
447 }
448
449 fn get_word_at_position(&self, text: &str, position: Position) -> Option<String> {
450 let offset = position_to_offset(text, position)?;
451 let offset = clamp_to_char_boundary(text, offset);
452 let before = &text[..offset];
453 let after = &text[offset..];
454
455 let left = before
456 .rsplit(|c: char| !is_word_char(c))
457 .next()
458 .unwrap_or("");
459 let right = after.split(|c: char| !is_word_char(c)).next().unwrap_or("");
460 let word = format!("{}{}", left, right);
461 if word.starts_with("--") {
462 Some(word)
463 } else {
464 None
465 }
466 }
467
468 async fn is_document_open(&self, uri: &Uri) -> bool {
469 let docs = self.document_map.read().await;
470 docs.contains_key(uri)
471 }
472}
473
474impl LanguageServer for CssVariableLsp {
476 async fn initialize(
477 &self,
478 params: InitializeParams,
479 ) -> tower_lsp_server::jsonrpc::Result<InitializeResult> {
480 self.client
481 .log_message(MessageType::INFO, "CSS Variable LSP (Rust) initializing...")
482 .await;
483
484 let has_workspace_folders = params
485 .capabilities
486 .workspace
487 .as_ref()
488 .and_then(|w| w.workspace_folders)
489 .unwrap_or(false);
490 let has_related_info = params
491 .capabilities
492 .text_document
493 .as_ref()
494 .and_then(|t| t.publish_diagnostics.as_ref())
495 .and_then(|p| p.related_information)
496 .unwrap_or(false);
497
498 {
499 let mut cap = self.has_workspace_folder_capability.write().await;
500 *cap = has_workspace_folders;
501 }
502 {
503 let mut rel = self.has_diagnostic_related_information.write().await;
504 *rel = has_related_info;
505 }
506
507 #[allow(deprecated)]
508 if let Some(root_uri) = params.root_uri.as_ref() {
509 let root_path = to_normalized_fs_path(root_uri);
510 let mut root = self.root_folder_path.write().await;
511 *root = root_path;
512 } else {
513 #[allow(deprecated)]
514 if let Some(root_path) = params.root_path.as_ref() {
515 let mut root = self.root_folder_path.write().await;
516 *root = Some(PathBuf::from(root_path));
517 }
518 }
519
520 self.update_workspace_folder_paths(params.workspace_folders.clone())
521 .await;
522
523 let mut capabilities = ServerCapabilities {
524 text_document_sync: Some(TextDocumentSyncCapability::Kind(
525 TextDocumentSyncKind::INCREMENTAL,
526 )),
527 completion_provider: Some(CompletionOptions {
528 resolve_provider: Some(true),
529 trigger_characters: Some(vec!["-".to_string(), "(".to_string(), ":".to_string()]),
530 work_done_progress_options: WorkDoneProgressOptions::default(),
531 all_commit_characters: None,
532 completion_item: None,
533 }),
534 hover_provider: Some(ls_types::HoverProviderCapability::Simple(true)),
535 definition_provider: Some(OneOf::Left(true)),
536 references_provider: Some(OneOf::Left(true)),
537 code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
538 rename_provider: Some(OneOf::Right(RenameOptions {
539 prepare_provider: Some(true),
540 work_done_progress_options: WorkDoneProgressOptions::default(),
541 })),
542
543 document_symbol_provider: Some(OneOf::Left(true)),
544 workspace_symbol_provider: Some(OneOf::Left(true)),
545 color_provider: if self.runtime_config.enable_color_provider {
546 Some(ColorProviderCapability::Simple(true))
547 } else {
548 None
549 },
550 ..Default::default()
551 };
552
553 if has_workspace_folders {
554 capabilities.workspace = Some(WorkspaceServerCapabilities {
555 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
556 supported: Some(true),
557 change_notifications: Some(OneOf::Left(true)),
558 }),
559 file_operations: None,
560 });
561 }
562
563 Ok(InitializeResult {
564 capabilities,
565 offset_encoding: Some("utf-16".to_string()),
566 server_info: Some(ls_types::ServerInfo {
567 name: "css-variable-lsp-rust".to_string(),
568 version: Some("0.1.0".to_string()),
569 }),
570 })
571 }
572
573 async fn initialized(&self, _params: ls_types::InitializedParams) {
574 self.client
575 .log_message(MessageType::INFO, "CSS Variable LSP (Rust) initialized!")
576 .await;
577
578 if let Ok(Some(folders)) = self.client.workspace_folders().await {
579 self.update_workspace_folder_paths(Some(folders.clone()))
580 .await;
581 self.scan_workspace_folders(folders).await;
582 }
583 }
584
585 async fn shutdown(&self) -> tower_lsp_server::jsonrpc::Result<()> {
586 Ok(())
587 }
588
589 async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
590 let patch =
594 serde_json::from_value::<ClientConfigPatch>(params.settings.clone()).or_else(|_| {
595 params
596 .settings
597 .get("cssVariableLsp")
598 .cloned()
599 .ok_or_else(|| {
600 serde_json::Error::io(std::io::Error::new(
601 std::io::ErrorKind::InvalidInput,
602 "missing cssVariableLsp key",
603 ))
604 })
605 .and_then(serde_json::from_value::<ClientConfigPatch>)
606 });
607
608 let patch = match patch {
609 Ok(patch) => patch,
610 Err(err) => {
611 self.client
612 .log_message(
613 MessageType::WARNING,
614 format!("Failed to parse didChangeConfiguration settings: {}", err),
615 )
616 .await;
617 return;
618 }
619 };
620
621 let mut config = self.live_config.read().await.clone();
622 let prev_lookup_files = config.lookup_files.clone();
623 config = apply_config_patch(config, patch);
624
625 {
626 let mut stored = self.live_config.write().await;
627 *stored = config.clone();
628 }
629
630 self.manager.set_config(config.clone()).await;
631
632 if config.lookup_files != prev_lookup_files {
634 let new_map = build_lookup_extension_map(&config.lookup_files);
635 let mut stored = self.lookup_extension_map.write().await;
636 *stored = new_map;
637
638 if let Ok(Some(folders)) = self.client.workspace_folders().await {
640 self.scan_workspace_folders(folders).await;
641 }
642 }
643
644 self.validate_all_open_documents().await;
646 }
647
648 async fn did_open(&self, params: DidOpenTextDocumentParams) {
649 let uri = params.text_document.uri;
650 let text = params.text_document.text;
651 let language_id = params.text_document.language_id;
652
653 let old_names = self.manager.get_document_variable_names(&uri).await;
654 let old_colors = self.manager.get_document_resolved_color_keys(&uri).await;
655
656 {
657 let mut docs = self.document_map.write().await;
658 docs.insert(uri.clone(), text.clone());
659 }
660 {
661 let mut langs = self.document_language_map.write().await;
662 langs.insert(uri.clone(), language_id.clone());
663 }
664 self.parse_document_text(&uri, &text, Some(&language_id))
665 .await;
666
667 let new_names = self.manager.get_document_variable_names(&uri).await;
668 let new_colors = self.manager.get_document_resolved_color_keys(&uri).await;
669
670 self.validate_document_text(&uri, &text).await;
671
672 let colors_changed = old_colors != new_colors;
673 if old_names != new_names || colors_changed {
674 let added_names: HashSet<_> = new_names.difference(&old_names).cloned().collect();
675 let removed_names: HashSet<_> = old_names.difference(&new_names).cloned().collect();
676 let mut changed_names = added_names;
677 changed_names.extend(removed_names);
678 self.revalidate_affected_documents(&changed_names, Some(&uri), colors_changed)
679 .await;
680 }
681 }
682
683 async fn did_change(&self, params: DidChangeTextDocumentParams) {
684 let uri = params.text_document.uri;
685 let changes = params.content_changes;
686
687 let old_names = self.manager.get_document_variable_names(&uri).await;
688 let old_colors = self.manager.get_document_resolved_color_keys(&uri).await;
689
690 let updated_text = match self.apply_content_changes(&uri, changes).await {
691 Some(text) => text,
692 None => return,
693 };
694 let language_id = {
695 let langs = self.document_language_map.read().await;
696 langs.get(&uri).cloned()
697 };
698 self.parse_document_text(&uri, &updated_text, language_id.as_deref())
699 .await;
700
701 let new_names = self.manager.get_document_variable_names(&uri).await;
702 let new_colors = self.manager.get_document_resolved_color_keys(&uri).await;
703
704 self.validate_document_text(&uri, &updated_text).await;
705
706 let colors_changed = old_colors != new_colors;
707 if old_names != new_names || colors_changed {
708 let added_names: HashSet<_> = new_names.difference(&old_names).cloned().collect();
709 let removed_names: HashSet<_> = old_names.difference(&new_names).cloned().collect();
710 let mut changed_names = added_names;
711 changed_names.extend(removed_names);
712 self.revalidate_affected_documents(&changed_names, Some(&uri), colors_changed)
713 .await;
714 }
715 }
716
717 async fn will_save(&self, _params: WillSaveTextDocumentParams) {
718 }
720
721 async fn will_save_wait_until(
722 &self,
723 _params: WillSaveTextDocumentParams,
724 ) -> tower_lsp_server::jsonrpc::Result<Option<Vec<TextEdit>>> {
725 Ok(None)
727 }
728
729 async fn did_save(&self, _params: DidSaveTextDocumentParams) {
730 }
732
733 async fn did_close(&self, params: DidCloseTextDocumentParams) {
734 let uri = params.text_document.uri;
735
736 let old_names = self.manager.get_document_variable_names(&uri).await;
737 let old_colors = self.manager.get_document_resolved_color_keys(&uri).await;
738
739 {
740 let mut docs = self.document_map.write().await;
741 docs.remove(&uri);
742 }
743 {
744 let mut langs = self.document_language_map.write().await;
745 langs.remove(&uri);
746 }
747
748 {
750 let mut usage_map = self.document_usage_map.write().await;
751 if let Some(old_usages) = usage_map.remove(&uri) {
752 let mut index = self.usage_index.write().await;
753 for name in old_usages {
754 if let Some(uris) = index.get_mut(&name) {
755 uris.remove(&uri);
756 if uris.is_empty() {
757 index.remove(&name);
758 }
759 }
760 }
761 }
762 }
763
764 self.update_document_from_disk(&uri).await;
765
766 let new_names = self.manager.get_document_variable_names(&uri).await;
767 let new_colors = self.manager.get_document_resolved_color_keys(&uri).await;
768
769 let colors_changed = old_colors != new_colors;
770 if old_names != new_names || colors_changed {
771 let added_names: HashSet<_> = new_names.difference(&old_names).cloned().collect();
772 let removed_names: HashSet<_> = old_names.difference(&new_names).cloned().collect();
773 let mut changed_names = added_names;
774 changed_names.extend(removed_names);
775 self.revalidate_affected_documents(&changed_names, Some(&uri), colors_changed)
776 .await;
777 }
778 }
779
780 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
781 for change in params.changes {
782 match change.typ {
783 FileChangeType::DELETED => {
784 self.manager.remove_document(&change.uri).await;
785 }
786 FileChangeType::CREATED | FileChangeType::CHANGED
787 if !self.is_document_open(&change.uri).await =>
788 {
789 self.update_document_from_disk(&change.uri).await;
790 }
791 _ => {}
792 }
793 }
794
795 self.validate_all_open_documents().await;
796 }
797
798 async fn did_create_files(&self, params: CreateFilesParams) {
799 for file in params.files {
800 let uri = match Uri::from_str(&file.uri) {
801 Ok(uri) => uri,
802 Err(_) => continue,
803 };
804 if !self.is_document_open(&uri).await {
805 self.update_document_from_disk(&uri).await;
806 }
807 }
808 self.validate_all_open_documents().await;
809 }
810
811 async fn did_rename_files(&self, params: RenameFilesParams) {
812 for file in params.files {
813 let old_uri = match Uri::from_str(&file.old_uri) {
814 Ok(uri) => uri,
815 Err(_) => continue,
816 };
817 let new_uri = match Uri::from_str(&file.new_uri) {
818 Ok(uri) => uri,
819 Err(_) => continue,
820 };
821
822 self.manager.remove_document(&old_uri).await;
824
825 if !self.is_document_open(&new_uri).await {
827 self.update_document_from_disk(&new_uri).await;
828 }
829 }
830 self.validate_all_open_documents().await;
831 }
832
833 async fn did_delete_files(&self, params: DeleteFilesParams) {
834 for file in params.files {
835 let uri = match Uri::from_str(&file.uri) {
836 Ok(uri) => uri,
837 Err(_) => continue,
838 };
839 self.manager.remove_document(&uri).await;
840 }
841 self.validate_all_open_documents().await;
842 }
843
844 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
845 let mut current_paths = {
846 let paths = self.workspace_folder_paths.read().await;
847 paths.clone()
848 };
849
850 for removed in params.event.removed {
851 if let Some(path) = to_normalized_fs_path(&removed.uri) {
852 current_paths.retain(|p| p != &path);
853 }
854 }
855
856 for added in params.event.added {
857 if let Some(path) = to_normalized_fs_path(&added.uri) {
858 current_paths.push(path);
859 }
860 }
861
862 current_paths.sort_by_key(|b| std::cmp::Reverse(b.to_string_lossy().len()));
863
864 let mut stored = self.workspace_folder_paths.write().await;
865 *stored = current_paths;
866 }
867
868 async fn completion(
869 &self,
870 params: CompletionParams,
871 ) -> tower_lsp_server::jsonrpc::Result<Option<CompletionResponse>> {
872 let uri = params.text_document_position.text_document.uri;
873 let position = params.text_document_position.position;
874
875 let text = {
876 let docs = self.document_map.read().await;
877 docs.get(&uri).cloned()
878 };
879 let text = match text {
880 Some(text) => text,
881 None => return Ok(Some(CompletionResponse::Array(Vec::new()))),
882 };
883
884 let language_id = {
885 let langs = self.document_language_map.read().await;
886 langs.get(&uri).cloned()
887 };
888 let lookup_map = self.lookup_extension_map.read().await.clone();
889 let context = completion_value_context_slice(
890 &text,
891 position,
892 language_id.as_deref(),
893 &uri,
894 &lookup_map,
895 );
896 let context = match context {
897 Some(context) => context,
898 None => return Ok(Some(CompletionResponse::Array(Vec::new()))),
899 };
900 let value_context = get_value_context_info(context.slice, context.allow_without_braces);
901 if !value_context.is_value_context {
902 return Ok(Some(CompletionResponse::Array(Vec::new())));
903 }
904 let property_name = value_context.property_name;
905 let in_var_context = is_var_function_context_slice(context.slice);
906 let workspace_folder_paths = self.workspace_folder_paths.read().await.clone();
907 let root_folder_path = self.root_folder_path.read().await.clone();
908
909 if !in_var_context {
910 if let Some(color_key) = self.literal_color_under_cursor(&uri, position).await {
911 let variables = self.manager.get_variables_by_color_key(&color_key).await;
912 let items = variables
913 .into_iter()
914 .map(|var| {
915 let options = PathDisplayOptions {
916 mode: self.runtime_config.path_display_mode,
917 abbrev_length: self.runtime_config.path_display_abbrev_length,
918 workspace_folder_paths: &workspace_folder_paths,
919 root_folder_path: root_folder_path.as_ref(),
920 };
921 let display_path = format_uri_for_display(&var.uri, options);
922 CompletionItem {
923 label: var.name.clone(),
924 kind: Some(CompletionItemKind::VARIABLE),
925 detail: Some(format!("{} • {}", var.value, display_path)),
926 insert_text: Some(format!("var({})", var.name)),
927 documentation: Some(ls_types::Documentation::String(format!(
928 "Defined in {}",
929 display_path
930 ))),
931 ..Default::default()
932 }
933 })
934 .collect();
935 return Ok(Some(CompletionResponse::Array(items)));
936 }
937 }
938
939 let variables = self.manager.get_all_variables().await;
940
941 let mut unique_vars = HashMap::new();
942 for var in variables {
943 unique_vars.entry(var.name.clone()).or_insert(var);
944 }
945
946 let mut scored_vars: Vec<(i32, _)> = unique_vars
947 .values()
948 .map(|var| {
949 let score = score_variable_relevance(&var.name, property_name.as_deref());
950 (score, var)
951 })
952 .collect();
953
954 scored_vars.retain(|(score, _)| *score != 0);
955 scored_vars.sort_by(|(score_a, var_a), (score_b, var_b)| {
956 if score_a != score_b {
957 return score_b.cmp(score_a);
958 }
959 var_a.name.cmp(&var_b.name)
960 });
961
962 let items = scored_vars
963 .into_iter()
964 .map(|(_, var)| {
965 let options = PathDisplayOptions {
966 mode: self.runtime_config.path_display_mode,
967 abbrev_length: self.runtime_config.path_display_abbrev_length,
968 workspace_folder_paths: &workspace_folder_paths,
969 root_folder_path: root_folder_path.as_ref(),
970 };
971 let insert_text = if in_var_context {
972 var.name.clone()
973 } else {
974 format!("var({})", var.name)
975 };
976 CompletionItem {
977 label: var.name.clone(),
978 kind: Some(CompletionItemKind::VARIABLE),
979 detail: Some(var.value.clone()),
980 insert_text: Some(insert_text),
981 documentation: Some(ls_types::Documentation::String(format!(
982 "Defined in {}",
983 format_uri_for_display(&var.uri, options)
984 ))),
985 ..Default::default()
986 }
987 })
988 .collect();
989
990 Ok(Some(CompletionResponse::Array(items)))
991 }
992
993 async fn completion_resolve(
994 &self,
995 item: CompletionItem,
996 ) -> tower_lsp_server::jsonrpc::Result<CompletionItem> {
997 Ok(item)
998 }
999
1000 async fn hover(&self, params: HoverParams) -> tower_lsp_server::jsonrpc::Result<Option<Hover>> {
1001 let uri = params.text_document_position_params.text_document.uri;
1002 let position = params.text_document_position_params.position;
1003
1004 let text = {
1005 let docs = self.document_map.read().await;
1006 docs.get(&uri).cloned()
1007 };
1008 let text = match text {
1009 Some(text) => text,
1010 None => return Ok(None),
1011 };
1012
1013 let word = match self.get_word_at_position(&text, position) {
1014 Some(word) => word,
1015 None => return Ok(None),
1016 };
1017
1018 let mut definitions = self.manager.get_variables(&word).await;
1019 if definitions.is_empty() {
1020 return Ok(None);
1021 }
1022
1023 let usages = self.manager.get_usages(&word).await;
1024 let offset = match position_to_offset(&text, position) {
1025 Some(offset) => offset,
1026 None => return Ok(None),
1027 };
1028 let hover_usage = usages.iter().find(|usage| {
1029 if usage.uri != uri {
1030 return false;
1031 }
1032 let start = position_to_offset(&text, usage.range.start).unwrap_or(0);
1033 let end = position_to_offset(&text, usage.range.end).unwrap_or(0);
1034 offset >= start && offset <= end
1035 });
1036
1037 let usage_context = hover_usage
1038 .map(|u| u.usage_context.clone())
1039 .unwrap_or_default();
1040 let is_inline_style = usage_context == "inline-style";
1041 let dom_tree = self.manager.get_dom_tree(&uri).await;
1042 let dom_node = hover_usage.and_then(|u| u.dom_node.clone());
1043
1044 sort_by_cascade(&mut definitions);
1045
1046 let mut hover_text = format!("### CSS Variable: `{}`\n\n", word);
1047
1048 if definitions.len() == 1 {
1049 let var = &definitions[0];
1050 hover_text.push_str(&format!("**Value:** `{}`", var.value));
1051 if var.important {
1052 hover_text.push_str(" **!important**");
1053 }
1054 hover_text.push_str("\n\n");
1055 if !var.selector.is_empty() {
1056 hover_text.push_str(&format!("**Defined in:** `{}`\n", var.selector));
1057 hover_text.push_str(&format!(
1058 "**Specificity:** {}\n",
1059 format_specificity(calculate_specificity(&var.selector))
1060 ));
1061 }
1062 } else {
1063 hover_text.push_str("**Definitions** (CSS cascade order):\n\n");
1064
1065 for (idx, var) in definitions.iter().enumerate() {
1066 let spec = calculate_specificity(&var.selector);
1067 let is_applicable = if usage_context.is_empty() {
1068 true
1069 } else {
1070 matches_context(
1071 &var.selector,
1072 &usage_context,
1073 dom_tree.as_ref(),
1074 dom_node.as_ref(),
1075 )
1076 };
1077 let is_winner = idx == 0 && (is_applicable || is_inline_style);
1078
1079 let mut line = format!("{}. `{}`", idx + 1, var.value);
1080 if var.important {
1081 line.push_str(" **!important**");
1082 }
1083 if !var.selector.is_empty() {
1084 line.push_str(&format!(
1085 " from `{}` {}",
1086 var.selector,
1087 format_specificity(spec)
1088 ));
1089 }
1090
1091 if is_winner && !usage_context.is_empty() {
1092 if var.important {
1093 line.push_str(" ✓ **Wins (!important)**");
1094 } else if is_inline_style {
1095 line.push_str(" ✓ **Would apply (inline style)**");
1096 } else if dom_tree.is_some() && dom_node.is_some() {
1097 line.push_str(" ✓ **Applies (DOM match)**");
1098 } else {
1099 line.push_str(" ✓ **Applies here**");
1100 }
1101 } else if !is_applicable && !usage_context.is_empty() && !is_inline_style {
1102 line.push_str(" _(selector doesn't match)_");
1103 } else if idx > 0 && !usage_context.is_empty() {
1104 let winner = &definitions[0];
1105 if winner.important && !var.important {
1106 line.push_str(" _(overridden by !important)_");
1107 } else {
1108 let winner_spec = calculate_specificity(&winner.selector);
1109 let cmp = compare_specificity(winner_spec, spec);
1110 if cmp > 0 {
1111 line.push_str(" _(lower specificity)_");
1112 } else if cmp == 0 {
1113 line.push_str(" _(earlier in source)_");
1114 }
1115 }
1116 }
1117
1118 hover_text.push_str(&line);
1119 hover_text.push('\n');
1120 }
1121
1122 if !usage_context.is_empty() {
1123 if is_inline_style {
1124 hover_text.push_str("\n_Context: Inline style (highest priority)_");
1125 } else if dom_tree.is_some() && dom_node.is_some() {
1126 hover_text.push_str(&format!(
1127 "\n_Context: `{}` (DOM-aware matching)_",
1128 usage_context
1129 ));
1130 } else {
1131 hover_text.push_str(&format!("\n_Context: `{}`_", usage_context));
1132 }
1133 }
1134 }
1135
1136 Ok(Some(Hover {
1137 contents: HoverContents::Markup(MarkupContent {
1138 kind: MarkupKind::Markdown,
1139 value: hover_text,
1140 }),
1141 range: None,
1142 }))
1143 }
1144
1145 async fn goto_definition(
1146 &self,
1147 params: GotoDefinitionParams,
1148 ) -> tower_lsp_server::jsonrpc::Result<Option<GotoDefinitionResponse>> {
1149 let uri = params.text_document_position_params.text_document.uri;
1150 let position = params.text_document_position_params.position;
1151
1152 let text = {
1153 let docs = self.document_map.read().await;
1154 docs.get(&uri).cloned()
1155 };
1156 let text = match text {
1157 Some(text) => text,
1158 None => return Ok(None),
1159 };
1160
1161 let word = match self.get_word_at_position(&text, position) {
1162 Some(word) => word,
1163 None => return Ok(None),
1164 };
1165
1166 let definitions = self.manager.get_variables(&word).await;
1167 let first = match definitions.first() {
1168 Some(def) => def,
1169 None => return Ok(None),
1170 };
1171
1172 Ok(Some(GotoDefinitionResponse::Scalar(Location::new(
1173 first.uri.clone(),
1174 first.range,
1175 ))))
1176 }
1177
1178 async fn code_action(
1179 &self,
1180 params: CodeActionParams,
1181 ) -> tower_lsp_server::jsonrpc::Result<Option<CodeActionResponse>> {
1182 let uri = params.text_document.uri;
1183 let text = {
1184 let docs = self.document_map.read().await;
1185 docs.get(&uri).cloned()
1186 };
1187 let text = match text {
1188 Some(text) => text,
1189 None => return Ok(Some(Vec::new())),
1190 };
1191
1192 let mut actions: Vec<CodeActionOrCommand> = Vec::new();
1193
1194 actions.extend(code_actions_for_undefined_variables(
1196 &uri,
1197 &text,
1198 ¶ms.context,
1199 &self.runtime_config,
1200 ));
1201 actions.extend(code_actions_for_replaceable_literal_colors(
1202 &uri,
1203 ¶ms.context,
1204 &self.runtime_config,
1205 ));
1206
1207 Ok(Some(actions))
1208 }
1209
1210 async fn references(
1211 &self,
1212 params: ReferenceParams,
1213 ) -> tower_lsp_server::jsonrpc::Result<Option<Vec<Location>>> {
1214 let uri = params.text_document_position.text_document.uri;
1215 let position = params.text_document_position.position;
1216
1217 let text = {
1218 let docs = self.document_map.read().await;
1219 docs.get(&uri).cloned()
1220 };
1221 let text = match text {
1222 Some(text) => text,
1223 None => return Ok(None),
1224 };
1225
1226 let word = match self.get_word_at_position(&text, position) {
1227 Some(word) => word,
1228 None => return Ok(None),
1229 };
1230
1231 let (definitions, usages) = self.manager.get_references(&word).await;
1232 let mut locations = Vec::new();
1233 for def in definitions {
1234 locations.push(Location::new(def.uri, def.range));
1235 }
1236 for usage in usages {
1237 locations.push(Location::new(usage.uri, usage.range));
1238 }
1239
1240 Ok(Some(locations))
1241 }
1242
1243 async fn document_color(
1244 &self,
1245 params: DocumentColorParams,
1246 ) -> tower_lsp_server::jsonrpc::Result<Vec<ColorInformation>> {
1247 let config = self.manager.get_config().await;
1248 if !config.enable_color_provider {
1249 return Ok(Vec::new());
1250 }
1251
1252 let uri = params.text_document.uri;
1253 let text = {
1254 let docs = self.document_map.read().await;
1255 docs.get(&uri).cloned()
1256 };
1257 let text = match text {
1258 Some(text) => text,
1259 None => return Ok(Vec::new()),
1260 };
1261
1262 let mut colors = Vec::new();
1263 let mut seen_ranges: HashSet<(u32, u32, u32, u32)> = HashSet::new();
1264 let range_key = |range: &Range| {
1265 (
1266 range.start.line,
1267 range.start.character,
1268 range.end.line,
1269 range.end.character,
1270 )
1271 };
1272
1273 if !config.color_only_on_variables {
1274 let definitions = self.manager.get_document_variables(&uri).await;
1275 for def in definitions {
1276 if let Some(color) = parse_color(&def.value) {
1277 if let Some(value_range) = def.value_range {
1278 if seen_ranges.insert(range_key(&value_range)) {
1279 colors.push(ColorInformation {
1280 range: value_range,
1281 color,
1282 });
1283 }
1284 } else if let Some(range) = find_value_range_in_definition(&text, &def) {
1285 if seen_ranges.insert(range_key(&range)) {
1286 colors.push(ColorInformation { range, color });
1287 }
1288 }
1289 }
1290 }
1291 }
1292
1293 let usages = self.manager.get_document_usages(&uri).await;
1294 for usage in usages {
1295 if let Some(color) = self.manager.resolve_variable_color(&usage.name).await {
1296 if seen_ranges.insert(range_key(&usage.range)) {
1297 colors.push(ColorInformation {
1298 range: usage.range,
1299 color,
1300 });
1301 }
1302 }
1303 }
1304
1305 for caps in self.var_usage_regex.captures_iter(&text) {
1306 let match_all = caps.get(0).unwrap();
1307 let var_name = caps.get(1).unwrap().as_str();
1308 let range = Range::new(
1309 crate::types::offset_to_position(&text, match_all.start()),
1310 crate::types::offset_to_position(&text, match_all.end()),
1311 );
1312 if !seen_ranges.insert(range_key(&range)) {
1313 continue;
1314 }
1315 if let Some(color) = self.manager.resolve_variable_color(var_name).await {
1316 colors.push(ColorInformation { range, color });
1317 }
1318 }
1319
1320 Ok(colors)
1321 }
1322
1323 async fn color_presentation(
1324 &self,
1325 params: ColorPresentationParams,
1326 ) -> tower_lsp_server::jsonrpc::Result<Vec<ColorPresentation>> {
1327 if !self.runtime_config.enable_color_provider {
1328 return Ok(Vec::new());
1329 }
1330 Ok(generate_color_presentations(params.color, params.range))
1331 }
1332
1333 async fn prepare_rename(
1334 &self,
1335 params: TextDocumentPositionParams,
1336 ) -> tower_lsp_server::jsonrpc::Result<Option<PrepareRenameResponse>> {
1337 let uri = params.text_document.uri;
1338 let position = params.position;
1339
1340 let text = {
1341 let docs = self.document_map.read().await;
1342 docs.get(&uri).cloned()
1343 };
1344 let text = match text {
1345 Some(text) => text,
1346 None => return Ok(None),
1347 };
1348
1349 let name = match self.get_word_at_position(&text, position) {
1350 Some(name) => name,
1351 None => return Ok(None),
1352 };
1353
1354 let definitions = self.manager.get_variables(&name).await;
1356 let range = definitions
1357 .first()
1358 .and_then(|d| d.name_range)
1359 .unwrap_or_else(|| {
1360 let offset = position_to_offset(&text, position).unwrap_or(0);
1363 let offset = clamp_to_char_boundary(&text, offset);
1364
1365 let before = &text[..offset];
1366 let after = &text[offset..];
1367
1368 let mut start_byte = offset;
1376 for (i, c) in before.char_indices().rev() {
1377 if is_word_char(c) {
1378 start_byte = i;
1379 } else {
1380 break;
1381 }
1382 }
1383 let mut end_byte = offset;
1384 for (i, c) in after.char_indices() {
1385 if is_word_char(c) {
1386 end_byte = offset + i + c.len_utf8();
1387 } else {
1388 break;
1389 }
1390 }
1391
1392 Range::new(
1393 crate::types::offset_to_position(&text, start_byte),
1394 crate::types::offset_to_position(&text, end_byte),
1395 )
1396 });
1397
1398 Ok(Some(PrepareRenameResponse::Range(range)))
1399 }
1400
1401 async fn rename(
1402 &self,
1403 params: RenameParams,
1404 ) -> tower_lsp_server::jsonrpc::Result<Option<WorkspaceEdit>> {
1405 let uri = params.text_document_position.text_document.uri;
1406 let position = params.text_document_position.position;
1407 let new_name = params.new_name;
1408
1409 let text = {
1410 let docs = self.document_map.read().await;
1411 docs.get(&uri).cloned()
1412 };
1413 let text = match text {
1414 Some(text) => text,
1415 None => return Ok(None),
1416 };
1417
1418 let old_name = match self.get_word_at_position(&text, position) {
1419 Some(word) => word,
1420 None => return Ok(None),
1421 };
1422
1423 let (definitions, usages) = self.manager.get_references(&old_name).await;
1424 let mut changes: HashMap<Uri, Vec<TextEdit>> = HashMap::new();
1425
1426 for def in definitions {
1427 let range = def.name_range.unwrap_or(def.range);
1428 changes.entry(def.uri.clone()).or_default().push(TextEdit {
1429 range,
1430 new_text: new_name.clone(),
1431 });
1432 }
1433
1434 for usage in usages {
1435 let range = usage.name_range.unwrap_or(usage.range);
1436 changes
1437 .entry(usage.uri.clone())
1438 .or_default()
1439 .push(TextEdit {
1440 range,
1441 new_text: new_name.clone(),
1442 });
1443 }
1444
1445 Ok(Some(WorkspaceEdit {
1446 changes: Some(changes),
1447 document_changes: None,
1448 change_annotations: None,
1449 }))
1450 }
1451
1452 #[allow(deprecated)]
1453 async fn document_symbol(
1454 &self,
1455 params: DocumentSymbolParams,
1456 ) -> tower_lsp_server::jsonrpc::Result<Option<DocumentSymbolResponse>> {
1457 let vars = self
1458 .manager
1459 .get_document_variables(¶ms.text_document.uri)
1460 .await;
1461 let symbols: Vec<DocumentSymbol> = vars
1462 .into_iter()
1463 .map(|var| DocumentSymbol {
1464 name: var.name,
1465 detail: Some(var.value),
1466 kind: SymbolKind::VARIABLE,
1467 tags: None,
1468 deprecated: None,
1469 range: var.range,
1470 selection_range: var.range,
1471 children: None,
1472 })
1473 .collect();
1474
1475 Ok(Some(DocumentSymbolResponse::Nested(symbols)))
1476 }
1477
1478 #[allow(deprecated)]
1479 async fn symbol(
1480 &self,
1481 params: WorkspaceSymbolParams,
1482 ) -> tower_lsp_server::jsonrpc::Result<Option<WorkspaceSymbolResponse>> {
1483 let query = params.query.to_lowercase();
1484 let vars = self.manager.get_all_variables().await;
1485 let mut symbols = Vec::new();
1486
1487 for var in vars {
1488 if !query.is_empty() && !var.name.to_lowercase().contains(&query) {
1489 continue;
1490 }
1491 symbols.push(SymbolInformation {
1492 name: var.name,
1493 kind: SymbolKind::VARIABLE,
1494 tags: None,
1495 deprecated: None,
1496 location: Location::new(var.uri.clone(), var.range),
1497 container_name: None,
1498 });
1499 }
1500
1501 Ok(Some(WorkspaceSymbolResponse::Flat(symbols)))
1502 }
1503}
1504
1505impl CssVariableLsp {
1506 async fn literal_color_under_cursor(
1507 &self,
1508 uri: &Uri,
1509 position: Position,
1510 ) -> Option<NormalizedColorKey> {
1511 let occurrences = self
1512 .manager
1513 .get_literal_colors_at_position(uri, position)
1514 .await;
1515 occurrences
1516 .into_iter()
1517 .find(|occurrence| range_contains_position(&occurrence.range, position))
1518 .map(|occurrence| occurrence.normalized_color)
1519 }
1520
1521 pub async fn scan_workspace_folders(&self, folders: Vec<WorkspaceFolder>) {
1523 let folder_uris: Vec<Uri> = folders.iter().map(|f| f.uri.clone()).collect();
1524
1525 self.client
1526 .log_message(
1527 MessageType::INFO,
1528 format!("Scanning {} workspace folders...", folder_uris.len()),
1529 )
1530 .await;
1531
1532 let manager = self.manager.clone();
1533 let client = self.client.clone();
1534
1535 let mut last_logged_percentage = 0;
1536 let result = crate::workspace::scan_workspace(folder_uris, &manager, |current, total| {
1537 if total == 0 {
1538 return;
1539 }
1540 let percentage = ((current as f64 / total as f64) * 100.0).round() as i32;
1541 if percentage - last_logged_percentage >= 20 || current == total {
1542 last_logged_percentage = percentage;
1543 let client = client.clone();
1544 tokio::spawn(async move {
1545 client
1546 .log_message(
1547 MessageType::INFO,
1548 format!(
1549 "Scanning CSS files: {}/{} ({}%)",
1550 current, total, percentage
1551 ),
1552 )
1553 .await;
1554 });
1555 }
1556 })
1557 .await;
1558
1559 match result {
1560 Ok(_) => {
1561 let total_vars = manager.get_all_variables().await.len();
1562 self.client
1563 .log_message(
1564 MessageType::INFO,
1565 format!(
1566 "Workspace scan complete. Found {} CSS variables.",
1567 total_vars
1568 ),
1569 )
1570 .await;
1571 }
1572 Err(e) => {
1573 self.client
1574 .log_message(MessageType::ERROR, format!("Workspace scan failed: {}", e))
1575 .await;
1576 }
1577 }
1578
1579 self.validate_all_open_documents().await;
1580 }
1581}
1582
1583#[allow(clippy::too_many_arguments)]
1584async fn validate_document_text_with(
1585 client: &Client,
1586 manager: &CssVariableManager,
1587 usage_regex: &Regex,
1588 undefined_var_fallback: UndefinedVarFallbackMode,
1589 has_related_info: bool,
1590 uri: &Uri,
1591 text: &str,
1592 document_usage_map: &Arc<RwLock<HashMap<Uri, HashSet<String>>>>,
1593 usage_index: &Arc<RwLock<HashMap<String, HashSet<Uri>>>>,
1594 color_occurrence_uris: Option<&Arc<RwLock<HashSet<Uri>>>>,
1595) {
1596 let mut diagnostics = Vec::new();
1597 let mut current_usages = HashSet::new();
1598 let mut has_literal_colors = false;
1599 let default_severity = DiagnosticSeverity::WARNING;
1600
1601 for captures in usage_regex.captures_iter(text) {
1602 let match_all = captures.get(0).unwrap();
1603 let name = captures.get(1).unwrap().as_str();
1604 let fallback = captures.get(2).map(|m| m.as_str()).unwrap_or("");
1605 let has_fallback = !fallback.trim().is_empty();
1606
1607 current_usages.insert(name.to_string());
1608
1609 let definitions = manager.get_variables(name).await;
1610 if !definitions.is_empty() {
1611 continue;
1612 }
1613
1614 let severity = if has_fallback {
1615 match undefined_var_fallback {
1616 UndefinedVarFallbackMode::Warning => Some(default_severity),
1617 UndefinedVarFallbackMode::Info => Some(DiagnosticSeverity::INFORMATION),
1618 UndefinedVarFallbackMode::Off => None,
1619 }
1620 } else {
1621 Some(default_severity)
1622 };
1623 let severity = match severity {
1624 Some(severity) => severity,
1625 None => continue,
1626 };
1627 let range = Range::new(
1628 crate::types::offset_to_position(text, match_all.start()),
1629 crate::types::offset_to_position(text, match_all.end()),
1630 );
1631 diagnostics.push(Diagnostic {
1632 range,
1633 severity: Some(severity),
1634 code: Some(ls_types::NumberOrString::String(
1635 "css-variable-lsp.undefined-variable".to_string(),
1636 )),
1637 code_description: None,
1638 source: Some("css-variable-lsp".to_string()),
1639 message: format!("CSS variable '{}' is not defined in the workspace", name),
1640 related_information: if has_related_info {
1641 Some(Vec::new())
1642 } else {
1643 None
1644 },
1645 tags: None,
1646 data: Some(serde_json::json!({
1647 "name": name,
1648 "hasFallback": has_fallback,
1649 "range": {
1650 "start": { "line": range.start.line, "character": range.start.character },
1651 "end": { "line": range.end.line, "character": range.end.character }
1652 }
1653 })),
1654 });
1655 }
1656
1657 for occurrence in manager.get_document_literal_colors(uri).await {
1658 let all_replacements = manager
1659 .get_variables_by_color_key(&occurrence.normalized_color)
1660 .await;
1661
1662 let replacements: Vec<_> = all_replacements
1666 .iter()
1667 .filter(|var| {
1668 if var.uri != *uri {
1670 return true;
1671 }
1672 let Some(value_range) = &var.value_range else {
1674 return true;
1675 };
1676 !range_contains(value_range, &occurrence.range)
1678 })
1679 .cloned()
1680 .collect();
1681
1682 if replacements.is_empty() {
1683 continue;
1684 }
1685
1686 has_literal_colors = true;
1688
1689 let replacement_data: Vec<_> = replacements
1690 .iter()
1691 .map(|var| {
1692 serde_json::json!({
1693 "name": var.name,
1694 "value": var.value,
1695 "uri": var.uri,
1696 })
1697 })
1698 .collect();
1699 let replacement_count = replacement_data.len();
1700 let message = if replacement_count == 1 {
1701 format!("Consider using {} for this color", replacements[0].name)
1702 } else {
1703 let var_names: Vec<&str> = replacements.iter().map(|v| v.name.as_str()).collect();
1704 format!(
1705 "Consider using one of these variables: {}",
1706 var_names.join(", ")
1707 )
1708 };
1709
1710 diagnostics.push(Diagnostic {
1711 range: occurrence.range,
1712 severity: Some(DiagnosticSeverity::INFORMATION),
1713 code: Some(ls_types::NumberOrString::String(
1714 "css-variable-lsp.literal-color-replaceable".to_string(),
1715 )),
1716 code_description: None,
1717 source: Some("css-variable-lsp".to_string()),
1718 message,
1719 related_information: None,
1720 tags: None,
1721 data: Some(serde_json::json!({
1722 "literal": occurrence.text,
1723 "usageContext": occurrence.usage_context,
1724 "replacements": replacement_data,
1725 })),
1726 });
1727 }
1728
1729 {
1731 let mut usage_map = document_usage_map.write().await;
1732 let old_usages = usage_map.insert(uri.clone(), current_usages.clone());
1733
1734 let mut index = usage_index.write().await;
1735
1736 if let Some(old_set) = old_usages {
1738 for name in old_set {
1739 if !current_usages.contains(&name) {
1740 if let Some(uris) = index.get_mut(&name) {
1741 uris.remove(uri);
1742 if uris.is_empty() {
1743 index.remove(&name);
1744 }
1745 }
1746 }
1747 }
1748 }
1749
1750 for name in current_usages {
1752 index
1753 .entry(name)
1754 .or_insert_with(HashSet::new)
1755 .insert(uri.clone());
1756 }
1757 }
1758
1759 if let Some(color_uris) = color_occurrence_uris {
1761 let mut color_uris = color_uris.write().await;
1762 if has_literal_colors {
1763 color_uris.insert(uri.clone());
1764 } else {
1765 color_uris.remove(uri);
1766 }
1767 }
1768
1769 client
1770 .publish_diagnostics(uri.clone(), diagnostics, None)
1771 .await;
1772}
1773
1774fn is_var_function_context_slice(before_cursor: &str) -> bool {
1775 let bytes = before_cursor.as_bytes();
1776 if bytes.is_empty() {
1777 return false;
1778 }
1779
1780 let mut i = bytes.len();
1781 if is_word_byte(bytes[i - 1]) {
1782 let mut start = i;
1783 while start > 0 && is_word_byte(bytes[start - 1]) {
1784 start -= 1;
1785 }
1786 if i - start < 2 || bytes[start] != b'-' || bytes[start + 1] != b'-' {
1787 return false;
1788 }
1789 i = start;
1790 }
1791
1792 while i > 0 && bytes[i - 1].is_ascii_whitespace() {
1793 i -= 1;
1794 }
1795
1796 if i == 0 || bytes[i - 1] != b'(' {
1797 return false;
1798 }
1799
1800 let paren_idx = i - 1;
1801 if paren_idx < 3 {
1802 return false;
1803 }
1804 let start = paren_idx - 3;
1805 if !bytes[start..paren_idx].eq_ignore_ascii_case(b"var") {
1806 return false;
1807 }
1808 if start == 0 {
1809 return true;
1810 }
1811 !is_word_byte(bytes[start - 1])
1812}
1813
1814#[cfg(test)]
1815mod tests {
1816 use super::*;
1817 use std::str::FromStr;
1818
1819 fn test_word_extraction(css: &str, cursor_pos: usize) -> Option<String> {
1820 use ls_types::Position;
1821 let position = Position {
1822 line: 0,
1823 character: cursor_pos as u32,
1824 };
1825
1826 let offset = position_to_offset(css, position)?;
1827 let offset = clamp_to_char_boundary(css, offset);
1828 let before = &css[..offset];
1829 let after = &css[offset..];
1830
1831 let left = before
1832 .rsplit(|c: char| !is_word_char(c))
1833 .next()
1834 .unwrap_or("");
1835 let right = after.split(|c: char| !is_word_char(c)).next().unwrap_or("");
1836 let word = format!("{}{}", left, right);
1837 if word.starts_with("--") {
1838 Some(word)
1839 } else {
1840 None
1841 }
1842 }
1843
1844 #[test]
1845 fn test_word_extraction_preserves_fallbacks() {
1846 let css = "background: var(--primary-color, blue);";
1848 let result = test_word_extraction(css, 20); assert_eq!(result, Some("--primary-color".to_string()));
1850
1851 let css2 = "color: var(--secondary-color, #ccc);";
1853 let result2 = test_word_extraction(css2, 15); assert_eq!(result2, Some("--secondary-color".to_string()));
1855
1856 let css3 = "border: var(--accent-color, var(--fallback, black));";
1858 let result3 = test_word_extraction(css3, 16); assert_eq!(result3, Some("--accent-color".to_string()));
1860
1861 let css4 = "--theme-color: red;";
1863 let result4 = test_word_extraction(css4, 5); assert_eq!(result4, Some("--theme-color".to_string()));
1865
1866 let css5 = "margin: var(--spacing);";
1868 let result5 = test_word_extraction(css5, 15); assert_eq!(result5, Some("--spacing".to_string()));
1870 }
1871
1872 #[test]
1873 fn test_var_function_context_open() {
1874 let text = "color: var(--primary";
1875 assert!(is_var_function_context_slice(text));
1876 }
1877
1878 #[test]
1879 fn test_var_function_context_closed() {
1880 let text = "color: var(--primary);";
1881 assert!(!is_var_function_context_slice(text));
1882 }
1883
1884 #[test]
1885 fn test_var_function_context_nested() {
1886 let text = "color: var(--primary, calc(100% - var(--secondary";
1887 assert!(is_var_function_context_slice(text));
1888 }
1889
1890 #[test]
1891 fn test_var_function_context_after_fallback() {
1892 let text = "color: var(--primary, ";
1893 assert!(!is_var_function_context_slice(text));
1894 }
1895
1896 #[test]
1897 fn test_var_function_context_requires_boundary() {
1898 let text = "navbar(--primary";
1899 assert!(!is_var_function_context_slice(text));
1900 }
1901
1902 #[test]
1903 fn test_var_function_context_case_insensitive() {
1904 let text = "color: VAR(--primary";
1905 assert!(is_var_function_context_slice(text));
1906 }
1907
1908 #[test]
1909 fn test_completion_value_context_slice_css() {
1910 let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
1911 let text = ".card { color: var(";
1912 let position = crate::types::offset_to_position(text, text.len());
1913 let uri = Uri::from_str("file:///styles.css").unwrap();
1914 let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
1915 .expect("expected css slice");
1916 assert_eq!(context.slice, text);
1917 assert!(!context.allow_without_braces);
1918 }
1919
1920 #[test]
1921 fn test_completion_value_context_slice_html_style_attribute() {
1922 let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
1923 let text = r#"<div style="color: var("#;
1924 let position = crate::types::offset_to_position(text, text.len());
1925 let uri = Uri::from_str("file:///index.html").unwrap();
1926 let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
1927 .expect("expected html style attribute slice");
1928 assert_eq!(context.slice, "color: var(");
1929 assert!(context.allow_without_braces);
1930 }
1931
1932 #[test]
1933 fn test_completion_value_context_slice_html_style_block() {
1934 let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
1935 let text = "<style>body { color: var(";
1936 let position = crate::types::offset_to_position(text, text.len());
1937 let uri = Uri::from_str("file:///index.html").unwrap();
1938 let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
1939 .expect("expected html style block slice");
1940 assert_eq!(context.slice, "body { color: var(");
1941 assert!(!context.allow_without_braces);
1942 }
1943
1944 #[test]
1945 fn test_completion_value_context_slice_js_string() {
1946 let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
1947 let text = r#"const css = "color: var("#;
1948 let position = crate::types::offset_to_position(text, text.len());
1949 let uri = Uri::from_str("file:///app.js").unwrap();
1950 let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
1951 .expect("expected js string slice");
1952 assert_eq!(context.slice, "color: var(");
1953 assert!(context.allow_without_braces);
1954 }
1955
1956 #[test]
1957 fn test_completion_value_context_slice_js_non_string() {
1958 let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
1959 let text = "const css = color: var(";
1960 let position = crate::types::offset_to_position(text, text.len());
1961 let uri = Uri::from_str("file:///app.js").unwrap();
1962 assert!(completion_value_context_slice(text, position, None, &uri, &lookup_map).is_none());
1963 }
1964
1965 #[test]
1966 fn test_completion_value_context_slice_unknown() {
1967 let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
1968 let text = "color: var(";
1969 let position = crate::types::offset_to_position(text, text.len());
1970 let uri = Uri::from_str("file:///notes.txt").unwrap();
1971 assert!(completion_value_context_slice(text, position, None, &uri, &lookup_map).is_none());
1972 }
1973
1974 #[test]
1975 fn test_html_style_attribute_slice_open() {
1976 let text = r#"<div style="color: var("#;
1977 let slice = find_html_style_attribute_slice(text).unwrap();
1978 assert_eq!(slice, "color: var(");
1979 }
1980
1981 #[test]
1982 fn test_html_style_attribute_slice_closed() {
1983 let text = r#"<div style="color: red">"#;
1984 assert!(find_html_style_attribute_slice(text).is_none());
1985 }
1986
1987 #[test]
1988 fn test_html_style_block_slice() {
1989 let text = "<style>body { color: var(";
1990 let slice = find_html_style_block_slice(text).unwrap();
1991 assert_eq!(slice, "body { color: var(");
1992 }
1993
1994 #[test]
1995 fn test_js_string_segment_basic() {
1996 let text = r#"const css = \"color: var("#;
1997 let slice = find_js_string_segment(text).unwrap();
1998 assert_eq!(slice, "color: var(");
1999 }
2000
2001 #[test]
2002 fn test_js_string_segment_template_after_expression() {
2003 let text = r#"const css = `color: ${theme}; background: var("#;
2004 let slice = find_js_string_segment(text).unwrap();
2005 assert_eq!(slice, "; background: var(");
2006 }
2007
2008 #[test]
2009 fn test_js_string_segment_template_expression() {
2010 let text = r#"const css = `color: ${theme"#;
2011 assert!(find_js_string_segment(text).is_none());
2012 }
2013
2014 #[test]
2015 fn resolve_document_kind_prefers_language_id() {
2016 let lookup_files = vec!["**/*.custom".to_string()];
2017 let lookup_map = build_lookup_extension_map(&lookup_files);
2018
2019 let kind = resolve_document_kind("file.custom", Some("html"), &lookup_map);
2020 assert_eq!(kind, Some(DocumentKind::Html));
2021 }
2022
2023 #[test]
2024 fn resolve_document_kind_uses_lookup_extensions() {
2025 let lookup_files = vec![
2026 "**/*.{css,scss}".to_string(),
2027 "**/*.vue".to_string(),
2028 "**/*.custom".to_string(),
2029 ];
2030 let lookup_map = build_lookup_extension_map(&lookup_files);
2031
2032 let css_kind = resolve_document_kind("styles.scss", None, &lookup_map);
2033 assert_eq!(css_kind, Some(DocumentKind::Css));
2034
2035 let html_kind = resolve_document_kind("component.vue", None, &lookup_map);
2036 assert_eq!(html_kind, Some(DocumentKind::Html));
2037
2038 let custom_kind = resolve_document_kind("theme.custom", None, &lookup_map);
2039 assert_eq!(custom_kind, Some(DocumentKind::Css));
2040 }
2041
2042 #[test]
2043 fn resolve_document_kind_returns_none_for_unknown() {
2044 let lookup_files = vec!["**/*.css".to_string()];
2045 let lookup_map = build_lookup_extension_map(&lookup_files);
2046
2047 let kind = resolve_document_kind("notes.txt", Some("plaintext"), &lookup_map);
2048 assert_eq!(kind, None);
2049 }
2050}