css_variable_lsp/
lsp_server.rs

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