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