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