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