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