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