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 let _document_lifecycle_guard = self.document_lifecycle_lock.lock().await;
904 for change in params.changes {
905 match change.typ {
906 FileChangeType::DELETED => {
907 self.manager.remove_document(&change.uri).await;
908 }
909 FileChangeType::CREATED | FileChangeType::CHANGED
910 if !self.is_document_open(&change.uri).await =>
911 {
912 self.update_document_from_disk(&change.uri).await;
913 }
914 _ => {}
915 }
916 }
917
918 self.validate_all_open_documents().await;
919 }
920
921 async fn did_create_files(&self, params: CreateFilesParams) {
922 let _document_lifecycle_guard = self.document_lifecycle_lock.lock().await;
923 for file in params.files {
924 let uri = match Uri::from_str(&file.uri) {
925 Ok(uri) => uri,
926 Err(_) => continue,
927 };
928 if !self.is_document_open(&uri).await {
929 self.update_document_from_disk(&uri).await;
930 }
931 }
932 self.validate_all_open_documents().await;
933 }
934
935 async fn did_rename_files(&self, params: RenameFilesParams) {
936 let _document_lifecycle_guard = self.document_lifecycle_lock.lock().await;
937 for file in params.files {
938 let old_uri = match Uri::from_str(&file.old_uri) {
939 Ok(uri) => uri,
940 Err(_) => continue,
941 };
942 let new_uri = match Uri::from_str(&file.new_uri) {
943 Ok(uri) => uri,
944 Err(_) => continue,
945 };
946
947 self.manager.remove_document(&old_uri).await;
949
950 if !self.is_document_open(&new_uri).await {
952 self.update_document_from_disk(&new_uri).await;
953 }
954 }
955 self.validate_all_open_documents().await;
956 }
957
958 async fn did_delete_files(&self, params: DeleteFilesParams) {
959 let _document_lifecycle_guard = self.document_lifecycle_lock.lock().await;
960 for file in params.files {
961 let uri = match Uri::from_str(&file.uri) {
962 Ok(uri) => uri,
963 Err(_) => continue,
964 };
965 self.manager.remove_document(&uri).await;
966 }
967 self.validate_all_open_documents().await;
968 }
969
970 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
971 let mut current_paths = {
972 let paths = self.workspace_folder_paths.read().await;
973 paths.clone()
974 };
975
976 let mut removed_paths = Vec::new();
977 for removed in ¶ms.event.removed {
978 if let Some(path) = to_normalized_fs_path(&removed.uri) {
979 current_paths.retain(|p| p != &path);
980 removed_paths.push(path);
981 }
982 }
983
984 let mut added_folders = Vec::new();
985 for added in params.event.added {
986 if let Some(path) = to_normalized_fs_path(&added.uri) {
987 if !current_paths.contains(&path) {
988 current_paths.push(path);
989 added_folders.push(added);
990 }
991 }
992 }
993
994 current_paths.sort_by_key(|b| std::cmp::Reverse(b.to_string_lossy().len()));
995
996 {
997 let mut stored = self.workspace_folder_paths.write().await;
998 *stored = current_paths.clone();
999 }
1000
1001 if !removed_paths.is_empty() {
1002 let open_uris: HashSet<Uri> = self.document_map.read().await.keys().cloned().collect();
1003 let stale_uris: HashSet<Uri> = self
1004 .manager
1005 .get_document_uris()
1006 .await
1007 .into_iter()
1008 .filter(|uri| !open_uris.contains(uri))
1009 .filter(|uri| {
1010 let Some(path) = to_normalized_fs_path(uri) else {
1011 return false;
1012 };
1013 removed_paths
1014 .iter()
1015 .any(|removed| path.starts_with(removed))
1016 && !current_paths
1017 .iter()
1018 .any(|current| path.starts_with(current))
1019 })
1020 .collect();
1021 self.manager.remove_documents(&stale_uris).await;
1022 }
1023
1024 if added_folders.is_empty() {
1025 self.validate_all_open_documents().await;
1026 } else {
1027 self.scan_workspace_folders(added_folders).await;
1028 }
1029 }
1030
1031 async fn completion(
1032 &self,
1033 params: CompletionParams,
1034 ) -> tower_lsp_server::jsonrpc::Result<Option<CompletionResponse>> {
1035 let uri = params.text_document_position.text_document.uri;
1036 let position = params.text_document_position.position;
1037
1038 let text = {
1039 let docs = self.document_map.read().await;
1040 docs.get(&uri).cloned()
1041 };
1042 let text = match text {
1043 Some(text) => text,
1044 None => return Ok(Some(CompletionResponse::Array(Vec::new()))),
1045 };
1046
1047 let language_id = {
1048 let langs = self.document_language_map.read().await;
1049 langs.get(&uri).cloned()
1050 };
1051 let lookup_map = self.lookup_extension_map.read().await.clone();
1052 let context = completion_value_context_slice(
1053 &text,
1054 position,
1055 language_id.as_deref(),
1056 &uri,
1057 &lookup_map,
1058 );
1059 let context = match context {
1060 Some(context) => context,
1061 None => return Ok(Some(CompletionResponse::Array(Vec::new()))),
1062 };
1063 let value_context = get_value_context_info(context.slice, context.allow_without_braces);
1064 if !value_context.is_value_context {
1065 return Ok(Some(CompletionResponse::Array(Vec::new())));
1066 }
1067 let property_name = value_context.property_name;
1068 let in_var_context = is_var_function_context_slice(context.slice);
1069 let workspace_folder_paths = self.workspace_folder_paths.read().await.clone();
1070 let root_folder_path = self.root_folder_path.read().await.clone();
1071
1072 if !in_var_context {
1073 if let Some(color_key) = self.literal_color_under_cursor(&uri, position).await {
1074 let variables = self.manager.get_variables_by_color_key(&color_key).await;
1075 let items = variables
1076 .into_iter()
1077 .map(|var| {
1078 let options = PathDisplayOptions {
1079 mode: self.runtime_config.path_display_mode,
1080 abbrev_length: self.runtime_config.path_display_abbrev_length,
1081 workspace_folder_paths: &workspace_folder_paths,
1082 root_folder_path: root_folder_path.as_ref(),
1083 };
1084 let display_path = format_uri_for_display(&var.uri, options);
1085 CompletionItem {
1086 label: var.name.clone(),
1087 kind: Some(CompletionItemKind::VARIABLE),
1088 detail: Some(format!(
1089 "{} • {}",
1090 variable_completion_detail(&var),
1091 display_path
1092 )),
1093 insert_text: Some(format!("var({})", var.name)),
1094 documentation: Some(ls_types::Documentation::String(
1095 variable_completion_documentation(&var, &display_path),
1096 )),
1097 ..Default::default()
1098 }
1099 })
1100 .collect();
1101 return Ok(Some(CompletionResponse::Array(items)));
1102 }
1103 }
1104
1105 let variables = self.manager.get_all_variables().await;
1106
1107 let mut unique_vars = HashMap::new();
1108 for var in variables {
1109 unique_vars.entry(var.name.clone()).or_insert(var);
1110 }
1111
1112 let mut scored_vars: Vec<(i32, _)> = unique_vars
1113 .values()
1114 .map(|var| {
1115 let score = score_variable_relevance(&var.name, property_name.as_deref());
1116 (score, var)
1117 })
1118 .collect();
1119
1120 scored_vars.retain(|(score, _)| *score != 0);
1121 scored_vars.sort_by(|(score_a, var_a), (score_b, var_b)| {
1122 if score_a != score_b {
1123 return score_b.cmp(score_a);
1124 }
1125 var_a.name.cmp(&var_b.name)
1126 });
1127
1128 let items = scored_vars
1129 .into_iter()
1130 .map(|(_, var)| {
1131 let options = PathDisplayOptions {
1132 mode: self.runtime_config.path_display_mode,
1133 abbrev_length: self.runtime_config.path_display_abbrev_length,
1134 workspace_folder_paths: &workspace_folder_paths,
1135 root_folder_path: root_folder_path.as_ref(),
1136 };
1137 let insert_text = if in_var_context {
1138 var.name.clone()
1139 } else {
1140 format!("var({})", var.name)
1141 };
1142 CompletionItem {
1143 label: var.name.clone(),
1144 kind: Some(CompletionItemKind::VARIABLE),
1145 detail: Some(variable_completion_detail(var)),
1146 insert_text: Some(insert_text),
1147 documentation: Some(ls_types::Documentation::String(
1148 variable_completion_documentation(
1149 var,
1150 &format_uri_for_display(&var.uri, options),
1151 ),
1152 )),
1153 ..Default::default()
1154 }
1155 })
1156 .collect();
1157
1158 Ok(Some(CompletionResponse::Array(items)))
1159 }
1160
1161 async fn completion_resolve(
1162 &self,
1163 item: CompletionItem,
1164 ) -> tower_lsp_server::jsonrpc::Result<CompletionItem> {
1165 Ok(item)
1166 }
1167
1168 async fn hover(&self, params: HoverParams) -> tower_lsp_server::jsonrpc::Result<Option<Hover>> {
1169 let uri = params.text_document_position_params.text_document.uri;
1170 let position = params.text_document_position_params.position;
1171
1172 let text = {
1173 let docs = self.document_map.read().await;
1174 docs.get(&uri).cloned()
1175 };
1176 let text = match text {
1177 Some(text) => text,
1178 None => return Ok(None),
1179 };
1180
1181 let word = match self.get_word_at_position(&text, position) {
1182 Some(word) => word,
1183 None => return Ok(None),
1184 };
1185
1186 let mut definitions = self.manager.get_variables(&word).await;
1187 if definitions.is_empty() {
1188 return Ok(None);
1189 }
1190
1191 let usages = self.manager.get_usages(&word).await;
1192 let offset = match position_to_offset(&text, position) {
1193 Some(offset) => offset,
1194 None => return Ok(None),
1195 };
1196 let hover_usage = usages.iter().find(|usage| {
1197 if usage.uri != uri {
1198 return false;
1199 }
1200 let start = position_to_offset(&text, usage.range.start).unwrap_or(0);
1201 let end = position_to_offset(&text, usage.range.end).unwrap_or(0);
1202 offset >= start && offset <= end
1203 });
1204
1205 let usage_context = hover_usage
1206 .map(|u| u.usage_context.clone())
1207 .unwrap_or_default();
1208 let is_inline_style = usage_context == "inline-style";
1209 let dom_tree = self.manager.get_dom_tree(&uri).await;
1210 let dom_node = hover_usage.and_then(|u| u.dom_node.clone());
1211
1212 sort_by_cascade(&mut definitions);
1213
1214 let mut hover_text = format!("### CSS Variable: `{}`\n\n", word);
1215
1216 if definitions.len() == 1 {
1217 let var = &definitions[0];
1218 if !var.value.is_empty() {
1219 hover_text.push_str(&format!("**Value:** `{}`", var.value));
1220 if var.important {
1221 hover_text.push_str(" **!important**");
1222 }
1223 hover_text.push_str("\n\n");
1224 }
1225 if let Some(source) = variable_source_label(var) {
1226 hover_text.push_str(&format!("**Generated by:** {}\n\n", source));
1227 }
1228 if !var.selector.is_empty() {
1229 let scope_label = if variable_source_label(var).is_some() {
1230 "CSS scope"
1231 } else {
1232 "Defined in"
1233 };
1234 hover_text.push_str(&format!("**{}:** `{}`\n", scope_label, var.selector));
1235 hover_text.push_str(&format!(
1236 "**Specificity:** {}\n",
1237 format_specificity(calculate_specificity(&var.selector))
1238 ));
1239 }
1240 } else {
1241 hover_text.push_str("**Definitions** (CSS cascade order):\n\n");
1242
1243 for (idx, var) in definitions.iter().enumerate() {
1244 let spec = calculate_specificity(&var.selector);
1245 let is_applicable = if usage_context.is_empty() {
1246 true
1247 } else {
1248 matches_context(
1249 &var.selector,
1250 &usage_context,
1251 dom_tree.as_ref(),
1252 dom_node.as_ref(),
1253 )
1254 };
1255 let is_winner = idx == 0 && (is_applicable || is_inline_style);
1256
1257 let mut line = if var.value.is_empty() {
1258 format!("{}.", idx + 1)
1259 } else {
1260 format!("{}. `{}`", idx + 1, var.value)
1261 };
1262 if let Some(source) = variable_source_label(var) {
1263 line.push_str(&format!(" generated by {}", source));
1264 }
1265 if var.important {
1266 line.push_str(" **!important**");
1267 }
1268 if !var.selector.is_empty() {
1269 line.push_str(&format!(
1270 " from `{}` {}",
1271 var.selector,
1272 format_specificity(spec)
1273 ));
1274 }
1275
1276 if is_winner && !usage_context.is_empty() {
1277 if var.important {
1278 line.push_str(" ✓ **Wins (!important)**");
1279 } else if is_inline_style {
1280 line.push_str(" ✓ **Would apply (inline style)**");
1281 } else if dom_tree.is_some() && dom_node.is_some() {
1282 line.push_str(" ✓ **Applies (DOM match)**");
1283 } else {
1284 line.push_str(" ✓ **Applies here**");
1285 }
1286 } else if !is_applicable && !usage_context.is_empty() && !is_inline_style {
1287 line.push_str(" _(selector doesn't match)_");
1288 } else if idx > 0 && !usage_context.is_empty() {
1289 let winner = &definitions[0];
1290 if winner.important && !var.important {
1291 line.push_str(" _(overridden by !important)_");
1292 } else {
1293 let winner_spec = calculate_specificity(&winner.selector);
1294 let cmp = compare_specificity(winner_spec, spec);
1295 if cmp > 0 {
1296 line.push_str(" _(lower specificity)_");
1297 } else if cmp == 0 {
1298 line.push_str(" _(earlier in source)_");
1299 }
1300 }
1301 }
1302
1303 hover_text.push_str(&line);
1304 hover_text.push('\n');
1305 }
1306
1307 if !usage_context.is_empty() {
1308 if is_inline_style {
1309 hover_text.push_str("\n_Context: Inline style (highest priority)_");
1310 } else if dom_tree.is_some() && dom_node.is_some() {
1311 hover_text.push_str(&format!(
1312 "\n_Context: `{}` (DOM-aware matching)_",
1313 usage_context
1314 ));
1315 } else {
1316 hover_text.push_str(&format!("\n_Context: `{}`_", usage_context));
1317 }
1318 }
1319 }
1320
1321 Ok(Some(Hover {
1322 contents: HoverContents::Markup(MarkupContent {
1323 kind: MarkupKind::Markdown,
1324 value: hover_text,
1325 }),
1326 range: None,
1327 }))
1328 }
1329
1330 async fn goto_definition(
1331 &self,
1332 params: GotoDefinitionParams,
1333 ) -> tower_lsp_server::jsonrpc::Result<Option<GotoDefinitionResponse>> {
1334 let uri = params.text_document_position_params.text_document.uri;
1335 let position = params.text_document_position_params.position;
1336
1337 let text = {
1338 let docs = self.document_map.read().await;
1339 docs.get(&uri).cloned()
1340 };
1341 let text = match text {
1342 Some(text) => text,
1343 None => return Ok(None),
1344 };
1345
1346 let word = match self.get_word_at_position(&text, position) {
1347 Some(word) => word,
1348 None => return Ok(None),
1349 };
1350
1351 let definitions = self.manager.get_variables(&word).await;
1352 let first = match definitions.first() {
1353 Some(def) => def,
1354 None => return Ok(None),
1355 };
1356
1357 Ok(Some(GotoDefinitionResponse::Scalar(Location::new(
1358 first.uri.clone(),
1359 first.range,
1360 ))))
1361 }
1362
1363 async fn code_action(
1364 &self,
1365 params: CodeActionParams,
1366 ) -> tower_lsp_server::jsonrpc::Result<Option<CodeActionResponse>> {
1367 let uri = params.text_document.uri;
1368 let text = {
1369 let docs = self.document_map.read().await;
1370 docs.get(&uri).cloned()
1371 };
1372 let text = match text {
1373 Some(text) => text,
1374 None => return Ok(Some(Vec::new())),
1375 };
1376
1377 let mut actions: Vec<CodeActionOrCommand> = Vec::new();
1378
1379 let language_id = {
1380 let languages = self.document_language_map.read().await;
1381 languages.get(&uri).cloned()
1382 };
1383 let lookup_map = self.lookup_extension_map.read().await.clone();
1384 let allow_create_definition = matches!(
1385 resolve_document_kind(uri.path().as_str(), language_id.as_deref(), &lookup_map),
1386 Some(DocumentKind::Css)
1387 );
1388
1389 actions.extend(code_actions_for_undefined_variables(
1391 &uri,
1392 &text,
1393 ¶ms.context,
1394 &self.runtime_config,
1395 allow_create_definition,
1396 ));
1397 actions.extend(code_actions_for_replaceable_literal_colors(
1398 &uri,
1399 ¶ms.context,
1400 &self.runtime_config,
1401 ));
1402
1403 Ok(Some(actions))
1404 }
1405
1406 async fn references(
1407 &self,
1408 params: ReferenceParams,
1409 ) -> tower_lsp_server::jsonrpc::Result<Option<Vec<Location>>> {
1410 let uri = params.text_document_position.text_document.uri;
1411 let position = params.text_document_position.position;
1412
1413 let text = {
1414 let docs = self.document_map.read().await;
1415 docs.get(&uri).cloned()
1416 };
1417 let text = match text {
1418 Some(text) => text,
1419 None => return Ok(None),
1420 };
1421
1422 let word = match self.get_word_at_position(&text, position) {
1423 Some(word) => word,
1424 None => return Ok(None),
1425 };
1426
1427 let (definitions, usages) = self.manager.get_references(&word).await;
1428 let mut locations = Vec::new();
1429 for def in definitions {
1430 locations.push(Location::new(def.uri, def.range));
1431 }
1432 for usage in usages {
1433 locations.push(Location::new(usage.uri, usage.range));
1434 }
1435
1436 Ok(Some(locations))
1437 }
1438
1439 async fn document_color(
1440 &self,
1441 params: DocumentColorParams,
1442 ) -> tower_lsp_server::jsonrpc::Result<Vec<ColorInformation>> {
1443 let config = self.manager.get_config().await;
1444 if !config.enable_color_provider {
1445 return Ok(Vec::new());
1446 }
1447
1448 let uri = params.text_document.uri;
1449 let text = {
1450 let docs = self.document_map.read().await;
1451 docs.get(&uri).cloned()
1452 };
1453 let text = match text {
1454 Some(text) => text,
1455 None => return Ok(Vec::new()),
1456 };
1457
1458 let mut colors = Vec::new();
1459 let mut seen_ranges: HashSet<(u32, u32, u32, u32)> = HashSet::new();
1460 let range_key = |range: &Range| {
1461 (
1462 range.start.line,
1463 range.start.character,
1464 range.end.line,
1465 range.end.character,
1466 )
1467 };
1468
1469 if !config.color_only_on_variables {
1470 let definitions = self.manager.get_document_variables(&uri).await;
1471 for def in definitions {
1472 if let Some(color) = parse_color(&def.value) {
1473 if let Some(value_range) = def.value_range {
1474 if seen_ranges.insert(range_key(&value_range)) {
1475 colors.push(ColorInformation {
1476 range: value_range,
1477 color,
1478 });
1479 }
1480 } else if let Some(range) = find_value_range_in_definition(&text, &def) {
1481 if seen_ranges.insert(range_key(&range)) {
1482 colors.push(ColorInformation { range, color });
1483 }
1484 }
1485 }
1486 }
1487 }
1488
1489 let usages = self.manager.get_document_usages(&uri).await;
1490 for usage in usages {
1491 if let Some(color) = self.manager.resolve_variable_color(&usage.name).await {
1492 if seen_ranges.insert(range_key(&usage.range)) {
1493 colors.push(ColorInformation {
1494 range: usage.range,
1495 color,
1496 });
1497 }
1498 }
1499 }
1500
1501 for caps in self.var_usage_regex.captures_iter(&text) {
1502 let match_all = caps.get(0).unwrap();
1503 let var_name = caps.get(1).unwrap().as_str();
1504 let range = Range::new(
1505 crate::types::offset_to_position(&text, match_all.start()),
1506 crate::types::offset_to_position(&text, match_all.end()),
1507 );
1508 if !seen_ranges.insert(range_key(&range)) {
1509 continue;
1510 }
1511 if let Some(color) = self.manager.resolve_variable_color(var_name).await {
1512 colors.push(ColorInformation { range, color });
1513 }
1514 }
1515
1516 Ok(colors)
1517 }
1518
1519 async fn color_presentation(
1520 &self,
1521 params: ColorPresentationParams,
1522 ) -> tower_lsp_server::jsonrpc::Result<Vec<ColorPresentation>> {
1523 if !self.runtime_config.enable_color_provider {
1524 return Ok(Vec::new());
1525 }
1526 Ok(generate_color_presentations(params.color, params.range))
1527 }
1528
1529 async fn prepare_rename(
1530 &self,
1531 params: TextDocumentPositionParams,
1532 ) -> tower_lsp_server::jsonrpc::Result<Option<PrepareRenameResponse>> {
1533 let uri = params.text_document.uri;
1534 let position = params.position;
1535
1536 let text = {
1537 let docs = self.document_map.read().await;
1538 docs.get(&uri).cloned()
1539 };
1540 let text = match text {
1541 Some(text) => text,
1542 None => return Ok(None),
1543 };
1544
1545 let (_, range) = match self.get_word_and_range_at_position(&text, position) {
1546 Some(word_and_range) => word_and_range,
1547 None => return Ok(None),
1548 };
1549
1550 Ok(Some(PrepareRenameResponse::Range(range)))
1551 }
1552
1553 async fn rename(
1554 &self,
1555 params: RenameParams,
1556 ) -> tower_lsp_server::jsonrpc::Result<Option<WorkspaceEdit>> {
1557 let uri = params.text_document_position.text_document.uri;
1558 let position = params.text_document_position.position;
1559 let new_name = params.new_name;
1560
1561 let text = {
1562 let docs = self.document_map.read().await;
1563 docs.get(&uri).cloned()
1564 };
1565 let text = match text {
1566 Some(text) => text,
1567 None => return Ok(None),
1568 };
1569
1570 let old_name = match self.get_word_at_position(&text, position) {
1571 Some(word) => word,
1572 None => return Ok(None),
1573 };
1574
1575 let (definitions, usages) = self.manager.get_references(&old_name).await;
1576 let mut changes: HashMap<Uri, Vec<TextEdit>> = HashMap::new();
1577
1578 for def in definitions {
1579 let range = def.name_range.unwrap_or(def.range);
1580 changes.entry(def.uri.clone()).or_default().push(TextEdit {
1581 range,
1582 new_text: new_name.clone(),
1583 });
1584 }
1585
1586 for usage in usages {
1587 let range = usage.name_range.unwrap_or(usage.range);
1588 changes
1589 .entry(usage.uri.clone())
1590 .or_default()
1591 .push(TextEdit {
1592 range,
1593 new_text: new_name.clone(),
1594 });
1595 }
1596
1597 Ok(Some(WorkspaceEdit {
1598 changes: Some(changes),
1599 document_changes: None,
1600 change_annotations: None,
1601 }))
1602 }
1603
1604 #[allow(deprecated)]
1605 async fn document_symbol(
1606 &self,
1607 params: DocumentSymbolParams,
1608 ) -> tower_lsp_server::jsonrpc::Result<Option<DocumentSymbolResponse>> {
1609 let vars = self
1610 .manager
1611 .get_document_variables(¶ms.text_document.uri)
1612 .await;
1613 let symbols: Vec<DocumentSymbol> = vars
1614 .into_iter()
1615 .map(|var| {
1616 let detail = variable_completion_detail(&var);
1617 DocumentSymbol {
1618 name: var.name,
1619 detail: Some(detail),
1620 kind: SymbolKind::VARIABLE,
1621 tags: None,
1622 deprecated: None,
1623 range: var.range,
1624 selection_range: var.range,
1625 children: None,
1626 }
1627 })
1628 .collect();
1629
1630 Ok(Some(DocumentSymbolResponse::Nested(symbols)))
1631 }
1632
1633 #[allow(deprecated)]
1634 async fn symbol(
1635 &self,
1636 params: WorkspaceSymbolParams,
1637 ) -> tower_lsp_server::jsonrpc::Result<Option<WorkspaceSymbolResponse>> {
1638 let query = params.query.to_lowercase();
1639 let vars = self.manager.get_all_variables().await;
1640 let mut symbols = Vec::new();
1641
1642 for var in vars {
1643 if !query.is_empty() && !var.name.to_lowercase().contains(&query) {
1644 continue;
1645 }
1646 symbols.push(SymbolInformation {
1647 name: var.name,
1648 kind: SymbolKind::VARIABLE,
1649 tags: None,
1650 deprecated: None,
1651 location: Location::new(var.uri.clone(), var.range),
1652 container_name: None,
1653 });
1654 }
1655
1656 Ok(Some(WorkspaceSymbolResponse::Flat(symbols)))
1657 }
1658}
1659
1660impl CssVariableLsp {
1661 async fn literal_color_under_cursor(
1662 &self,
1663 uri: &Uri,
1664 position: Position,
1665 ) -> Option<NormalizedColorKey> {
1666 let occurrences = self
1667 .manager
1668 .get_literal_colors_at_position(uri, position)
1669 .await;
1670 occurrences
1671 .into_iter()
1672 .find(|occurrence| range_contains_position(&occurrence.range, position))
1673 .map(|occurrence| occurrence.normalized_color)
1674 }
1675
1676 pub async fn scan_workspace_folders(&self, folders: Vec<WorkspaceFolder>) {
1678 let folder_uris: Vec<Uri> = folders.iter().map(|f| f.uri.clone()).collect();
1679
1680 self.client
1681 .log_message(
1682 MessageType::INFO,
1683 format!("Scanning {} workspace folders...", folder_uris.len()),
1684 )
1685 .await;
1686
1687 let manager = self.manager.clone();
1688 let client = self.client.clone();
1689
1690 let mut last_logged_percentage = 0;
1691 let result = crate::workspace::scan_workspace(folder_uris, &manager, |current, total| {
1692 if total == 0 {
1693 return;
1694 }
1695 let percentage = ((current as f64 / total as f64) * 100.0).round() as i32;
1696 if percentage - last_logged_percentage >= 20 || current == total {
1697 last_logged_percentage = percentage;
1698 let client = client.clone();
1699 tokio::spawn(async move {
1700 client
1701 .log_message(
1702 MessageType::INFO,
1703 format!(
1704 "Scanning CSS files: {}/{} ({}%)",
1705 current, total, percentage
1706 ),
1707 )
1708 .await;
1709 });
1710 }
1711 })
1712 .await;
1713
1714 match result {
1715 Ok(_) => {
1716 let total_vars = manager.get_all_variables().await.len();
1717 self.client
1718 .log_message(
1719 MessageType::INFO,
1720 format!(
1721 "Workspace scan complete. Found {} CSS variables.",
1722 total_vars
1723 ),
1724 )
1725 .await;
1726 }
1727 Err(e) => {
1728 self.client
1729 .log_message(MessageType::ERROR, format!("Workspace scan failed: {}", e))
1730 .await;
1731 }
1732 }
1733
1734 let _document_lifecycle_guard = self.document_lifecycle_lock.lock().await;
1737 let open_documents = {
1738 let documents = self.document_map.read().await;
1739 let languages = self.document_language_map.read().await;
1740 documents
1741 .iter()
1742 .map(|(uri, text)| (uri.clone(), text.clone(), languages.get(uri).cloned()))
1743 .collect::<Vec<_>>()
1744 };
1745 for (uri, text, language_id) in open_documents {
1746 let version = if is_supported_config_path(std::path::Path::new(uri.path().as_str())) {
1747 self.manager.get_document_version(&uri).await
1748 } else {
1749 None
1750 };
1751 self.parse_document_text(&uri, &text, language_id.as_deref(), version)
1752 .await;
1753 }
1754
1755 self.validate_all_open_documents().await;
1756 }
1757}
1758
1759#[allow(clippy::too_many_arguments)]
1760async fn validate_document_text_with(
1761 client: &Client,
1762 manager: &CssVariableManager,
1763 usage_regex: &Regex,
1764 undefined_var_fallback: UndefinedVarFallbackMode,
1765 has_related_info: bool,
1766 uri: &Uri,
1767 text: &str,
1768 document_usage_map: &Arc<RwLock<HashMap<Uri, HashSet<String>>>>,
1769 usage_index: &Arc<RwLock<HashMap<String, HashSet<Uri>>>>,
1770 color_occurrence_uris: Option<&Arc<RwLock<HashSet<Uri>>>>,
1771) {
1772 let mut diagnostics = Vec::new();
1773 let mut current_usages = HashSet::new();
1774 let mut has_literal_colors = false;
1775 let default_severity = DiagnosticSeverity::WARNING;
1776
1777 for captures in usage_regex.captures_iter(text) {
1778 let match_all = captures.get(0).unwrap();
1779 let name = captures.get(1).unwrap().as_str();
1780 let fallback = captures.get(2).map(|m| m.as_str()).unwrap_or("");
1781 let has_fallback = !fallback.trim().is_empty();
1782
1783 current_usages.insert(name.to_string());
1784
1785 let definitions = manager.get_variables(name).await;
1786 if !definitions.is_empty() {
1787 continue;
1788 }
1789
1790 let severity = if has_fallback {
1791 match undefined_var_fallback {
1792 UndefinedVarFallbackMode::Warning => Some(default_severity),
1793 UndefinedVarFallbackMode::Info => Some(DiagnosticSeverity::INFORMATION),
1794 UndefinedVarFallbackMode::Off => None,
1795 }
1796 } else {
1797 Some(default_severity)
1798 };
1799 let severity = match severity {
1800 Some(severity) => severity,
1801 None => continue,
1802 };
1803 let range = Range::new(
1804 crate::types::offset_to_position(text, match_all.start()),
1805 crate::types::offset_to_position(text, match_all.end()),
1806 );
1807 diagnostics.push(Diagnostic {
1808 range,
1809 severity: Some(severity),
1810 code: Some(ls_types::NumberOrString::String(
1811 "css-variable-lsp.undefined-variable".to_string(),
1812 )),
1813 code_description: None,
1814 source: Some("css-variable-lsp".to_string()),
1815 message: format!("CSS variable '{}' is not defined in the workspace", name),
1816 related_information: if has_related_info {
1817 Some(Vec::new())
1818 } else {
1819 None
1820 },
1821 tags: None,
1822 data: Some(serde_json::json!({
1823 "name": name,
1824 "hasFallback": has_fallback,
1825 "range": {
1826 "start": { "line": range.start.line, "character": range.start.character },
1827 "end": { "line": range.end.line, "character": range.end.character }
1828 }
1829 })),
1830 });
1831 }
1832
1833 for occurrence in manager.get_document_literal_colors(uri).await {
1834 let all_replacements = manager
1835 .get_variables_by_color_key(&occurrence.normalized_color)
1836 .await;
1837
1838 let replacements: Vec<_> = all_replacements
1842 .iter()
1843 .filter(|var| {
1844 if var.uri != *uri {
1846 return true;
1847 }
1848 let Some(value_range) = &var.value_range else {
1850 return true;
1851 };
1852 !range_contains(value_range, &occurrence.range)
1854 })
1855 .cloned()
1856 .collect();
1857
1858 if replacements.is_empty() {
1859 continue;
1860 }
1861
1862 has_literal_colors = true;
1864
1865 let replacement_data: Vec<_> = replacements
1866 .iter()
1867 .map(|var| {
1868 serde_json::json!({
1869 "name": var.name,
1870 "value": var.value,
1871 "uri": var.uri,
1872 })
1873 })
1874 .collect();
1875 let replacement_count = replacement_data.len();
1876 let message = if replacement_count == 1 {
1877 format!("Consider using {} for this color", replacements[0].name)
1878 } else {
1879 let var_names: Vec<&str> = replacements.iter().map(|v| v.name.as_str()).collect();
1880 format!(
1881 "Consider using one of these variables: {}",
1882 var_names.join(", ")
1883 )
1884 };
1885
1886 diagnostics.push(Diagnostic {
1887 range: occurrence.range,
1888 severity: Some(DiagnosticSeverity::INFORMATION),
1889 code: Some(ls_types::NumberOrString::String(
1890 "css-variable-lsp.literal-color-replaceable".to_string(),
1891 )),
1892 code_description: None,
1893 source: Some("css-variable-lsp".to_string()),
1894 message,
1895 related_information: None,
1896 tags: None,
1897 data: Some(serde_json::json!({
1898 "literal": occurrence.text,
1899 "usageContext": occurrence.usage_context,
1900 "replacements": replacement_data,
1901 })),
1902 });
1903 }
1904
1905 {
1907 let mut usage_map = document_usage_map.write().await;
1908 let old_usages = usage_map.insert(uri.clone(), current_usages.clone());
1909
1910 let mut index = usage_index.write().await;
1911
1912 if let Some(old_set) = old_usages {
1914 for name in old_set {
1915 if !current_usages.contains(&name) {
1916 if let Some(uris) = index.get_mut(&name) {
1917 uris.remove(uri);
1918 if uris.is_empty() {
1919 index.remove(&name);
1920 }
1921 }
1922 }
1923 }
1924 }
1925
1926 for name in current_usages {
1928 index
1929 .entry(name)
1930 .or_insert_with(HashSet::new)
1931 .insert(uri.clone());
1932 }
1933 }
1934
1935 if let Some(color_uris) = color_occurrence_uris {
1937 let mut color_uris = color_uris.write().await;
1938 if has_literal_colors {
1939 color_uris.insert(uri.clone());
1940 } else {
1941 color_uris.remove(uri);
1942 }
1943 }
1944
1945 client
1946 .publish_diagnostics(uri.clone(), diagnostics, None)
1947 .await;
1948}
1949
1950fn is_var_function_context_slice(before_cursor: &str) -> bool {
1951 let bytes = before_cursor.as_bytes();
1952 if bytes.is_empty() {
1953 return false;
1954 }
1955
1956 let mut i = bytes.len();
1957 if is_word_byte(bytes[i - 1]) {
1958 let mut start = i;
1959 while start > 0 && is_word_byte(bytes[start - 1]) {
1960 start -= 1;
1961 }
1962 if i - start < 2 || bytes[start] != b'-' || bytes[start + 1] != b'-' {
1963 return false;
1964 }
1965 i = start;
1966 }
1967
1968 while i > 0 && bytes[i - 1].is_ascii_whitespace() {
1969 i -= 1;
1970 }
1971
1972 if i == 0 || bytes[i - 1] != b'(' {
1973 return false;
1974 }
1975
1976 let paren_idx = i - 1;
1977 if paren_idx < 3 {
1978 return false;
1979 }
1980 let start = paren_idx - 3;
1981 if !bytes[start..paren_idx].eq_ignore_ascii_case(b"var") {
1982 return false;
1983 }
1984 if start == 0 {
1985 return true;
1986 }
1987 !is_word_byte(bytes[start - 1])
1988}
1989
1990#[cfg(test)]
1991mod tests {
1992 use super::*;
1993 use std::str::FromStr;
1994
1995 fn test_word_extraction(css: &str, cursor_pos: usize) -> Option<String> {
1996 use ls_types::Position;
1997 let position = Position {
1998 line: 0,
1999 character: cursor_pos as u32,
2000 };
2001
2002 let offset = position_to_offset(css, position)?;
2003 let offset = clamp_to_char_boundary(css, offset);
2004 let before = &css[..offset];
2005 let after = &css[offset..];
2006
2007 let left = before
2008 .rsplit(|c: char| !is_word_char(c))
2009 .next()
2010 .unwrap_or("");
2011 let right = after.split(|c: char| !is_word_char(c)).next().unwrap_or("");
2012 let word = format!("{}{}", left, right);
2013 if word.starts_with("--") {
2014 Some(word)
2015 } else {
2016 None
2017 }
2018 }
2019
2020 #[test]
2021 fn test_word_extraction_preserves_fallbacks() {
2022 let css = "background: var(--primary-color, blue);";
2024 let result = test_word_extraction(css, 20); assert_eq!(result, Some("--primary-color".to_string()));
2026
2027 let css2 = "color: var(--secondary-color, #ccc);";
2029 let result2 = test_word_extraction(css2, 15); assert_eq!(result2, Some("--secondary-color".to_string()));
2031
2032 let css3 = "border: var(--accent-color, var(--fallback, black));";
2034 let result3 = test_word_extraction(css3, 16); assert_eq!(result3, Some("--accent-color".to_string()));
2036
2037 let css4 = "--theme-color: red;";
2039 let result4 = test_word_extraction(css4, 5); assert_eq!(result4, Some("--theme-color".to_string()));
2041
2042 let css5 = "margin: var(--spacing);";
2044 let result5 = test_word_extraction(css5, 15); assert_eq!(result5, Some("--spacing".to_string()));
2046 }
2047
2048 #[test]
2049 fn test_var_function_context_open() {
2050 let text = "color: var(--primary";
2051 assert!(is_var_function_context_slice(text));
2052 }
2053
2054 #[test]
2055 fn test_var_function_context_closed() {
2056 let text = "color: var(--primary);";
2057 assert!(!is_var_function_context_slice(text));
2058 }
2059
2060 #[test]
2061 fn test_var_function_context_nested() {
2062 let text = "color: var(--primary, calc(100% - var(--secondary";
2063 assert!(is_var_function_context_slice(text));
2064 }
2065
2066 #[test]
2067 fn test_var_function_context_after_fallback() {
2068 let text = "color: var(--primary, ";
2069 assert!(!is_var_function_context_slice(text));
2070 }
2071
2072 #[test]
2073 fn test_var_function_context_requires_boundary() {
2074 let text = "navbar(--primary";
2075 assert!(!is_var_function_context_slice(text));
2076 }
2077
2078 #[test]
2079 fn test_var_function_context_case_insensitive() {
2080 let text = "color: VAR(--primary";
2081 assert!(is_var_function_context_slice(text));
2082 }
2083
2084 #[test]
2085 fn test_completion_value_context_slice_css() {
2086 let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
2087 let text = ".card { color: var(";
2088 let position = crate::types::offset_to_position(text, text.len());
2089 let uri = Uri::from_str("file:///styles.css").unwrap();
2090 let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
2091 .expect("expected css slice");
2092 assert!(context.slice.ends_with("color: var("));
2093 assert!(!context.allow_without_braces);
2094 let value_context = get_value_context_info(context.slice, context.allow_without_braces);
2095 assert!(value_context.is_value_context);
2096 assert_eq!(value_context.property_name.as_deref(), Some("color"));
2097 }
2098
2099 #[test]
2100 fn test_completion_value_context_slice_html_style_attribute() {
2101 let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
2102 let text = r#"<div style="color: var("#;
2103 let position = crate::types::offset_to_position(text, text.len());
2104 let uri = Uri::from_str("file:///index.html").unwrap();
2105 let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
2106 .expect("expected html style attribute slice");
2107 assert_eq!(context.slice, "color: var(");
2108 assert!(context.allow_without_braces);
2109 }
2110
2111 #[test]
2112 fn test_completion_value_context_slice_html_style_block() {
2113 let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
2114 let text = "<style>body { color: var(";
2115 let position = crate::types::offset_to_position(text, text.len());
2116 let uri = Uri::from_str("file:///index.html").unwrap();
2117 let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
2118 .expect("expected html style block slice");
2119 assert_eq!(context.slice, "body { color: var(");
2120 assert!(!context.allow_without_braces);
2121 }
2122
2123 #[test]
2124 fn test_completion_value_context_slice_js_string() {
2125 let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
2126 let text = r#"const css = "color: var("#;
2127 let position = crate::types::offset_to_position(text, text.len());
2128 let uri = Uri::from_str("file:///app.js").unwrap();
2129 let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
2130 .expect("expected js string slice");
2131 assert_eq!(context.slice, "color: var(");
2132 assert!(context.allow_without_braces);
2133 }
2134
2135 #[test]
2136 fn test_completion_value_context_slice_js_non_string() {
2137 let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
2138 let text = "const css = color: var(";
2139 let position = crate::types::offset_to_position(text, text.len());
2140 let uri = Uri::from_str("file:///app.js").unwrap();
2141 assert!(completion_value_context_slice(text, position, None, &uri, &lookup_map).is_none());
2142 }
2143
2144 #[test]
2145 fn test_completion_value_context_slice_unknown() {
2146 let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
2147 let text = "color: var(";
2148 let position = crate::types::offset_to_position(text, text.len());
2149 let uri = Uri::from_str("file:///notes.txt").unwrap();
2150 assert!(completion_value_context_slice(text, position, None, &uri, &lookup_map).is_none());
2151 }
2152
2153 #[test]
2154 fn test_html_style_attribute_slice_open() {
2155 let text = r#"<div style="color: var("#;
2156 let slice = find_html_style_attribute_slice(text).unwrap();
2157 assert_eq!(slice, "color: var(");
2158 }
2159
2160 #[test]
2161 fn test_html_style_attribute_slice_closed() {
2162 let text = r#"<div style="color: red">"#;
2163 assert!(find_html_style_attribute_slice(text).is_none());
2164 }
2165
2166 #[test]
2167 fn test_html_style_block_slice() {
2168 let text = "<style>body { color: var(";
2169 let slice = find_html_style_block_slice(text).unwrap();
2170 assert_eq!(slice, "body { color: var(");
2171 }
2172
2173 #[test]
2174 fn test_js_string_segment_basic() {
2175 let text = r#"const css = \"color: var("#;
2176 let slice = find_js_string_segment(text).unwrap();
2177 assert_eq!(slice, "color: var(");
2178 }
2179
2180 #[test]
2181 fn test_js_string_segment_template_after_expression() {
2182 let text = r#"const css = `color: ${theme}; background: var("#;
2183 let slice = find_js_string_segment(text).unwrap();
2184 assert_eq!(slice, "; background: var(");
2185 }
2186
2187 #[test]
2188 fn test_js_string_segment_template_expression() {
2189 let text = r#"const css = `color: ${theme"#;
2190 assert!(find_js_string_segment(text).is_none());
2191 }
2192
2193 #[test]
2194 fn resolve_document_kind_prefers_language_id() {
2195 let lookup_files = vec!["**/*.custom".to_string()];
2196 let lookup_map = build_lookup_extension_map(&lookup_files);
2197
2198 let kind = resolve_document_kind("file.custom", Some("html"), &lookup_map);
2199 assert_eq!(kind, Some(DocumentKind::Html));
2200 }
2201
2202 #[test]
2203 fn resolve_document_kind_uses_lookup_extensions() {
2204 let lookup_files = vec![
2205 "**/*.{css,scss}".to_string(),
2206 "**/*.vue".to_string(),
2207 "**/*.custom".to_string(),
2208 ];
2209 let lookup_map = build_lookup_extension_map(&lookup_files);
2210
2211 let css_kind = resolve_document_kind("styles.scss", None, &lookup_map);
2212 assert_eq!(css_kind, Some(DocumentKind::Css));
2213
2214 let html_kind = resolve_document_kind("component.vue", None, &lookup_map);
2215 assert_eq!(html_kind, Some(DocumentKind::Html));
2216
2217 let custom_kind = resolve_document_kind("theme.custom", None, &lookup_map);
2218 assert_eq!(custom_kind, Some(DocumentKind::Css));
2219 }
2220
2221 #[test]
2222 fn resolve_document_kind_returns_none_for_unknown() {
2223 let lookup_files = vec!["**/*.css".to_string()];
2224 let lookup_map = build_lookup_extension_map(&lookup_files);
2225
2226 let kind = resolve_document_kind("notes.txt", Some("plaintext"), &lookup_map);
2227 assert_eq!(kind, None);
2228 }
2229}