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