css_variable_lsp/
lsp_server.rs

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