1use std::collections::{HashMap, HashSet};
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use regex::Regex;
6use tokio::sync::RwLock;
7use tower_lsp::lsp_types::{
8 ColorInformation, ColorPresentation, ColorPresentationParams, ColorProviderCapability,
9 CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse,
10 Diagnostic, DiagnosticSeverity, DidChangeTextDocumentParams, DidChangeWatchedFilesParams,
11 DidChangeWorkspaceFoldersParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
12 DocumentColorParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse,
13 FileChangeType, GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents,
14 HoverParams, InitializeParams, InitializeResult, Location, MarkupContent, MarkupKind,
15 MessageType, OneOf, Position, Range, ReferenceParams, RenameParams, ServerCapabilities,
16 SymbolInformation, SymbolKind, TextDocumentContentChangeEvent, TextDocumentSyncCapability,
17 TextDocumentSyncKind, TextEdit, Url, WorkDoneProgressOptions, WorkspaceEdit, WorkspaceFolder,
18 WorkspaceFoldersServerCapabilities, WorkspaceServerCapabilities, WorkspaceSymbolParams,
19};
20use tower_lsp::{Client, LanguageServer};
21
22use crate::color::{generate_color_presentations, parse_color};
23use crate::manager::CssVariableManager;
24use crate::parsers::{parse_css_document, parse_html_document};
25use crate::path_display::{format_uri_for_display, to_normalized_fs_path, PathDisplayOptions};
26use crate::runtime_config::RuntimeConfig;
27use crate::specificity::{
28 calculate_specificity, compare_specificity, format_specificity, matches_context,
29 sort_by_cascade,
30};
31use crate::types::{position_to_offset, Config};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34enum DocumentKind {
35 Css,
36 Html,
37}
38
39pub struct CssVariableLsp {
40 client: Client,
41 manager: Arc<CssVariableManager>,
42 document_map: Arc<RwLock<HashMap<Url, String>>>,
43 runtime_config: RuntimeConfig,
44 workspace_folder_paths: Arc<RwLock<Vec<PathBuf>>>,
45 root_folder_path: Arc<RwLock<Option<PathBuf>>>,
46 has_workspace_folder_capability: Arc<RwLock<bool>>,
47 has_diagnostic_related_information: Arc<RwLock<bool>>,
48 usage_regex: Regex,
49 var_usage_regex: Regex,
50 var_partial_regex: Regex,
51 style_attr_regex: Regex,
52 lookup_extension_map: HashMap<String, DocumentKind>,
53 document_language_map: Arc<RwLock<HashMap<Url, String>>>,
54 document_usage_map: Arc<RwLock<HashMap<Url, HashSet<String>>>>,
55 usage_index: Arc<RwLock<HashMap<String, HashSet<Url>>>>,
56}
57
58impl CssVariableLsp {
59 pub fn new(client: Client, runtime_config: RuntimeConfig) -> Self {
60 let config = Config::from_runtime(&runtime_config);
61 let lookup_extension_map = build_lookup_extension_map(&config.lookup_files);
62 Self {
63 client,
64 manager: Arc::new(CssVariableManager::new(config)),
65 document_map: Arc::new(RwLock::new(HashMap::new())),
66 runtime_config,
67 workspace_folder_paths: Arc::new(RwLock::new(Vec::new())),
68 root_folder_path: Arc::new(RwLock::new(None)),
69 has_workspace_folder_capability: Arc::new(RwLock::new(false)),
70 has_diagnostic_related_information: Arc::new(RwLock::new(false)),
71 usage_regex: Regex::new(r"var\((--[\w-]+)(?:\s*,\s*[^)]+)?\)").unwrap(),
72 var_usage_regex: Regex::new(r"var\((--[\w-]+)\)").unwrap(),
73 var_partial_regex: Regex::new(r"var\(\s*(--[\w-]*)$").unwrap(),
74 style_attr_regex: Regex::new(r#"(?i)style\s*=\s*["'][^"']*:\s*[^"';]*$"#).unwrap(),
75 lookup_extension_map,
76 document_language_map: Arc::new(RwLock::new(HashMap::new())),
77 document_usage_map: Arc::new(RwLock::new(HashMap::new())),
78 usage_index: Arc::new(RwLock::new(HashMap::new())),
79 }
80 }
81
82 async fn update_workspace_folder_paths(&self, folders: Option<Vec<WorkspaceFolder>>) {
83 let mut paths = Vec::new();
84 if let Some(folders) = folders {
85 for folder in folders {
86 if let Some(path) = to_normalized_fs_path(&folder.uri) {
87 paths.push(path);
88 }
89 }
90 }
91 paths.sort_by_key(|b| std::cmp::Reverse(b.to_string_lossy().len()));
92 let mut stored = self.workspace_folder_paths.write().await;
93 *stored = paths;
94 }
95
96 async fn parse_document_text(&self, uri: &Url, text: &str, language_id: Option<&str>) {
97 self.manager.remove_document(uri).await;
98
99 let path = uri.path();
100 let kind = resolve_document_kind(path, language_id, &self.lookup_extension_map);
101 let result = match kind {
102 Some(DocumentKind::Html) => parse_html_document(text, uri, &self.manager).await,
103 Some(DocumentKind::Css) => parse_css_document(text, uri, &self.manager).await,
104 None => return,
105 };
106
107 if let Err(e) = result {
108 self.client
109 .log_message(MessageType::ERROR, format!("Parse error: {}", e))
110 .await;
111 }
112 }
113
114 async fn validate_document_text(&self, uri: &Url, text: &str) {
115 let has_related_info = *self.has_diagnostic_related_information.read().await;
116 validate_document_text_with(
117 &self.client,
118 self.manager.as_ref(),
119 &self.usage_regex,
120 has_related_info,
121 uri,
122 text,
123 &self.document_usage_map,
124 &self.usage_index,
125 )
126 .await;
127 }
128
129 async fn validate_all_open_documents(&self) {
130 let has_related_info = *self.has_diagnostic_related_information.read().await;
131 let docs_snapshot = {
132 let docs = self.document_map.read().await;
133 docs.iter()
134 .map(|(uri, text)| (uri.clone(), text.clone()))
135 .collect::<Vec<_>>()
136 };
137
138 for (uri, text) in docs_snapshot {
139 validate_document_text_with(
140 &self.client,
141 self.manager.as_ref(),
142 &self.usage_regex,
143 has_related_info,
144 &uri,
145 &text,
146 &self.document_usage_map,
147 &self.usage_index,
148 )
149 .await;
150 }
151 }
152
153 async fn update_document_from_disk(&self, uri: &Url) {
154 let path = match to_normalized_fs_path(uri) {
155 Some(path) => path,
156 None => {
157 self.manager.remove_document(uri).await;
158 return;
159 }
160 };
161
162 match tokio::fs::read_to_string(&path).await {
163 Ok(text) => {
164 self.parse_document_text(uri, &text, None).await;
165 }
166 Err(_) => {
167 self.manager.remove_document(uri).await;
168 }
169 }
170 }
171
172 async fn apply_content_changes(
173 &self,
174 uri: &Url,
175 changes: Vec<TextDocumentContentChangeEvent>,
176 ) -> Option<String> {
177 let mut docs = self.document_map.write().await;
178 let mut text = if let Some(existing) = docs.get(uri) {
179 existing.clone()
180 } else {
181 if changes.len() == 1 && changes[0].range.is_none() {
182 let new_text = changes[0].text.clone();
183 docs.insert(uri.clone(), new_text.clone());
184 return Some(new_text);
185 }
186 return None;
187 };
188
189 for change in changes {
190 apply_change_to_text(&mut text, &change);
191 }
192
193 docs.insert(uri.clone(), text.clone());
194 Some(text)
195 }
196
197 fn get_word_at_position(&self, text: &str, position: Position) -> Option<String> {
198 let offset = position_to_offset(text, position)?;
199 let offset = clamp_to_char_boundary(text, offset);
200 let before = &text[..offset];
201 let after = &text[offset..];
202
203 let left = before
204 .rsplit(|c: char| !is_word_char(c))
205 .next()
206 .unwrap_or("");
207 let right = after.split(|c: char| !is_word_char(c)).next().unwrap_or("");
208 let word = format!("{}{}", left, right);
209 if word.starts_with("--") {
210 Some(word)
211 } else {
212 None
213 }
214 }
215
216 fn is_in_css_value_context(&self, text: &str, position: Position) -> bool {
217 let offset = match position_to_offset(text, position) {
218 Some(o) => o,
219 None => return false,
220 };
221 let start = clamp_to_char_boundary(text, offset.saturating_sub(200));
222 let offset = clamp_to_char_boundary(text, offset);
223 let before_cursor = &text[start..offset];
224
225 if self.var_partial_regex.is_match(before_cursor) {
226 return true;
227 }
228
229 if let Some(_property_name) = get_property_name_from_context(before_cursor) {
230 return true;
231 }
232
233 if self.style_attr_regex.is_match(before_cursor) {
234 return true;
235 }
236
237 false
238 }
239
240 fn get_property_name_from_context(&self, text: &str, position: Position) -> Option<String> {
241 let offset = position_to_offset(text, position)?;
242 let start = clamp_to_char_boundary(text, offset.saturating_sub(200));
243 let offset = clamp_to_char_boundary(text, offset);
244 let before_cursor = &text[start..offset];
245 get_property_name_from_context(before_cursor)
246 }
247
248 async fn is_document_open(&self, uri: &Url) -> bool {
249 let docs = self.document_map.read().await;
250 docs.contains_key(uri)
251 }
252
253 async fn revalidate_affected_documents(
254 &self,
255 changed_names: &HashSet<String>,
256 exclude_uri: Option<&Url>,
257 ) {
258 let mut affected_uris = HashSet::new();
259 {
260 let index = self.usage_index.read().await;
261 for name in changed_names {
262 if let Some(uris) = index.get(name) {
263 for uri in uris {
264 if exclude_uri != Some(uri) {
265 affected_uris.insert(uri.clone());
266 }
267 }
268 }
269 }
270 }
271
272 if affected_uris.is_empty() {
273 return;
274 }
275
276 let has_related_info = *self.has_diagnostic_related_information.read().await;
277 let affected_snapshot = {
278 let docs = self.document_map.read().await;
279 affected_uris
280 .into_iter()
281 .filter_map(|uri| docs.get(&uri).map(|text| (uri, text.clone())))
282 .collect::<Vec<_>>()
283 };
284
285 for (uri, text) in affected_snapshot {
286 validate_document_text_with(
287 &self.client,
288 self.manager.as_ref(),
289 &self.usage_regex,
290 has_related_info,
291 &uri,
292 &text,
293 &self.document_usage_map,
294 &self.usage_index,
295 )
296 .await;
297 }
298 }
299}
300
301#[tower_lsp::async_trait]
302impl LanguageServer for CssVariableLsp {
303 async fn initialize(
304 &self,
305 params: InitializeParams,
306 ) -> tower_lsp::jsonrpc::Result<InitializeResult> {
307 self.client
308 .log_message(MessageType::INFO, "CSS Variable LSP (Rust) initializing...")
309 .await;
310
311 let has_workspace_folders = params
312 .capabilities
313 .workspace
314 .as_ref()
315 .and_then(|w| w.workspace_folders)
316 .unwrap_or(false);
317 let has_related_info = params
318 .capabilities
319 .text_document
320 .as_ref()
321 .and_then(|t| t.publish_diagnostics.as_ref())
322 .and_then(|p| p.related_information)
323 .unwrap_or(false);
324
325 {
326 let mut cap = self.has_workspace_folder_capability.write().await;
327 *cap = has_workspace_folders;
328 }
329 {
330 let mut rel = self.has_diagnostic_related_information.write().await;
331 *rel = has_related_info;
332 }
333
334 if let Some(root_uri) = params.root_uri.as_ref() {
335 let root_path = to_normalized_fs_path(root_uri);
336 let mut root = self.root_folder_path.write().await;
337 *root = root_path;
338 } else {
339 #[allow(deprecated)]
340 if let Some(root_path) = params.root_path.as_ref() {
341 let mut root = self.root_folder_path.write().await;
342 *root = Some(PathBuf::from(root_path));
343 }
344 }
345
346 self.update_workspace_folder_paths(params.workspace_folders.clone())
347 .await;
348
349 let mut capabilities = ServerCapabilities {
350 text_document_sync: Some(TextDocumentSyncCapability::Kind(
351 TextDocumentSyncKind::INCREMENTAL,
352 )),
353 completion_provider: Some(CompletionOptions {
354 resolve_provider: Some(true),
355 trigger_characters: Some(vec!["-".to_string()]),
356 work_done_progress_options: WorkDoneProgressOptions::default(),
357 all_commit_characters: None,
358 completion_item: None,
359 }),
360 hover_provider: Some(tower_lsp::lsp_types::HoverProviderCapability::Simple(true)),
361 definition_provider: Some(OneOf::Left(true)),
362 references_provider: Some(OneOf::Left(true)),
363 rename_provider: Some(OneOf::Left(true)),
364 document_symbol_provider: Some(OneOf::Left(true)),
365 workspace_symbol_provider: Some(OneOf::Left(true)),
366 color_provider: if self.runtime_config.enable_color_provider {
367 Some(ColorProviderCapability::Simple(true))
368 } else {
369 None
370 },
371 ..Default::default()
372 };
373
374 if has_workspace_folders {
375 capabilities.workspace = Some(WorkspaceServerCapabilities {
376 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
377 supported: Some(true),
378 change_notifications: None,
379 }),
380 file_operations: None,
381 });
382 }
383
384 Ok(InitializeResult {
385 capabilities,
386 server_info: Some(tower_lsp::lsp_types::ServerInfo {
387 name: "css-variable-lsp-rust".to_string(),
388 version: Some("0.1.0".to_string()),
389 }),
390 })
391 }
392
393 async fn initialized(&self, _params: tower_lsp::lsp_types::InitializedParams) {
394 self.client
395 .log_message(MessageType::INFO, "CSS Variable LSP (Rust) initialized!")
396 .await;
397
398 if let Ok(Some(folders)) = self.client.workspace_folders().await {
399 self.update_workspace_folder_paths(Some(folders.clone()))
400 .await;
401 self.scan_workspace_folders(folders).await;
402 }
403 }
404
405 async fn shutdown(&self) -> tower_lsp::jsonrpc::Result<()> {
406 Ok(())
407 }
408
409 async fn did_open(&self, params: DidOpenTextDocumentParams) {
410 let uri = params.text_document.uri;
411 let text = params.text_document.text;
412 let language_id = params.text_document.language_id;
413
414 let old_names = self.manager.get_document_variable_names(&uri).await;
415
416 {
417 let mut docs = self.document_map.write().await;
418 docs.insert(uri.clone(), text.clone());
419 }
420 {
421 let mut langs = self.document_language_map.write().await;
422 langs.insert(uri.clone(), language_id.clone());
423 }
424 self.parse_document_text(&uri, &text, Some(&language_id))
425 .await;
426
427 let new_names = self.manager.get_document_variable_names(&uri).await;
428
429 self.validate_document_text(&uri, &text).await;
430
431 if old_names != new_names {
432 let changed_names: HashSet<_> = old_names
433 .symmetric_difference(&new_names)
434 .cloned()
435 .collect();
436 self.revalidate_affected_documents(&changed_names, Some(&uri))
437 .await;
438 }
439 }
440
441 async fn did_change(&self, params: DidChangeTextDocumentParams) {
442 let uri = params.text_document.uri;
443 let changes = params.content_changes;
444
445 let old_names = self.manager.get_document_variable_names(&uri).await;
446
447 let updated_text = match self.apply_content_changes(&uri, changes).await {
448 Some(text) => text,
449 None => return,
450 };
451 let language_id = {
452 let langs = self.document_language_map.read().await;
453 langs.get(&uri).cloned()
454 };
455 self.parse_document_text(&uri, &updated_text, language_id.as_deref())
456 .await;
457
458 let new_names = self.manager.get_document_variable_names(&uri).await;
459
460 self.validate_document_text(&uri, &updated_text).await;
461
462 if old_names != new_names {
463 let changed_names: HashSet<_> = old_names
464 .symmetric_difference(&new_names)
465 .cloned()
466 .collect();
467 self.revalidate_affected_documents(&changed_names, Some(&uri))
468 .await;
469 }
470 }
471
472 async fn did_close(&self, params: DidCloseTextDocumentParams) {
473 let uri = params.text_document.uri;
474
475 let old_names = self.manager.get_document_variable_names(&uri).await;
476
477 {
478 let mut docs = self.document_map.write().await;
479 docs.remove(&uri);
480 }
481 {
482 let mut langs = self.document_language_map.write().await;
483 langs.remove(&uri);
484 }
485
486 {
488 let mut usage_map = self.document_usage_map.write().await;
489 if let Some(old_usages) = usage_map.remove(&uri) {
490 let mut index = self.usage_index.write().await;
491 for name in old_usages {
492 if let Some(uris) = index.get_mut(&name) {
493 uris.remove(&uri);
494 if uris.is_empty() {
495 index.remove(&name);
496 }
497 }
498 }
499 }
500 }
501
502 self.update_document_from_disk(&uri).await;
503
504 let new_names = self.manager.get_document_variable_names(&uri).await;
505
506 if old_names != new_names {
507 let changed_names: HashSet<_> = old_names
508 .symmetric_difference(&new_names)
509 .cloned()
510 .collect();
511 self.revalidate_affected_documents(&changed_names, None)
512 .await;
513 }
514 }
515
516 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
517 for change in params.changes {
518 match change.typ {
519 FileChangeType::DELETED => {
520 self.manager.remove_document(&change.uri).await;
521 }
522 FileChangeType::CREATED | FileChangeType::CHANGED => {
523 if !self.is_document_open(&change.uri).await {
524 self.update_document_from_disk(&change.uri).await;
525 }
526 }
527 _ => {}
528 }
529 }
530
531 self.validate_all_open_documents().await;
532 }
533
534 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
535 let mut current_paths = {
536 let paths = self.workspace_folder_paths.read().await;
537 paths.clone()
538 };
539
540 for removed in params.event.removed {
541 if let Some(path) = to_normalized_fs_path(&removed.uri) {
542 current_paths.retain(|p| p != &path);
543 }
544 }
545
546 for added in params.event.added {
547 if let Some(path) = to_normalized_fs_path(&added.uri) {
548 current_paths.push(path);
549 }
550 }
551
552 current_paths.sort_by_key(|b| std::cmp::Reverse(b.to_string_lossy().len()));
553
554 let mut stored = self.workspace_folder_paths.write().await;
555 *stored = current_paths;
556 }
557
558 async fn completion(
559 &self,
560 params: CompletionParams,
561 ) -> tower_lsp::jsonrpc::Result<Option<CompletionResponse>> {
562 let uri = params.text_document_position.text_document.uri;
563 let position = params.text_document_position.position;
564
565 let text = {
566 let docs = self.document_map.read().await;
567 docs.get(&uri).cloned()
568 };
569 let text = match text {
570 Some(text) => text,
571 None => return Ok(Some(CompletionResponse::Array(Vec::new()))),
572 };
573
574 if !self.is_in_css_value_context(&text, position) {
575 return Ok(Some(CompletionResponse::Array(Vec::new())));
576 }
577
578 let property_name = self.get_property_name_from_context(&text, position);
579 let variables = self.manager.get_all_variables().await;
580
581 let mut unique_vars = HashMap::new();
582 for var in variables {
583 unique_vars.entry(var.name.clone()).or_insert(var);
584 }
585
586 let mut scored_vars: Vec<(i32, _)> = unique_vars
587 .values()
588 .map(|var| {
589 let score = score_variable_relevance(&var.name, property_name.as_deref());
590 (score, var)
591 })
592 .collect();
593
594 scored_vars.retain(|(score, _)| *score != 0);
595 scored_vars.sort_by(|(score_a, var_a), (score_b, var_b)| {
596 if score_a != score_b {
597 return score_b.cmp(score_a);
598 }
599 var_a.name.cmp(&var_b.name)
600 });
601
602 let workspace_folder_paths = self.workspace_folder_paths.read().await.clone();
603 let root_folder_path = self.root_folder_path.read().await.clone();
604
605 let items = scored_vars
606 .into_iter()
607 .map(|(_, var)| {
608 let options = PathDisplayOptions {
609 mode: self.runtime_config.path_display_mode,
610 abbrev_length: self.runtime_config.path_display_abbrev_length,
611 workspace_folder_paths: &workspace_folder_paths,
612 root_folder_path: root_folder_path.as_ref(),
613 };
614 CompletionItem {
615 label: var.name.clone(),
616 kind: Some(CompletionItemKind::VARIABLE),
617 detail: Some(var.value.clone()),
618 documentation: Some(tower_lsp::lsp_types::Documentation::String(format!(
619 "Defined in {}",
620 format_uri_for_display(&var.uri, options)
621 ))),
622 ..Default::default()
623 }
624 })
625 .collect();
626
627 Ok(Some(CompletionResponse::Array(items)))
628 }
629
630 async fn hover(&self, params: HoverParams) -> tower_lsp::jsonrpc::Result<Option<Hover>> {
631 let uri = params.text_document_position_params.text_document.uri;
632 let position = params.text_document_position_params.position;
633
634 let text = {
635 let docs = self.document_map.read().await;
636 docs.get(&uri).cloned()
637 };
638 let text = match text {
639 Some(text) => text,
640 None => return Ok(None),
641 };
642
643 let word = match self.get_word_at_position(&text, position) {
644 Some(word) => word,
645 None => return Ok(None),
646 };
647
648 let mut definitions = self.manager.get_variables(&word).await;
649 if definitions.is_empty() {
650 return Ok(None);
651 }
652
653 let usages = self.manager.get_usages(&word).await;
654 let offset = match position_to_offset(&text, position) {
655 Some(offset) => offset,
656 None => return Ok(None),
657 };
658 let hover_usage = usages.iter().find(|usage| {
659 if usage.uri != uri {
660 return false;
661 }
662 let start = position_to_offset(&text, usage.range.start).unwrap_or(0);
663 let end = position_to_offset(&text, usage.range.end).unwrap_or(0);
664 offset >= start && offset <= end
665 });
666
667 let usage_context = hover_usage
668 .map(|u| u.usage_context.clone())
669 .unwrap_or_default();
670 let is_inline_style = usage_context == "inline-style";
671 let dom_tree = self.manager.get_dom_tree(&uri).await;
672 let dom_node = hover_usage.and_then(|u| u.dom_node.clone());
673
674 sort_by_cascade(&mut definitions);
675
676 let mut hover_text = format!("### CSS Variable: `{}`\n\n", word);
677
678 if definitions.len() == 1 {
679 let var = &definitions[0];
680 hover_text.push_str(&format!("**Value:** `{}`", var.value));
681 if var.important {
682 hover_text.push_str(" **!important**");
683 }
684 hover_text.push_str("\n\n");
685 if !var.selector.is_empty() {
686 hover_text.push_str(&format!("**Defined in:** `{}`\n", var.selector));
687 hover_text.push_str(&format!(
688 "**Specificity:** {}\n",
689 format_specificity(calculate_specificity(&var.selector))
690 ));
691 }
692 } else {
693 hover_text.push_str("**Definitions** (CSS cascade order):\n\n");
694
695 for (idx, var) in definitions.iter().enumerate() {
696 let spec = calculate_specificity(&var.selector);
697 let is_applicable = if usage_context.is_empty() {
698 true
699 } else {
700 matches_context(
701 &var.selector,
702 &usage_context,
703 dom_tree.as_ref(),
704 dom_node.as_ref(),
705 )
706 };
707 let is_winner = idx == 0 && (is_applicable || is_inline_style);
708
709 let mut line = format!("{}. `{}`", idx + 1, var.value);
710 if var.important {
711 line.push_str(" **!important**");
712 }
713 if !var.selector.is_empty() {
714 line.push_str(&format!(
715 " from `{}` {}",
716 var.selector,
717 format_specificity(spec)
718 ));
719 }
720
721 if is_winner && !usage_context.is_empty() {
722 if var.important {
723 line.push_str(" ✓ **Wins (!important)**");
724 } else if is_inline_style {
725 line.push_str(" ✓ **Would apply (inline style)**");
726 } else if dom_tree.is_some() && dom_node.is_some() {
727 line.push_str(" ✓ **Applies (DOM match)**");
728 } else {
729 line.push_str(" ✓ **Applies here**");
730 }
731 } else if !is_applicable && !usage_context.is_empty() && !is_inline_style {
732 line.push_str(" _(selector doesn't match)_");
733 } else if idx > 0 && !usage_context.is_empty() {
734 let winner = &definitions[0];
735 if winner.important && !var.important {
736 line.push_str(" _(overridden by !important)_");
737 } else {
738 let winner_spec = calculate_specificity(&winner.selector);
739 let cmp = compare_specificity(winner_spec, spec);
740 if cmp > 0 {
741 line.push_str(" _(lower specificity)_");
742 } else if cmp == 0 {
743 line.push_str(" _(earlier in source)_");
744 }
745 }
746 }
747
748 hover_text.push_str(&line);
749 hover_text.push('\n');
750 }
751
752 if !usage_context.is_empty() {
753 if is_inline_style {
754 hover_text.push_str("\n_Context: Inline style (highest priority)_");
755 } else if dom_tree.is_some() && dom_node.is_some() {
756 hover_text.push_str(&format!(
757 "\n_Context: `{}` (DOM-aware matching)_",
758 usage_context
759 ));
760 } else {
761 hover_text.push_str(&format!("\n_Context: `{}`_", usage_context));
762 }
763 }
764 }
765
766 Ok(Some(Hover {
767 contents: HoverContents::Markup(MarkupContent {
768 kind: MarkupKind::Markdown,
769 value: hover_text,
770 }),
771 range: None,
772 }))
773 }
774
775 async fn goto_definition(
776 &self,
777 params: GotoDefinitionParams,
778 ) -> tower_lsp::jsonrpc::Result<Option<GotoDefinitionResponse>> {
779 let uri = params.text_document_position_params.text_document.uri;
780 let position = params.text_document_position_params.position;
781
782 let text = {
783 let docs = self.document_map.read().await;
784 docs.get(&uri).cloned()
785 };
786 let text = match text {
787 Some(text) => text,
788 None => return Ok(None),
789 };
790
791 let word = match self.get_word_at_position(&text, position) {
792 Some(word) => word,
793 None => return Ok(None),
794 };
795
796 let definitions = self.manager.get_variables(&word).await;
797 let first = match definitions.first() {
798 Some(def) => def,
799 None => return Ok(None),
800 };
801
802 Ok(Some(GotoDefinitionResponse::Scalar(Location::new(
803 first.uri.clone(),
804 first.range,
805 ))))
806 }
807
808 async fn references(
809 &self,
810 params: ReferenceParams,
811 ) -> tower_lsp::jsonrpc::Result<Option<Vec<Location>>> {
812 let uri = params.text_document_position.text_document.uri;
813 let position = params.text_document_position.position;
814
815 let text = {
816 let docs = self.document_map.read().await;
817 docs.get(&uri).cloned()
818 };
819 let text = match text {
820 Some(text) => text,
821 None => return Ok(None),
822 };
823
824 let word = match self.get_word_at_position(&text, position) {
825 Some(word) => word,
826 None => return Ok(None),
827 };
828
829 let (definitions, usages) = self.manager.get_references(&word).await;
830 let mut locations = Vec::new();
831 for def in definitions {
832 locations.push(Location::new(def.uri, def.range));
833 }
834 for usage in usages {
835 locations.push(Location::new(usage.uri, usage.range));
836 }
837
838 Ok(Some(locations))
839 }
840
841 async fn document_color(
842 &self,
843 params: DocumentColorParams,
844 ) -> tower_lsp::jsonrpc::Result<Vec<ColorInformation>> {
845 let config = self.manager.get_config().await;
846 if !config.enable_color_provider {
847 return Ok(Vec::new());
848 }
849
850 let uri = params.text_document.uri;
851 let text = {
852 let docs = self.document_map.read().await;
853 docs.get(&uri).cloned()
854 };
855 let text = match text {
856 Some(text) => text,
857 None => return Ok(Vec::new()),
858 };
859
860 let mut colors = Vec::new();
861 let mut seen_ranges: HashSet<(u32, u32, u32, u32)> = HashSet::new();
862 let range_key = |range: &Range| {
863 (
864 range.start.line,
865 range.start.character,
866 range.end.line,
867 range.end.character,
868 )
869 };
870
871 if !config.color_only_on_variables {
872 let definitions = self.manager.get_document_variables(&uri).await;
873 for def in definitions {
874 if let Some(color) = parse_color(&def.value) {
875 if let Some(value_range) = def.value_range {
876 if seen_ranges.insert(range_key(&value_range)) {
877 colors.push(ColorInformation {
878 range: value_range,
879 color,
880 });
881 }
882 } else if let Some(range) = find_value_range_in_definition(&text, &def) {
883 if seen_ranges.insert(range_key(&range)) {
884 colors.push(ColorInformation { range, color });
885 }
886 }
887 }
888 }
889 }
890
891 let usages = self.manager.get_document_usages(&uri).await;
892 for usage in usages {
893 if let Some(color) = self.manager.resolve_variable_color(&usage.name).await {
894 if seen_ranges.insert(range_key(&usage.range)) {
895 colors.push(ColorInformation {
896 range: usage.range,
897 color,
898 });
899 }
900 }
901 }
902
903 for caps in self.var_usage_regex.captures_iter(&text) {
904 let match_all = caps.get(0).unwrap();
905 let var_name = caps.get(1).unwrap().as_str();
906 let range = Range::new(
907 crate::types::offset_to_position(&text, match_all.start()),
908 crate::types::offset_to_position(&text, match_all.end()),
909 );
910 if !seen_ranges.insert(range_key(&range)) {
911 continue;
912 }
913 if let Some(color) = self.manager.resolve_variable_color(var_name).await {
914 colors.push(ColorInformation { range, color });
915 }
916 }
917
918 Ok(colors)
919 }
920
921 async fn color_presentation(
922 &self,
923 params: ColorPresentationParams,
924 ) -> tower_lsp::jsonrpc::Result<Vec<ColorPresentation>> {
925 if !self.runtime_config.enable_color_provider {
926 return Ok(Vec::new());
927 }
928 Ok(generate_color_presentations(params.color, params.range))
929 }
930
931 async fn rename(
932 &self,
933 params: RenameParams,
934 ) -> tower_lsp::jsonrpc::Result<Option<WorkspaceEdit>> {
935 let uri = params.text_document_position.text_document.uri;
936 let position = params.text_document_position.position;
937 let new_name = params.new_name;
938
939 let text = {
940 let docs = self.document_map.read().await;
941 docs.get(&uri).cloned()
942 };
943 let text = match text {
944 Some(text) => text,
945 None => return Ok(None),
946 };
947
948 let old_name = match self.get_word_at_position(&text, position) {
949 Some(word) => word,
950 None => return Ok(None),
951 };
952
953 let (definitions, usages) = self.manager.get_references(&old_name).await;
954 let mut changes: HashMap<Url, Vec<TextEdit>> = HashMap::new();
955
956 for def in definitions {
957 let range = def.name_range.unwrap_or(def.range);
958 changes.entry(def.uri.clone()).or_default().push(TextEdit {
959 range,
960 new_text: new_name.clone(),
961 });
962 }
963
964 for usage in usages {
965 let range = usage.name_range.unwrap_or(usage.range);
966 changes
967 .entry(usage.uri.clone())
968 .or_default()
969 .push(TextEdit {
970 range,
971 new_text: new_name.clone(),
972 });
973 }
974
975 Ok(Some(WorkspaceEdit {
976 changes: Some(changes),
977 document_changes: None,
978 change_annotations: None,
979 }))
980 }
981
982 #[allow(deprecated)]
983 async fn document_symbol(
984 &self,
985 params: DocumentSymbolParams,
986 ) -> tower_lsp::jsonrpc::Result<Option<DocumentSymbolResponse>> {
987 let vars = self
988 .manager
989 .get_document_variables(¶ms.text_document.uri)
990 .await;
991 let symbols: Vec<DocumentSymbol> = vars
992 .into_iter()
993 .map(|var| DocumentSymbol {
994 name: var.name,
995 detail: Some(var.value),
996 kind: SymbolKind::VARIABLE,
997 tags: None,
998 deprecated: None,
999 range: var.range,
1000 selection_range: var.range,
1001 children: None,
1002 })
1003 .collect();
1004
1005 Ok(Some(DocumentSymbolResponse::Nested(symbols)))
1006 }
1007
1008 #[allow(deprecated)]
1009 async fn symbol(
1010 &self,
1011 params: WorkspaceSymbolParams,
1012 ) -> tower_lsp::jsonrpc::Result<Option<Vec<SymbolInformation>>> {
1013 let query = params.query.to_lowercase();
1014 let vars = self.manager.get_all_variables().await;
1015 let mut symbols = Vec::new();
1016
1017 for var in vars {
1018 if !query.is_empty() && !var.name.to_lowercase().contains(&query) {
1019 continue;
1020 }
1021 symbols.push(SymbolInformation {
1022 name: var.name,
1023 kind: SymbolKind::VARIABLE,
1024 tags: None,
1025 deprecated: None,
1026 location: Location::new(var.uri, var.range),
1027 container_name: None,
1028 });
1029 }
1030
1031 Ok(Some(symbols))
1032 }
1033}
1034
1035impl CssVariableLsp {
1036 pub async fn scan_workspace_folders(&self, folders: Vec<WorkspaceFolder>) {
1038 let folder_uris: Vec<Url> = folders.iter().map(|f| f.uri.clone()).collect();
1039
1040 self.client
1041 .log_message(
1042 MessageType::INFO,
1043 format!("Scanning {} workspace folders...", folder_uris.len()),
1044 )
1045 .await;
1046
1047 let manager = self.manager.clone();
1048 let client = self.client.clone();
1049
1050 let mut last_logged_percentage = 0;
1051 let result = crate::workspace::scan_workspace(folder_uris, &manager, |current, total| {
1052 if total == 0 {
1053 return;
1054 }
1055 let percentage = ((current as f64 / total as f64) * 100.0).round() as i32;
1056 if percentage - last_logged_percentage >= 20 || current == total {
1057 last_logged_percentage = percentage;
1058 let client = client.clone();
1059 tokio::spawn(async move {
1060 client
1061 .log_message(
1062 MessageType::INFO,
1063 format!(
1064 "Scanning CSS files: {}/{} ({}%)",
1065 current, total, percentage
1066 ),
1067 )
1068 .await;
1069 });
1070 }
1071 })
1072 .await;
1073
1074 match result {
1075 Ok(_) => {
1076 let total_vars = manager.get_all_variables().await.len();
1077 self.client
1078 .log_message(
1079 MessageType::INFO,
1080 format!(
1081 "Workspace scan complete. Found {} CSS variables.",
1082 total_vars
1083 ),
1084 )
1085 .await;
1086 }
1087 Err(e) => {
1088 self.client
1089 .log_message(MessageType::ERROR, format!("Workspace scan failed: {}", e))
1090 .await;
1091 }
1092 }
1093
1094 self.validate_all_open_documents().await;
1095 }
1096}
1097
1098#[allow(clippy::too_many_arguments)]
1099async fn validate_document_text_with(
1100 client: &Client,
1101 manager: &CssVariableManager,
1102 usage_regex: &Regex,
1103 has_related_info: bool,
1104 uri: &Url,
1105 text: &str,
1106 document_usage_map: &Arc<RwLock<HashMap<Url, HashSet<String>>>>,
1107 usage_index: &Arc<RwLock<HashMap<String, HashSet<Url>>>>,
1108) {
1109 let mut diagnostics = Vec::new();
1110 let mut current_usages = HashSet::new();
1111
1112 for captures in usage_regex.captures_iter(text) {
1113 let match_all = captures.get(0).unwrap();
1114 let name = captures.get(1).unwrap().as_str();
1115
1116 current_usages.insert(name.to_string());
1117
1118 let definitions = manager.get_variables(name).await;
1119 if !definitions.is_empty() {
1120 continue;
1121 }
1122 let range = Range::new(
1123 crate::types::offset_to_position(text, match_all.start()),
1124 crate::types::offset_to_position(text, match_all.end()),
1125 );
1126 diagnostics.push(Diagnostic {
1127 range,
1128 severity: Some(DiagnosticSeverity::WARNING),
1129 code: None,
1130 code_description: None,
1131 source: Some("css-variable-lsp".to_string()),
1132 message: format!("CSS variable '{}' is not defined in the workspace", name),
1133 related_information: if has_related_info {
1134 Some(Vec::new())
1135 } else {
1136 None
1137 },
1138 tags: None,
1139 data: None,
1140 });
1141 }
1142
1143 {
1145 let mut usage_map = document_usage_map.write().await;
1146 let old_usages = usage_map.insert(uri.clone(), current_usages.clone());
1147
1148 let mut index = usage_index.write().await;
1149
1150 if let Some(old_set) = old_usages {
1152 for name in old_set {
1153 if !current_usages.contains(&name) {
1154 if let Some(uris) = index.get_mut(&name) {
1155 uris.remove(uri);
1156 if uris.is_empty() {
1157 index.remove(&name);
1158 }
1159 }
1160 }
1161 }
1162 }
1163
1164 for name in current_usages {
1166 index
1167 .entry(name)
1168 .or_insert_with(HashSet::new)
1169 .insert(uri.clone());
1170 }
1171 }
1172
1173 client
1174 .publish_diagnostics(uri.clone(), diagnostics, None)
1175 .await;
1176}
1177
1178fn is_html_like_extension(ext: &str) -> bool {
1179 matches!(ext, ".html" | ".vue" | ".svelte" | ".astro" | ".ripple")
1180}
1181
1182fn language_id_kind(language_id: &str) -> Option<DocumentKind> {
1183 match language_id.to_lowercase().as_str() {
1184 "html" | "vue" | "svelte" | "astro" | "ripple" => Some(DocumentKind::Html),
1185 "css" | "scss" | "sass" | "less" => Some(DocumentKind::Css),
1186 _ => None,
1187 }
1188}
1189
1190fn normalize_extension(ext: &str) -> Option<String> {
1191 let trimmed = ext.trim().trim_start_matches('.');
1192 if trimmed.is_empty() {
1193 return None;
1194 }
1195 Some(format!(".{}", trimmed.to_lowercase()))
1196}
1197
1198fn extract_extensions(pattern: &str) -> Vec<String> {
1199 let pattern = pattern.trim();
1200 if let (Some(start), Some(end)) = (pattern.find('{'), pattern.find('}')) {
1201 if end > start + 1 {
1202 let inner = &pattern[start + 1..end];
1203 return inner.split(',').filter_map(normalize_extension).collect();
1204 }
1205 }
1206
1207 let ext = std::path::Path::new(pattern)
1208 .extension()
1209 .and_then(|ext| ext.to_str());
1210 ext.and_then(normalize_extension).into_iter().collect()
1211}
1212
1213fn build_lookup_extension_map(lookup_files: &[String]) -> HashMap<String, DocumentKind> {
1214 let mut map = HashMap::new();
1215 for pattern in lookup_files {
1216 for ext in extract_extensions(pattern) {
1217 let kind = if is_html_like_extension(&ext) {
1218 DocumentKind::Html
1219 } else {
1220 DocumentKind::Css
1221 };
1222 map.insert(ext, kind);
1223 }
1224 }
1225 map
1226}
1227
1228fn resolve_document_kind(
1229 path: &str,
1230 language_id: Option<&str>,
1231 lookup_extension_map: &HashMap<String, DocumentKind>,
1232) -> Option<DocumentKind> {
1233 if let Some(language_id) = language_id {
1234 if let Some(kind) = language_id_kind(language_id) {
1235 return Some(kind);
1236 }
1237 }
1238
1239 let ext = std::path::Path::new(path)
1240 .extension()
1241 .and_then(|ext| ext.to_str())
1242 .and_then(normalize_extension)?;
1243
1244 lookup_extension_map.get(&ext).copied()
1245}
1246
1247fn clamp_to_char_boundary(text: &str, mut idx: usize) -> usize {
1248 if idx > text.len() {
1249 idx = text.len();
1250 }
1251 while idx > 0 && !text.is_char_boundary(idx) {
1252 idx -= 1;
1253 }
1254 idx
1255}
1256
1257fn is_word_char(c: char) -> bool {
1258 c.is_ascii_alphanumeric() || c == '-' || c == '_'
1259}
1260
1261fn find_context_colon(before_cursor: &str) -> Option<usize> {
1262 let mut in_braces = 0i32;
1263 let mut in_parens = 0i32;
1264 let mut last_colon: i32 = -1;
1265 let mut last_semicolon: i32 = -1;
1266 let mut last_brace: i32 = -1;
1267
1268 for (idx, ch) in before_cursor.char_indices().rev() {
1269 match ch {
1270 ')' => in_parens += 1,
1271 '(' => {
1272 in_parens -= 1;
1273 if in_parens < 0 {
1274 break;
1275 }
1276 }
1277 '}' => in_braces += 1,
1278 '{' => {
1279 in_braces -= 1;
1280 if in_braces < 0 {
1281 last_brace = idx as i32;
1282 break;
1283 }
1284 }
1285 ':' if in_parens == 0 && in_braces == 0 && last_colon == -1 => {
1286 last_colon = idx as i32;
1287 }
1288 ';' if in_parens == 0 && in_braces == 0 && last_semicolon == -1 => {
1289 last_semicolon = idx as i32;
1290 }
1291 _ => {}
1292 }
1293 }
1294
1295 if last_colon > last_semicolon && last_colon > last_brace {
1296 Some(last_colon as usize)
1297 } else {
1298 None
1299 }
1300}
1301
1302fn get_property_name_from_context(before_cursor: &str) -> Option<String> {
1303 let colon_pos = find_context_colon(before_cursor)?;
1304 let before_colon = before_cursor[..colon_pos].trim_end();
1305 if before_colon.is_empty() {
1306 return None;
1307 }
1308
1309 let mut start = before_colon.len();
1310 for (idx, ch) in before_colon.char_indices().rev() {
1311 if is_word_char(ch) {
1312 start = idx;
1313 } else {
1314 break;
1315 }
1316 }
1317
1318 if start >= before_colon.len() {
1319 return None;
1320 }
1321
1322 Some(before_colon[start..].to_lowercase())
1323}
1324
1325fn score_variable_relevance(var_name: &str, property_name: Option<&str>) -> i32 {
1326 let property_name = match property_name {
1327 Some(name) => name,
1328 None => return -1,
1329 };
1330
1331 let lower_var_name = var_name.to_lowercase();
1332
1333 let color_properties = [
1334 "color",
1335 "background-color",
1336 "background",
1337 "border-color",
1338 "outline-color",
1339 "text-decoration-color",
1340 "fill",
1341 "stroke",
1342 ];
1343 if color_properties.contains(&property_name) {
1344 if lower_var_name.contains("color")
1345 || lower_var_name.contains("bg")
1346 || lower_var_name.contains("background")
1347 || lower_var_name.contains("primary")
1348 || lower_var_name.contains("secondary")
1349 || lower_var_name.contains("accent")
1350 || lower_var_name.contains("text")
1351 || lower_var_name.contains("border")
1352 || lower_var_name.contains("link")
1353 {
1354 return 10;
1355 }
1356 if lower_var_name.contains("spacing")
1357 || lower_var_name.contains("margin")
1358 || lower_var_name.contains("padding")
1359 || lower_var_name.contains("size")
1360 || lower_var_name.contains("width")
1361 || lower_var_name.contains("height")
1362 || lower_var_name.contains("font")
1363 || lower_var_name.contains("weight")
1364 || lower_var_name.contains("radius")
1365 {
1366 return 0;
1367 }
1368 return 5;
1369 }
1370
1371 let spacing_properties = [
1372 "margin",
1373 "margin-top",
1374 "margin-right",
1375 "margin-bottom",
1376 "margin-left",
1377 "padding",
1378 "padding-top",
1379 "padding-right",
1380 "padding-bottom",
1381 "padding-left",
1382 "gap",
1383 "row-gap",
1384 "column-gap",
1385 ];
1386 if spacing_properties.contains(&property_name) {
1387 if lower_var_name.contains("spacing")
1388 || lower_var_name.contains("margin")
1389 || lower_var_name.contains("padding")
1390 || lower_var_name.contains("gap")
1391 {
1392 return 10;
1393 }
1394 if lower_var_name.contains("color")
1395 || lower_var_name.contains("bg")
1396 || lower_var_name.contains("background")
1397 {
1398 return 0;
1399 }
1400 return 5;
1401 }
1402
1403 let size_properties = [
1404 "width",
1405 "height",
1406 "max-width",
1407 "max-height",
1408 "min-width",
1409 "min-height",
1410 "font-size",
1411 ];
1412 if size_properties.contains(&property_name) {
1413 if lower_var_name.contains("width")
1414 || lower_var_name.contains("height")
1415 || lower_var_name.contains("size")
1416 {
1417 return 10;
1418 }
1419 if lower_var_name.contains("color")
1420 || lower_var_name.contains("bg")
1421 || lower_var_name.contains("background")
1422 {
1423 return 0;
1424 }
1425 return 5;
1426 }
1427
1428 if property_name.contains("radius") {
1429 if lower_var_name.contains("radius") || lower_var_name.contains("rounded") {
1430 return 10;
1431 }
1432 if lower_var_name.contains("color")
1433 || lower_var_name.contains("bg")
1434 || lower_var_name.contains("background")
1435 {
1436 return 0;
1437 }
1438 return 5;
1439 }
1440
1441 let font_properties = ["font-family", "font-weight", "font-style"];
1442 if font_properties.contains(&property_name) {
1443 if lower_var_name.contains("font") {
1444 return 10;
1445 }
1446 if lower_var_name.contains("color") || lower_var_name.contains("spacing") {
1447 return 0;
1448 }
1449 return 5;
1450 }
1451
1452 -1
1453}
1454
1455fn apply_change_to_text(text: &mut String, change: &TextDocumentContentChangeEvent) {
1456 if let Some(range) = change.range {
1457 let start = position_to_offset(text, range.start);
1458 let end = position_to_offset(text, range.end);
1459 if let (Some(start), Some(end)) = (start, end) {
1460 if start <= end && end <= text.len() {
1461 text.replace_range(start..end, &change.text);
1462 return;
1463 }
1464 }
1465 }
1466 *text = change.text.clone();
1467}
1468
1469fn find_value_range_in_definition(text: &str, def: &crate::types::CssVariable) -> Option<Range> {
1470 let start = position_to_offset(text, def.range.start)?;
1471 let end = position_to_offset(text, def.range.end)?;
1472 if start >= end || end > text.len() {
1473 return None;
1474 }
1475 let def_text = &text[start..end];
1476 let colon_index = def_text.find(':')?;
1477 let after_colon = &def_text[colon_index + 1..];
1478 let value_trim = def.value.trim();
1479 let value_index = after_colon.find(value_trim)?;
1480
1481 let absolute_start = start + colon_index + 1 + value_index;
1482 let absolute_end = absolute_start + value_trim.len();
1483
1484 Some(Range::new(
1485 crate::types::offset_to_position(text, absolute_start),
1486 crate::types::offset_to_position(text, absolute_end),
1487 ))
1488}
1489
1490#[cfg(test)]
1491mod tests {
1492 use super::*;
1493
1494 fn test_word_extraction(css: &str, cursor_pos: usize) -> Option<String> {
1495 use tower_lsp::lsp_types::Position;
1496 let position = Position {
1497 line: 0,
1498 character: cursor_pos as u32,
1499 };
1500
1501 let offset = position_to_offset(css, position)?;
1502 let offset = clamp_to_char_boundary(css, offset);
1503 let before = &css[..offset];
1504 let after = &css[offset..];
1505
1506 let left = before
1507 .rsplit(|c: char| !is_word_char(c))
1508 .next()
1509 .unwrap_or("");
1510 let right = after.split(|c: char| !is_word_char(c)).next().unwrap_or("");
1511 let word = format!("{}{}", left, right);
1512 if word.starts_with("--") {
1513 Some(word)
1514 } else {
1515 None
1516 }
1517 }
1518
1519 #[test]
1520 fn test_word_extraction_preserves_fallbacks() {
1521 let css = "background: var(--primary-color, blue);";
1523 let result = test_word_extraction(css, 20); assert_eq!(result, Some("--primary-color".to_string()));
1525
1526 let css2 = "color: var(--secondary-color, #ccc);";
1528 let result2 = test_word_extraction(css2, 15); assert_eq!(result2, Some("--secondary-color".to_string()));
1530
1531 let css3 = "border: var(--accent-color, var(--fallback, black));";
1533 let result3 = test_word_extraction(css3, 16); assert_eq!(result3, Some("--accent-color".to_string()));
1535
1536 let css4 = "--theme-color: red;";
1538 let result4 = test_word_extraction(css4, 5); assert_eq!(result4, Some("--theme-color".to_string()));
1540
1541 let css5 = "margin: var(--spacing);";
1543 let result5 = test_word_extraction(css5, 15); assert_eq!(result5, Some("--spacing".to_string()));
1545 }
1546
1547 #[test]
1548 fn resolve_document_kind_prefers_language_id() {
1549 let lookup_files = vec!["**/*.custom".to_string()];
1550 let lookup_map = build_lookup_extension_map(&lookup_files);
1551
1552 let kind = resolve_document_kind("file.custom", Some("html"), &lookup_map);
1553 assert_eq!(kind, Some(DocumentKind::Html));
1554 }
1555
1556 #[test]
1557 fn resolve_document_kind_uses_lookup_extensions() {
1558 let lookup_files = vec![
1559 "**/*.{css,scss}".to_string(),
1560 "**/*.vue".to_string(),
1561 "**/*.custom".to_string(),
1562 ];
1563 let lookup_map = build_lookup_extension_map(&lookup_files);
1564
1565 let css_kind = resolve_document_kind("styles.scss", None, &lookup_map);
1566 assert_eq!(css_kind, Some(DocumentKind::Css));
1567
1568 let html_kind = resolve_document_kind("component.vue", None, &lookup_map);
1569 assert_eq!(html_kind, Some(DocumentKind::Html));
1570
1571 let custom_kind = resolve_document_kind("theme.custom", None, &lookup_map);
1572 assert_eq!(custom_kind, Some(DocumentKind::Css));
1573 }
1574
1575 #[test]
1576 fn resolve_document_kind_returns_none_for_unknown() {
1577 let lookup_files = vec!["**/*.css".to_string()];
1578 let lookup_map = build_lookup_extension_map(&lookup_files);
1579
1580 let kind = resolve_document_kind("notes.txt", Some("plaintext"), &lookup_map);
1581 assert_eq!(kind, None);
1582 }
1583}