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