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