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