Skip to main content

css_variable_lsp/
lsp_server.rs

1use ls_types::Uri;
2use std::collections::{HashMap, HashSet};
3use std::path::PathBuf;
4use std::str::FromStr;
5use std::sync::Arc;
6
7use ls_types::{
8    CodeAction, CodeActionContext, CodeActionKind, CodeActionOrCommand, CodeActionParams,
9    CodeActionProviderCapability, CodeActionResponse, ColorInformation, ColorPresentation,
10    ColorPresentationParams, ColorProviderCapability, CompletionItem, CompletionItemKind,
11    CompletionOptions, CompletionParams, CompletionResponse, CreateFilesParams, DeleteFilesParams,
12    Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams, DidChangeTextDocumentParams,
13    DidChangeWatchedFilesParams, DidChangeWorkspaceFoldersParams, DidCloseTextDocumentParams,
14    DidOpenTextDocumentParams, DidSaveTextDocumentParams, DocumentColorParams, DocumentSymbol,
15    DocumentSymbolParams, DocumentSymbolResponse, FileChangeType, GotoDefinitionParams,
16    GotoDefinitionResponse, Hover, HoverContents, HoverParams, InitializeParams, InitializeResult,
17    Location, MarkupContent, MarkupKind, MessageType, OneOf, Position, PrepareRenameResponse,
18    Range, ReferenceParams, RenameFilesParams, RenameOptions, RenameParams, ServerCapabilities,
19    SymbolInformation, SymbolKind, TextDocumentContentChangeEvent, TextDocumentPositionParams,
20    TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit, WillSaveTextDocumentParams,
21    WorkDoneProgressOptions, WorkspaceEdit, WorkspaceFolder, WorkspaceFoldersServerCapabilities,
22    WorkspaceServerCapabilities, WorkspaceSymbolParams, WorkspaceSymbolResponse,
23};
24use regex::Regex;
25use tokio::sync::RwLock;
26use tower_lsp_server::{Client, LanguageServer};
27
28use crate::color::{generate_color_presentations, parse_color, NormalizedColorKey};
29use crate::completion_context::{
30    completion_value_context_slice, get_value_context_info, score_variable_relevance,
31};
32#[cfg(test)]
33use crate::completion_context::{
34    find_html_style_attribute_slice, find_html_style_block_slice, find_js_string_segment,
35};
36use crate::document_kind::{
37    apply_config_patch, build_lookup_extension_map, resolve_document_kind, ClientConfigPatch,
38    DocumentKind,
39};
40use crate::manager::CssVariableManager;
41use crate::parsers::{parse_css_document, parse_html_document};
42use crate::path_display::{format_uri_for_display, to_normalized_fs_path, PathDisplayOptions};
43use crate::runtime_config::{RuntimeConfig, UndefinedVarFallbackMode};
44use crate::specificity::{
45    calculate_specificity, compare_specificity, format_specificity, matches_context,
46    sort_by_cascade,
47};
48use crate::text_utils::{
49    apply_change_to_text, clamp_to_char_boundary, find_value_range_in_definition, is_word_byte,
50    is_word_char, range_contains, range_contains_position,
51};
52use crate::types::{position_to_offset, Config};
53
54fn code_actions_for_undefined_variables(
55    uri: &Uri,
56    text: &str,
57    context: &CodeActionContext,
58    runtime_config: &RuntimeConfig,
59) -> Vec<CodeActionOrCommand> {
60    let mut actions = Vec::new();
61
62    for diag in &context.diagnostics {
63        let code = match diag.code.as_ref() {
64            Some(ls_types::NumberOrString::String(code)) => code.as_str(),
65            _ => continue,
66        };
67        if code != "css-variable-lsp.undefined-variable" {
68            continue;
69        }
70
71        let name = diag
72            .data
73            .as_ref()
74            .and_then(|d| d.get("name"))
75            .and_then(|v| v.as_str())
76            .map(|s| s.to_string());
77        let name = match name {
78            Some(name) => name,
79            None => continue,
80        };
81
82        // Very conservative quickfix: insert a :root block at the start of the current file.
83        // This avoids trying to parse/modify existing CSS.
84        let insert_text = format!(":root {{\n    {}: ;\n}}\n\n", name);
85
86        let edit = WorkspaceEdit {
87            changes: Some(HashMap::from([(
88                uri.clone(),
89                vec![TextEdit {
90                    range: Range::new(Position::new(0, 0), Position::new(0, 0)),
91                    new_text: insert_text,
92                }],
93            )])),
94            document_changes: None,
95            change_annotations: None,
96        };
97
98        let action = CodeAction {
99            title: format!("Create {} in :root", name),
100            kind: Some(CodeActionKind::QUICKFIX),
101            diagnostics: Some(vec![diag.clone()]),
102            edit: Some(edit),
103            command: None,
104            is_preferred: Some(true),
105            disabled: None,
106            data: None,
107        };
108
109        actions.push(CodeActionOrCommand::CodeAction(action));
110
111        // Optional quickfix: add fallback to `var(--name)` -> `var(--name, )`
112        // Only offered when the diagnostic covers a `var(...)` call without a comma.
113        // Can be disabled via --no-suggest-add-fallback or CSS_LSP_SUGGEST_ADD_FALLBACK=0
114        if runtime_config.suggest_add_fallback {
115            if let (Some(start), Some(end)) = (
116                crate::types::position_to_offset(text, diag.range.start),
117                crate::types::position_to_offset(text, diag.range.end),
118            ) {
119                if start < end && end <= text.len() {
120                    let slice = &text[start..end];
121                    if slice.starts_with("var(") && slice.ends_with(')') && !slice.contains(',') {
122                        let new_text = slice.trim_end_matches(')').to_string() + ", )";
123                        let edit = WorkspaceEdit {
124                            changes: Some(HashMap::from([(
125                                uri.clone(),
126                                vec![TextEdit {
127                                    range: diag.range,
128                                    new_text,
129                                }],
130                            )])),
131                            document_changes: None,
132                            change_annotations: None,
133                        };
134
135                        let action = CodeAction {
136                            title: format!("Add fallback to {}", name),
137                            kind: Some(CodeActionKind::QUICKFIX),
138                            diagnostics: Some(vec![diag.clone()]),
139                            edit: Some(edit),
140                            command: None,
141                            is_preferred: Some(false),
142                            disabled: None,
143                            data: None,
144                        };
145                        actions.push(CodeActionOrCommand::CodeAction(action));
146                    }
147                }
148            }
149        }
150    }
151
152    actions
153}
154
155fn code_actions_for_replaceable_literal_colors(
156    uri: &Uri,
157    context: &CodeActionContext,
158    runtime_config: &RuntimeConfig,
159) -> Vec<CodeActionOrCommand> {
160    if !runtime_config.suggest_exact_color_variables {
161        return Vec::new();
162    }
163
164    let mut actions = Vec::new();
165
166    for diag in &context.diagnostics {
167        let code = match diag.code.as_ref() {
168            Some(ls_types::NumberOrString::String(code)) => code.as_str(),
169            _ => continue,
170        };
171        if code != "css-variable-lsp.literal-color-replaceable" {
172            continue;
173        }
174
175        let replacements = diag
176            .data
177            .as_ref()
178            .and_then(|d| d.get("replacements"))
179            .and_then(|v| v.as_array())
180            .cloned()
181            .unwrap_or_default();
182
183        for replacement in replacements {
184            let name = match replacement.get("name").and_then(|v| v.as_str()) {
185                Some(name) => name,
186                None => continue,
187            };
188
189            let edit = WorkspaceEdit {
190                changes: Some(HashMap::from([(
191                    uri.clone(),
192                    vec![TextEdit {
193                        range: diag.range,
194                        new_text: format!("var({})", name),
195                    }],
196                )])),
197                document_changes: None,
198                change_annotations: None,
199            };
200
201            actions.push(CodeActionOrCommand::CodeAction(CodeAction {
202                title: format!("Replace with var({})", name),
203                kind: Some(CodeActionKind::QUICKFIX),
204                diagnostics: Some(vec![diag.clone()]),
205                edit: Some(edit),
206                command: None,
207                is_preferred: Some(false),
208                disabled: None,
209                data: None,
210            }));
211        }
212    }
213
214    actions
215}
216
217pub struct CssVariableLsp {
218    client: Client,
219    manager: Arc<CssVariableManager>,
220    document_map: Arc<RwLock<HashMap<Uri, String>>>,
221    runtime_config: RuntimeConfig,
222    workspace_folder_paths: Arc<RwLock<Vec<PathBuf>>>,
223    root_folder_path: Arc<RwLock<Option<PathBuf>>>,
224    has_workspace_folder_capability: Arc<RwLock<bool>>,
225    has_diagnostic_related_information: Arc<RwLock<bool>>,
226    usage_regex: Regex,
227    var_usage_regex: Regex,
228    lookup_extension_map: Arc<RwLock<HashMap<String, DocumentKind>>>,
229    live_config: Arc<RwLock<Config>>,
230    document_language_map: Arc<RwLock<HashMap<Uri, String>>>,
231    document_usage_map: Arc<RwLock<HashMap<Uri, HashSet<String>>>>,
232    usage_index: Arc<RwLock<HashMap<String, HashSet<Uri>>>>,
233    /// URIs of documents that contain literal color occurrences
234    color_occurrence_uris: Arc<RwLock<HashSet<Uri>>>,
235}
236
237impl CssVariableLsp {
238    pub fn new(client: Client, runtime_config: RuntimeConfig) -> Self {
239        let config = Config::from_runtime(&runtime_config);
240        let lookup_extension_map = build_lookup_extension_map(&config.lookup_files);
241        let live_config = config.clone();
242        Self {
243            client,
244            manager: Arc::new(CssVariableManager::new(config)),
245            document_map: Arc::new(RwLock::new(HashMap::new())),
246            runtime_config,
247            workspace_folder_paths: Arc::new(RwLock::new(Vec::new())),
248            root_folder_path: Arc::new(RwLock::new(None)),
249            has_workspace_folder_capability: Arc::new(RwLock::new(false)),
250            has_diagnostic_related_information: Arc::new(RwLock::new(false)),
251            usage_regex: Regex::new(r"var\((--[\w-]+)(?:\s*,\s*([^)]+))?\)").unwrap(),
252            var_usage_regex: Regex::new(r"var\((--[\w-]+)\)").unwrap(),
253            lookup_extension_map: Arc::new(RwLock::new(lookup_extension_map)),
254            live_config: Arc::new(RwLock::new(live_config)),
255            document_language_map: Arc::new(RwLock::new(HashMap::new())),
256            document_usage_map: Arc::new(RwLock::new(HashMap::new())),
257            usage_index: Arc::new(RwLock::new(HashMap::new())),
258            color_occurrence_uris: Arc::new(RwLock::new(HashSet::new())),
259        }
260    }
261
262    async fn update_workspace_folder_paths(&self, folders: Option<Vec<WorkspaceFolder>>) {
263        let mut paths = Vec::new();
264        if let Some(folders) = folders {
265            for folder in folders {
266                if let Some(path) = to_normalized_fs_path(&folder.uri) {
267                    paths.push(path);
268                }
269            }
270        }
271        paths.sort_by_key(|b| std::cmp::Reverse(b.to_string_lossy().len()));
272        let mut stored = self.workspace_folder_paths.write().await;
273        *stored = paths;
274    }
275
276    async fn parse_document_text(&self, uri: &Uri, text: &str, language_id: Option<&str>) {
277        self.manager.remove_document(uri).await;
278
279        let path = uri.path().as_str();
280        let lookup_map = self.lookup_extension_map.read().await.clone();
281        let kind = resolve_document_kind(path, language_id, &lookup_map);
282        let result = match kind {
283            Some(DocumentKind::Html) => parse_html_document(text, uri, &self.manager).await,
284            Some(DocumentKind::Css) => parse_css_document(text, uri, &self.manager).await,
285            None => return,
286        };
287
288        if let Err(e) = result {
289            self.client
290                .log_message(MessageType::ERROR, format!("Parse error: {}", e))
291                .await;
292        }
293
294        self.manager.rebuild_color_index().await;
295    }
296
297    async fn validate_document_text(&self, uri: &Uri, text: &str) {
298        let has_related_info = *self.has_diagnostic_related_information.read().await;
299        validate_document_text_with(
300            &self.client,
301            self.manager.as_ref(),
302            &self.usage_regex,
303            self.runtime_config.undefined_var_fallback,
304            has_related_info,
305            uri,
306            text,
307            &self.document_usage_map,
308            &self.usage_index,
309            Some(&self.color_occurrence_uris),
310        )
311        .await;
312    }
313
314    async fn validate_all_open_documents(&self) {
315        let has_related_info = *self.has_diagnostic_related_information.read().await;
316        let docs_snapshot = {
317            let docs = self.document_map.read().await;
318            docs.iter()
319                .map(|(uri, text)| (uri.clone(), text.clone()))
320                .collect::<Vec<_>>()
321        };
322
323        for (uri, text) in docs_snapshot {
324            validate_document_text_with(
325                &self.client,
326                self.manager.as_ref(),
327                &self.usage_regex,
328                self.runtime_config.undefined_var_fallback,
329                has_related_info,
330                &uri,
331                &text,
332                &self.document_usage_map,
333                &self.usage_index,
334                Some(&self.color_occurrence_uris),
335            )
336            .await;
337        }
338    }
339
340    async fn revalidate_affected_documents(
341        &self,
342        changed_names: &HashSet<String>,
343        exclude_uri: Option<&Uri>,
344        revalidate_color_occurrences: bool,
345    ) {
346        if changed_names.is_empty() && !revalidate_color_occurrences {
347            return;
348        }
349
350        let mut affected_uris = HashSet::new();
351        {
352            let index = self.usage_index.read().await;
353            for name in changed_names {
354                if let Some(uris) = index.get(name) {
355                    for uri in uris {
356                        if exclude_uri != Some(uri) {
357                            affected_uris.insert(uri.clone());
358                        }
359                    }
360                }
361            }
362        }
363
364        // Also revalidate documents with literal color occurrences if colors may have changed
365        if revalidate_color_occurrences {
366            let color_uris = self.color_occurrence_uris.read().await;
367            for uri in color_uris.iter() {
368                if exclude_uri != Some(uri) {
369                    affected_uris.insert(uri.clone());
370                }
371            }
372        }
373
374        if affected_uris.is_empty() {
375            return;
376        }
377
378        let has_related_info = *self.has_diagnostic_related_information.read().await;
379        let affected_snapshot = {
380            let docs = self.document_map.read().await;
381            affected_uris
382                .into_iter()
383                .filter_map(|uri| docs.get(&uri).map(|text| (uri.clone(), text.clone())))
384                .collect::<Vec<_>>()
385        };
386
387        for (uri, text) in affected_snapshot {
388            validate_document_text_with(
389                &self.client,
390                self.manager.as_ref(),
391                &self.usage_regex,
392                self.runtime_config.undefined_var_fallback,
393                has_related_info,
394                &uri,
395                &text,
396                &self.document_usage_map,
397                &self.usage_index,
398                Some(&self.color_occurrence_uris),
399            )
400            .await;
401        }
402    }
403
404    async fn update_document_from_disk(&self, uri: &Uri) {
405        let path = match to_normalized_fs_path(uri) {
406            Some(path) => path,
407            None => {
408                self.manager.remove_document(uri).await;
409                return;
410            }
411        };
412
413        match tokio::fs::read_to_string(&path).await {
414            Ok(text) => {
415                self.parse_document_text(uri, &text, None).await;
416            }
417            Err(_) => {
418                self.manager.remove_document(uri).await;
419                self.manager.rebuild_color_index().await;
420            }
421        }
422    }
423
424    async fn apply_content_changes(
425        &self,
426        uri: &Uri,
427        changes: Vec<TextDocumentContentChangeEvent>,
428    ) -> Option<String> {
429        let mut docs = self.document_map.write().await;
430        let mut text = if let Some(existing) = docs.get(uri) {
431            existing.clone()
432        } else {
433            if changes.len() == 1 && changes[0].range.is_none() {
434                let new_text = changes[0].text.clone();
435                docs.insert(uri.clone(), new_text.clone());
436                return Some(new_text);
437            }
438            return None;
439        };
440
441        for change in changes {
442            apply_change_to_text(&mut text, &change);
443        }
444
445        docs.insert(uri.clone(), text.clone());
446        Some(text)
447    }
448
449    fn get_word_at_position(&self, text: &str, position: Position) -> Option<String> {
450        let offset = position_to_offset(text, position)?;
451        let offset = clamp_to_char_boundary(text, offset);
452        let before = &text[..offset];
453        let after = &text[offset..];
454
455        let left = before
456            .rsplit(|c: char| !is_word_char(c))
457            .next()
458            .unwrap_or("");
459        let right = after.split(|c: char| !is_word_char(c)).next().unwrap_or("");
460        let word = format!("{}{}", left, right);
461        if word.starts_with("--") {
462            Some(word)
463        } else {
464            None
465        }
466    }
467
468    async fn is_document_open(&self, uri: &Uri) -> bool {
469        let docs = self.document_map.read().await;
470        docs.contains_key(uri)
471    }
472}
473
474// async_trait macro no longer needed for tower-lsp-server v0.21+
475impl LanguageServer for CssVariableLsp {
476    async fn initialize(
477        &self,
478        params: InitializeParams,
479    ) -> tower_lsp_server::jsonrpc::Result<InitializeResult> {
480        self.client
481            .log_message(MessageType::INFO, "CSS Variable LSP (Rust) initializing...")
482            .await;
483
484        let has_workspace_folders = params
485            .capabilities
486            .workspace
487            .as_ref()
488            .and_then(|w| w.workspace_folders)
489            .unwrap_or(false);
490        let has_related_info = params
491            .capabilities
492            .text_document
493            .as_ref()
494            .and_then(|t| t.publish_diagnostics.as_ref())
495            .and_then(|p| p.related_information)
496            .unwrap_or(false);
497
498        {
499            let mut cap = self.has_workspace_folder_capability.write().await;
500            *cap = has_workspace_folders;
501        }
502        {
503            let mut rel = self.has_diagnostic_related_information.write().await;
504            *rel = has_related_info;
505        }
506
507        #[allow(deprecated)]
508        if let Some(root_uri) = params.root_uri.as_ref() {
509            let root_path = to_normalized_fs_path(root_uri);
510            let mut root = self.root_folder_path.write().await;
511            *root = root_path;
512        } else {
513            #[allow(deprecated)]
514            if let Some(root_path) = params.root_path.as_ref() {
515                let mut root = self.root_folder_path.write().await;
516                *root = Some(PathBuf::from(root_path));
517            }
518        }
519
520        self.update_workspace_folder_paths(params.workspace_folders.clone())
521            .await;
522
523        let mut capabilities = ServerCapabilities {
524            text_document_sync: Some(TextDocumentSyncCapability::Kind(
525                TextDocumentSyncKind::INCREMENTAL,
526            )),
527            completion_provider: Some(CompletionOptions {
528                resolve_provider: Some(true),
529                trigger_characters: Some(vec!["-".to_string(), "(".to_string(), ":".to_string()]),
530                work_done_progress_options: WorkDoneProgressOptions::default(),
531                all_commit_characters: None,
532                completion_item: None,
533            }),
534            hover_provider: Some(ls_types::HoverProviderCapability::Simple(true)),
535            definition_provider: Some(OneOf::Left(true)),
536            references_provider: Some(OneOf::Left(true)),
537            code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
538            rename_provider: Some(OneOf::Right(RenameOptions {
539                prepare_provider: Some(true),
540                work_done_progress_options: WorkDoneProgressOptions::default(),
541            })),
542
543            document_symbol_provider: Some(OneOf::Left(true)),
544            workspace_symbol_provider: Some(OneOf::Left(true)),
545            color_provider: if self.runtime_config.enable_color_provider {
546                Some(ColorProviderCapability::Simple(true))
547            } else {
548                None
549            },
550            ..Default::default()
551        };
552
553        if has_workspace_folders {
554            capabilities.workspace = Some(WorkspaceServerCapabilities {
555                workspace_folders: Some(WorkspaceFoldersServerCapabilities {
556                    supported: Some(true),
557                    change_notifications: Some(OneOf::Left(true)),
558                }),
559                file_operations: None,
560            });
561        }
562
563        Ok(InitializeResult {
564            capabilities,
565            offset_encoding: Some("utf-16".to_string()),
566            server_info: Some(ls_types::ServerInfo {
567                name: "css-variable-lsp-rust".to_string(),
568                version: Some("0.1.0".to_string()),
569            }),
570        })
571    }
572
573    async fn initialized(&self, _params: ls_types::InitializedParams) {
574        self.client
575            .log_message(MessageType::INFO, "CSS Variable LSP (Rust) initialized!")
576            .await;
577
578        if let Ok(Some(folders)) = self.client.workspace_folders().await {
579            self.update_workspace_folder_paths(Some(folders.clone()))
580                .await;
581            self.scan_workspace_folders(folders).await;
582        }
583    }
584
585    async fn shutdown(&self) -> tower_lsp_server::jsonrpc::Result<()> {
586        Ok(())
587    }
588
589    async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
590        // We accept either:
591        // - a flat object matching Config fields
592        // - or a namespaced object { "cssVariableLsp": { ... } }
593        let patch =
594            serde_json::from_value::<ClientConfigPatch>(params.settings.clone()).or_else(|_| {
595                params
596                    .settings
597                    .get("cssVariableLsp")
598                    .cloned()
599                    .ok_or_else(|| {
600                        serde_json::Error::io(std::io::Error::new(
601                            std::io::ErrorKind::InvalidInput,
602                            "missing cssVariableLsp key",
603                        ))
604                    })
605                    .and_then(serde_json::from_value::<ClientConfigPatch>)
606            });
607
608        let patch = match patch {
609            Ok(patch) => patch,
610            Err(err) => {
611                self.client
612                    .log_message(
613                        MessageType::WARNING,
614                        format!("Failed to parse didChangeConfiguration settings: {}", err),
615                    )
616                    .await;
617                return;
618            }
619        };
620
621        let mut config = self.live_config.read().await.clone();
622        let prev_lookup_files = config.lookup_files.clone();
623        config = apply_config_patch(config, patch);
624
625        {
626            let mut stored = self.live_config.write().await;
627            *stored = config.clone();
628        }
629
630        self.manager.set_config(config.clone()).await;
631
632        // Update extension map if lookup patterns changed.
633        if config.lookup_files != prev_lookup_files {
634            let new_map = build_lookup_extension_map(&config.lookup_files);
635            let mut stored = self.lookup_extension_map.write().await;
636            *stored = new_map;
637
638            // Patterns changed => rescan workspace folders.
639            if let Ok(Some(folders)) = self.client.workspace_folders().await {
640                self.scan_workspace_folders(folders).await;
641            }
642        }
643
644        // Always revalidate open docs (diagnostics may change due to ignore patterns etc.).
645        self.validate_all_open_documents().await;
646    }
647
648    async fn did_open(&self, params: DidOpenTextDocumentParams) {
649        let uri = params.text_document.uri;
650        let text = params.text_document.text;
651        let language_id = params.text_document.language_id;
652
653        let old_names = self.manager.get_document_variable_names(&uri).await;
654        let old_colors = self.manager.get_document_resolved_color_keys(&uri).await;
655
656        {
657            let mut docs = self.document_map.write().await;
658            docs.insert(uri.clone(), text.clone());
659        }
660        {
661            let mut langs = self.document_language_map.write().await;
662            langs.insert(uri.clone(), language_id.clone());
663        }
664        self.parse_document_text(&uri, &text, Some(&language_id))
665            .await;
666
667        let new_names = self.manager.get_document_variable_names(&uri).await;
668        let new_colors = self.manager.get_document_resolved_color_keys(&uri).await;
669
670        self.validate_document_text(&uri, &text).await;
671
672        let colors_changed = old_colors != new_colors;
673        if old_names != new_names || colors_changed {
674            let added_names: HashSet<_> = new_names.difference(&old_names).cloned().collect();
675            let removed_names: HashSet<_> = old_names.difference(&new_names).cloned().collect();
676            let mut changed_names = added_names;
677            changed_names.extend(removed_names);
678            self.revalidate_affected_documents(&changed_names, Some(&uri), colors_changed)
679                .await;
680        }
681    }
682
683    async fn did_change(&self, params: DidChangeTextDocumentParams) {
684        let uri = params.text_document.uri;
685        let changes = params.content_changes;
686
687        let old_names = self.manager.get_document_variable_names(&uri).await;
688        let old_colors = self.manager.get_document_resolved_color_keys(&uri).await;
689
690        let updated_text = match self.apply_content_changes(&uri, changes).await {
691            Some(text) => text,
692            None => return,
693        };
694        let language_id = {
695            let langs = self.document_language_map.read().await;
696            langs.get(&uri).cloned()
697        };
698        self.parse_document_text(&uri, &updated_text, language_id.as_deref())
699            .await;
700
701        let new_names = self.manager.get_document_variable_names(&uri).await;
702        let new_colors = self.manager.get_document_resolved_color_keys(&uri).await;
703
704        self.validate_document_text(&uri, &updated_text).await;
705
706        let colors_changed = old_colors != new_colors;
707        if old_names != new_names || colors_changed {
708            let added_names: HashSet<_> = new_names.difference(&old_names).cloned().collect();
709            let removed_names: HashSet<_> = old_names.difference(&new_names).cloned().collect();
710            let mut changed_names = added_names;
711            changed_names.extend(removed_names);
712            self.revalidate_affected_documents(&changed_names, Some(&uri), colors_changed)
713                .await;
714        }
715    }
716
717    async fn will_save(&self, _params: WillSaveTextDocumentParams) {
718        // No-op: no pre-save mutation required.
719    }
720
721    async fn will_save_wait_until(
722        &self,
723        _params: WillSaveTextDocumentParams,
724    ) -> tower_lsp_server::jsonrpc::Result<Option<Vec<TextEdit>>> {
725        // No-op: no pre-save edits to apply.
726        Ok(None)
727    }
728
729    async fn did_save(&self, _params: DidSaveTextDocumentParams) {
730        // No-op: we already parse and validate on open/change notifications.
731    }
732
733    async fn did_close(&self, params: DidCloseTextDocumentParams) {
734        let uri = params.text_document.uri;
735
736        let old_names = self.manager.get_document_variable_names(&uri).await;
737        let old_colors = self.manager.get_document_resolved_color_keys(&uri).await;
738
739        {
740            let mut docs = self.document_map.write().await;
741            docs.remove(&uri);
742        }
743        {
744            let mut langs = self.document_language_map.write().await;
745            langs.remove(&uri);
746        }
747
748        // Clean up usage maps
749        {
750            let mut usage_map = self.document_usage_map.write().await;
751            if let Some(old_usages) = usage_map.remove(&uri) {
752                let mut index = self.usage_index.write().await;
753                for name in old_usages {
754                    if let Some(uris) = index.get_mut(&name) {
755                        uris.remove(&uri);
756                        if uris.is_empty() {
757                            index.remove(&name);
758                        }
759                    }
760                }
761            }
762        }
763
764        self.update_document_from_disk(&uri).await;
765
766        let new_names = self.manager.get_document_variable_names(&uri).await;
767        let new_colors = self.manager.get_document_resolved_color_keys(&uri).await;
768
769        let colors_changed = old_colors != new_colors;
770        if old_names != new_names || colors_changed {
771            let added_names: HashSet<_> = new_names.difference(&old_names).cloned().collect();
772            let removed_names: HashSet<_> = old_names.difference(&new_names).cloned().collect();
773            let mut changed_names = added_names;
774            changed_names.extend(removed_names);
775            self.revalidate_affected_documents(&changed_names, Some(&uri), colors_changed)
776                .await;
777        }
778    }
779
780    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
781        for change in params.changes {
782            match change.typ {
783                FileChangeType::DELETED => {
784                    self.manager.remove_document(&change.uri).await;
785                }
786                FileChangeType::CREATED | FileChangeType::CHANGED
787                    if !self.is_document_open(&change.uri).await =>
788                {
789                    self.update_document_from_disk(&change.uri).await;
790                }
791                _ => {}
792            }
793        }
794
795        self.validate_all_open_documents().await;
796    }
797
798    async fn did_create_files(&self, params: CreateFilesParams) {
799        for file in params.files {
800            let uri = match Uri::from_str(&file.uri) {
801                Ok(uri) => uri,
802                Err(_) => continue,
803            };
804            if !self.is_document_open(&uri).await {
805                self.update_document_from_disk(&uri).await;
806            }
807        }
808        self.validate_all_open_documents().await;
809    }
810
811    async fn did_rename_files(&self, params: RenameFilesParams) {
812        for file in params.files {
813            let old_uri = match Uri::from_str(&file.old_uri) {
814                Ok(uri) => uri,
815                Err(_) => continue,
816            };
817            let new_uri = match Uri::from_str(&file.new_uri) {
818                Ok(uri) => uri,
819                Err(_) => continue,
820            };
821
822            // Remove old document data
823            self.manager.remove_document(&old_uri).await;
824
825            // If the new URI is not open, load it from disk.
826            if !self.is_document_open(&new_uri).await {
827                self.update_document_from_disk(&new_uri).await;
828            }
829        }
830        self.validate_all_open_documents().await;
831    }
832
833    async fn did_delete_files(&self, params: DeleteFilesParams) {
834        for file in params.files {
835            let uri = match Uri::from_str(&file.uri) {
836                Ok(uri) => uri,
837                Err(_) => continue,
838            };
839            self.manager.remove_document(&uri).await;
840        }
841        self.validate_all_open_documents().await;
842    }
843
844    async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
845        let mut current_paths = {
846            let paths = self.workspace_folder_paths.read().await;
847            paths.clone()
848        };
849
850        for removed in params.event.removed {
851            if let Some(path) = to_normalized_fs_path(&removed.uri) {
852                current_paths.retain(|p| p != &path);
853            }
854        }
855
856        for added in params.event.added {
857            if let Some(path) = to_normalized_fs_path(&added.uri) {
858                current_paths.push(path);
859            }
860        }
861
862        current_paths.sort_by_key(|b| std::cmp::Reverse(b.to_string_lossy().len()));
863
864        let mut stored = self.workspace_folder_paths.write().await;
865        *stored = current_paths;
866    }
867
868    async fn completion(
869        &self,
870        params: CompletionParams,
871    ) -> tower_lsp_server::jsonrpc::Result<Option<CompletionResponse>> {
872        let uri = params.text_document_position.text_document.uri;
873        let position = params.text_document_position.position;
874
875        let text = {
876            let docs = self.document_map.read().await;
877            docs.get(&uri).cloned()
878        };
879        let text = match text {
880            Some(text) => text,
881            None => return Ok(Some(CompletionResponse::Array(Vec::new()))),
882        };
883
884        let language_id = {
885            let langs = self.document_language_map.read().await;
886            langs.get(&uri).cloned()
887        };
888        let lookup_map = self.lookup_extension_map.read().await.clone();
889        let context = completion_value_context_slice(
890            &text,
891            position,
892            language_id.as_deref(),
893            &uri,
894            &lookup_map,
895        );
896        let context = match context {
897            Some(context) => context,
898            None => return Ok(Some(CompletionResponse::Array(Vec::new()))),
899        };
900        let value_context = get_value_context_info(context.slice, context.allow_without_braces);
901        if !value_context.is_value_context {
902            return Ok(Some(CompletionResponse::Array(Vec::new())));
903        }
904        let property_name = value_context.property_name;
905        let in_var_context = is_var_function_context_slice(context.slice);
906        let workspace_folder_paths = self.workspace_folder_paths.read().await.clone();
907        let root_folder_path = self.root_folder_path.read().await.clone();
908
909        if !in_var_context {
910            if let Some(color_key) = self.literal_color_under_cursor(&uri, position).await {
911                let variables = self.manager.get_variables_by_color_key(&color_key).await;
912                let items = variables
913                    .into_iter()
914                    .map(|var| {
915                        let options = PathDisplayOptions {
916                            mode: self.runtime_config.path_display_mode,
917                            abbrev_length: self.runtime_config.path_display_abbrev_length,
918                            workspace_folder_paths: &workspace_folder_paths,
919                            root_folder_path: root_folder_path.as_ref(),
920                        };
921                        let display_path = format_uri_for_display(&var.uri, options);
922                        CompletionItem {
923                            label: var.name.clone(),
924                            kind: Some(CompletionItemKind::VARIABLE),
925                            detail: Some(format!("{} • {}", var.value, display_path)),
926                            insert_text: Some(format!("var({})", var.name)),
927                            documentation: Some(ls_types::Documentation::String(format!(
928                                "Defined in {}",
929                                display_path
930                            ))),
931                            ..Default::default()
932                        }
933                    })
934                    .collect();
935                return Ok(Some(CompletionResponse::Array(items)));
936            }
937        }
938
939        let variables = self.manager.get_all_variables().await;
940
941        let mut unique_vars = HashMap::new();
942        for var in variables {
943            unique_vars.entry(var.name.clone()).or_insert(var);
944        }
945
946        let mut scored_vars: Vec<(i32, _)> = unique_vars
947            .values()
948            .map(|var| {
949                let score = score_variable_relevance(&var.name, property_name.as_deref());
950                (score, var)
951            })
952            .collect();
953
954        scored_vars.retain(|(score, _)| *score != 0);
955        scored_vars.sort_by(|(score_a, var_a), (score_b, var_b)| {
956            if score_a != score_b {
957                return score_b.cmp(score_a);
958            }
959            var_a.name.cmp(&var_b.name)
960        });
961
962        let items = scored_vars
963            .into_iter()
964            .map(|(_, var)| {
965                let options = PathDisplayOptions {
966                    mode: self.runtime_config.path_display_mode,
967                    abbrev_length: self.runtime_config.path_display_abbrev_length,
968                    workspace_folder_paths: &workspace_folder_paths,
969                    root_folder_path: root_folder_path.as_ref(),
970                };
971                let insert_text = if in_var_context {
972                    var.name.clone()
973                } else {
974                    format!("var({})", var.name)
975                };
976                CompletionItem {
977                    label: var.name.clone(),
978                    kind: Some(CompletionItemKind::VARIABLE),
979                    detail: Some(var.value.clone()),
980                    insert_text: Some(insert_text),
981                    documentation: Some(ls_types::Documentation::String(format!(
982                        "Defined in {}",
983                        format_uri_for_display(&var.uri, options)
984                    ))),
985                    ..Default::default()
986                }
987            })
988            .collect();
989
990        Ok(Some(CompletionResponse::Array(items)))
991    }
992
993    async fn completion_resolve(
994        &self,
995        item: CompletionItem,
996    ) -> tower_lsp_server::jsonrpc::Result<CompletionItem> {
997        Ok(item)
998    }
999
1000    async fn hover(&self, params: HoverParams) -> tower_lsp_server::jsonrpc::Result<Option<Hover>> {
1001        let uri = params.text_document_position_params.text_document.uri;
1002        let position = params.text_document_position_params.position;
1003
1004        let text = {
1005            let docs = self.document_map.read().await;
1006            docs.get(&uri).cloned()
1007        };
1008        let text = match text {
1009            Some(text) => text,
1010            None => return Ok(None),
1011        };
1012
1013        let word = match self.get_word_at_position(&text, position) {
1014            Some(word) => word,
1015            None => return Ok(None),
1016        };
1017
1018        let mut definitions = self.manager.get_variables(&word).await;
1019        if definitions.is_empty() {
1020            return Ok(None);
1021        }
1022
1023        let usages = self.manager.get_usages(&word).await;
1024        let offset = match position_to_offset(&text, position) {
1025            Some(offset) => offset,
1026            None => return Ok(None),
1027        };
1028        let hover_usage = usages.iter().find(|usage| {
1029            if usage.uri != uri {
1030                return false;
1031            }
1032            let start = position_to_offset(&text, usage.range.start).unwrap_or(0);
1033            let end = position_to_offset(&text, usage.range.end).unwrap_or(0);
1034            offset >= start && offset <= end
1035        });
1036
1037        let usage_context = hover_usage
1038            .map(|u| u.usage_context.clone())
1039            .unwrap_or_default();
1040        let is_inline_style = usage_context == "inline-style";
1041        let dom_tree = self.manager.get_dom_tree(&uri).await;
1042        let dom_node = hover_usage.and_then(|u| u.dom_node.clone());
1043
1044        sort_by_cascade(&mut definitions);
1045
1046        let mut hover_text = format!("### CSS Variable: `{}`\n\n", word);
1047
1048        if definitions.len() == 1 {
1049            let var = &definitions[0];
1050            hover_text.push_str(&format!("**Value:** `{}`", var.value));
1051            if var.important {
1052                hover_text.push_str(" **!important**");
1053            }
1054            hover_text.push_str("\n\n");
1055            if !var.selector.is_empty() {
1056                hover_text.push_str(&format!("**Defined in:** `{}`\n", var.selector));
1057                hover_text.push_str(&format!(
1058                    "**Specificity:** {}\n",
1059                    format_specificity(calculate_specificity(&var.selector))
1060                ));
1061            }
1062        } else {
1063            hover_text.push_str("**Definitions** (CSS cascade order):\n\n");
1064
1065            for (idx, var) in definitions.iter().enumerate() {
1066                let spec = calculate_specificity(&var.selector);
1067                let is_applicable = if usage_context.is_empty() {
1068                    true
1069                } else {
1070                    matches_context(
1071                        &var.selector,
1072                        &usage_context,
1073                        dom_tree.as_ref(),
1074                        dom_node.as_ref(),
1075                    )
1076                };
1077                let is_winner = idx == 0 && (is_applicable || is_inline_style);
1078
1079                let mut line = format!("{}. `{}`", idx + 1, var.value);
1080                if var.important {
1081                    line.push_str(" **!important**");
1082                }
1083                if !var.selector.is_empty() {
1084                    line.push_str(&format!(
1085                        " from `{}` {}",
1086                        var.selector,
1087                        format_specificity(spec)
1088                    ));
1089                }
1090
1091                if is_winner && !usage_context.is_empty() {
1092                    if var.important {
1093                        line.push_str(" ✓ **Wins (!important)**");
1094                    } else if is_inline_style {
1095                        line.push_str(" ✓ **Would apply (inline style)**");
1096                    } else if dom_tree.is_some() && dom_node.is_some() {
1097                        line.push_str(" ✓ **Applies (DOM match)**");
1098                    } else {
1099                        line.push_str(" ✓ **Applies here**");
1100                    }
1101                } else if !is_applicable && !usage_context.is_empty() && !is_inline_style {
1102                    line.push_str(" _(selector doesn't match)_");
1103                } else if idx > 0 && !usage_context.is_empty() {
1104                    let winner = &definitions[0];
1105                    if winner.important && !var.important {
1106                        line.push_str(" _(overridden by !important)_");
1107                    } else {
1108                        let winner_spec = calculate_specificity(&winner.selector);
1109                        let cmp = compare_specificity(winner_spec, spec);
1110                        if cmp > 0 {
1111                            line.push_str(" _(lower specificity)_");
1112                        } else if cmp == 0 {
1113                            line.push_str(" _(earlier in source)_");
1114                        }
1115                    }
1116                }
1117
1118                hover_text.push_str(&line);
1119                hover_text.push('\n');
1120            }
1121
1122            if !usage_context.is_empty() {
1123                if is_inline_style {
1124                    hover_text.push_str("\n_Context: Inline style (highest priority)_");
1125                } else if dom_tree.is_some() && dom_node.is_some() {
1126                    hover_text.push_str(&format!(
1127                        "\n_Context: `{}` (DOM-aware matching)_",
1128                        usage_context
1129                    ));
1130                } else {
1131                    hover_text.push_str(&format!("\n_Context: `{}`_", usage_context));
1132                }
1133            }
1134        }
1135
1136        Ok(Some(Hover {
1137            contents: HoverContents::Markup(MarkupContent {
1138                kind: MarkupKind::Markdown,
1139                value: hover_text,
1140            }),
1141            range: None,
1142        }))
1143    }
1144
1145    async fn goto_definition(
1146        &self,
1147        params: GotoDefinitionParams,
1148    ) -> tower_lsp_server::jsonrpc::Result<Option<GotoDefinitionResponse>> {
1149        let uri = params.text_document_position_params.text_document.uri;
1150        let position = params.text_document_position_params.position;
1151
1152        let text = {
1153            let docs = self.document_map.read().await;
1154            docs.get(&uri).cloned()
1155        };
1156        let text = match text {
1157            Some(text) => text,
1158            None => return Ok(None),
1159        };
1160
1161        let word = match self.get_word_at_position(&text, position) {
1162            Some(word) => word,
1163            None => return Ok(None),
1164        };
1165
1166        let definitions = self.manager.get_variables(&word).await;
1167        let first = match definitions.first() {
1168            Some(def) => def,
1169            None => return Ok(None),
1170        };
1171
1172        Ok(Some(GotoDefinitionResponse::Scalar(Location::new(
1173            first.uri.clone(),
1174            first.range,
1175        ))))
1176    }
1177
1178    async fn code_action(
1179        &self,
1180        params: CodeActionParams,
1181    ) -> tower_lsp_server::jsonrpc::Result<Option<CodeActionResponse>> {
1182        let uri = params.text_document.uri;
1183        let text = {
1184            let docs = self.document_map.read().await;
1185            docs.get(&uri).cloned()
1186        };
1187        let text = match text {
1188            Some(text) => text,
1189            None => return Ok(Some(Vec::new())),
1190        };
1191
1192        let mut actions: Vec<CodeActionOrCommand> = Vec::new();
1193
1194        // 1) Quick-fix undefined variable diagnostics.
1195        actions.extend(code_actions_for_undefined_variables(
1196            &uri,
1197            &text,
1198            &params.context,
1199            &self.runtime_config,
1200        ));
1201        actions.extend(code_actions_for_replaceable_literal_colors(
1202            &uri,
1203            &params.context,
1204            &self.runtime_config,
1205        ));
1206
1207        Ok(Some(actions))
1208    }
1209
1210    async fn references(
1211        &self,
1212        params: ReferenceParams,
1213    ) -> tower_lsp_server::jsonrpc::Result<Option<Vec<Location>>> {
1214        let uri = params.text_document_position.text_document.uri;
1215        let position = params.text_document_position.position;
1216
1217        let text = {
1218            let docs = self.document_map.read().await;
1219            docs.get(&uri).cloned()
1220        };
1221        let text = match text {
1222            Some(text) => text,
1223            None => return Ok(None),
1224        };
1225
1226        let word = match self.get_word_at_position(&text, position) {
1227            Some(word) => word,
1228            None => return Ok(None),
1229        };
1230
1231        let (definitions, usages) = self.manager.get_references(&word).await;
1232        let mut locations = Vec::new();
1233        for def in definitions {
1234            locations.push(Location::new(def.uri, def.range));
1235        }
1236        for usage in usages {
1237            locations.push(Location::new(usage.uri, usage.range));
1238        }
1239
1240        Ok(Some(locations))
1241    }
1242
1243    async fn document_color(
1244        &self,
1245        params: DocumentColorParams,
1246    ) -> tower_lsp_server::jsonrpc::Result<Vec<ColorInformation>> {
1247        let config = self.manager.get_config().await;
1248        if !config.enable_color_provider {
1249            return Ok(Vec::new());
1250        }
1251
1252        let uri = params.text_document.uri;
1253        let text = {
1254            let docs = self.document_map.read().await;
1255            docs.get(&uri).cloned()
1256        };
1257        let text = match text {
1258            Some(text) => text,
1259            None => return Ok(Vec::new()),
1260        };
1261
1262        let mut colors = Vec::new();
1263        let mut seen_ranges: HashSet<(u32, u32, u32, u32)> = HashSet::new();
1264        let range_key = |range: &Range| {
1265            (
1266                range.start.line,
1267                range.start.character,
1268                range.end.line,
1269                range.end.character,
1270            )
1271        };
1272
1273        if !config.color_only_on_variables {
1274            let definitions = self.manager.get_document_variables(&uri).await;
1275            for def in definitions {
1276                if let Some(color) = parse_color(&def.value) {
1277                    if let Some(value_range) = def.value_range {
1278                        if seen_ranges.insert(range_key(&value_range)) {
1279                            colors.push(ColorInformation {
1280                                range: value_range,
1281                                color,
1282                            });
1283                        }
1284                    } else if let Some(range) = find_value_range_in_definition(&text, &def) {
1285                        if seen_ranges.insert(range_key(&range)) {
1286                            colors.push(ColorInformation { range, color });
1287                        }
1288                    }
1289                }
1290            }
1291        }
1292
1293        let usages = self.manager.get_document_usages(&uri).await;
1294        for usage in usages {
1295            if let Some(color) = self.manager.resolve_variable_color(&usage.name).await {
1296                if seen_ranges.insert(range_key(&usage.range)) {
1297                    colors.push(ColorInformation {
1298                        range: usage.range,
1299                        color,
1300                    });
1301                }
1302            }
1303        }
1304
1305        for caps in self.var_usage_regex.captures_iter(&text) {
1306            let match_all = caps.get(0).unwrap();
1307            let var_name = caps.get(1).unwrap().as_str();
1308            let range = Range::new(
1309                crate::types::offset_to_position(&text, match_all.start()),
1310                crate::types::offset_to_position(&text, match_all.end()),
1311            );
1312            if !seen_ranges.insert(range_key(&range)) {
1313                continue;
1314            }
1315            if let Some(color) = self.manager.resolve_variable_color(var_name).await {
1316                colors.push(ColorInformation { range, color });
1317            }
1318        }
1319
1320        Ok(colors)
1321    }
1322
1323    async fn color_presentation(
1324        &self,
1325        params: ColorPresentationParams,
1326    ) -> tower_lsp_server::jsonrpc::Result<Vec<ColorPresentation>> {
1327        if !self.runtime_config.enable_color_provider {
1328            return Ok(Vec::new());
1329        }
1330        Ok(generate_color_presentations(params.color, params.range))
1331    }
1332
1333    async fn prepare_rename(
1334        &self,
1335        params: TextDocumentPositionParams,
1336    ) -> tower_lsp_server::jsonrpc::Result<Option<PrepareRenameResponse>> {
1337        let uri = params.text_document.uri;
1338        let position = params.position;
1339
1340        let text = {
1341            let docs = self.document_map.read().await;
1342            docs.get(&uri).cloned()
1343        };
1344        let text = match text {
1345            Some(text) => text,
1346            None => return Ok(None),
1347        };
1348
1349        let name = match self.get_word_at_position(&text, position) {
1350            Some(name) => name,
1351            None => return Ok(None),
1352        };
1353
1354        // Prefer the precise name range when available.
1355        let definitions = self.manager.get_variables(&name).await;
1356        let range = definitions
1357            .first()
1358            .and_then(|d| d.name_range)
1359            .unwrap_or_else(|| {
1360                // Fallback to the word bounds at the cursor position.
1361                // We intentionally return the cursor word selection rather than the whole declaration.
1362                let offset = position_to_offset(&text, position).unwrap_or(0);
1363                let offset = clamp_to_char_boundary(&text, offset);
1364
1365                let before = &text[..offset];
1366                let after = &text[offset..];
1367
1368                // Compute byte indices for the word under the cursor.
1369                // We do this manually because LSP positions are UTF-16 based.
1370                //
1371                // Note: get_word_at_position already verifies we're on a `--var`.
1372                // So the scan boundaries are safe for our token definition.
1373
1374                // Simpler: recompute via byte indices using char_indices
1375                let mut start_byte = offset;
1376                for (i, c) in before.char_indices().rev() {
1377                    if is_word_char(c) {
1378                        start_byte = i;
1379                    } else {
1380                        break;
1381                    }
1382                }
1383                let mut end_byte = offset;
1384                for (i, c) in after.char_indices() {
1385                    if is_word_char(c) {
1386                        end_byte = offset + i + c.len_utf8();
1387                    } else {
1388                        break;
1389                    }
1390                }
1391
1392                Range::new(
1393                    crate::types::offset_to_position(&text, start_byte),
1394                    crate::types::offset_to_position(&text, end_byte),
1395                )
1396            });
1397
1398        Ok(Some(PrepareRenameResponse::Range(range)))
1399    }
1400
1401    async fn rename(
1402        &self,
1403        params: RenameParams,
1404    ) -> tower_lsp_server::jsonrpc::Result<Option<WorkspaceEdit>> {
1405        let uri = params.text_document_position.text_document.uri;
1406        let position = params.text_document_position.position;
1407        let new_name = params.new_name;
1408
1409        let text = {
1410            let docs = self.document_map.read().await;
1411            docs.get(&uri).cloned()
1412        };
1413        let text = match text {
1414            Some(text) => text,
1415            None => return Ok(None),
1416        };
1417
1418        let old_name = match self.get_word_at_position(&text, position) {
1419            Some(word) => word,
1420            None => return Ok(None),
1421        };
1422
1423        let (definitions, usages) = self.manager.get_references(&old_name).await;
1424        let mut changes: HashMap<Uri, Vec<TextEdit>> = HashMap::new();
1425
1426        for def in definitions {
1427            let range = def.name_range.unwrap_or(def.range);
1428            changes.entry(def.uri.clone()).or_default().push(TextEdit {
1429                range,
1430                new_text: new_name.clone(),
1431            });
1432        }
1433
1434        for usage in usages {
1435            let range = usage.name_range.unwrap_or(usage.range);
1436            changes
1437                .entry(usage.uri.clone())
1438                .or_default()
1439                .push(TextEdit {
1440                    range,
1441                    new_text: new_name.clone(),
1442                });
1443        }
1444
1445        Ok(Some(WorkspaceEdit {
1446            changes: Some(changes),
1447            document_changes: None,
1448            change_annotations: None,
1449        }))
1450    }
1451
1452    #[allow(deprecated)]
1453    async fn document_symbol(
1454        &self,
1455        params: DocumentSymbolParams,
1456    ) -> tower_lsp_server::jsonrpc::Result<Option<DocumentSymbolResponse>> {
1457        let vars = self
1458            .manager
1459            .get_document_variables(&params.text_document.uri)
1460            .await;
1461        let symbols: Vec<DocumentSymbol> = vars
1462            .into_iter()
1463            .map(|var| DocumentSymbol {
1464                name: var.name,
1465                detail: Some(var.value),
1466                kind: SymbolKind::VARIABLE,
1467                tags: None,
1468                deprecated: None,
1469                range: var.range,
1470                selection_range: var.range,
1471                children: None,
1472            })
1473            .collect();
1474
1475        Ok(Some(DocumentSymbolResponse::Nested(symbols)))
1476    }
1477
1478    #[allow(deprecated)]
1479    async fn symbol(
1480        &self,
1481        params: WorkspaceSymbolParams,
1482    ) -> tower_lsp_server::jsonrpc::Result<Option<WorkspaceSymbolResponse>> {
1483        let query = params.query.to_lowercase();
1484        let vars = self.manager.get_all_variables().await;
1485        let mut symbols = Vec::new();
1486
1487        for var in vars {
1488            if !query.is_empty() && !var.name.to_lowercase().contains(&query) {
1489                continue;
1490            }
1491            symbols.push(SymbolInformation {
1492                name: var.name,
1493                kind: SymbolKind::VARIABLE,
1494                tags: None,
1495                deprecated: None,
1496                location: Location::new(var.uri.clone(), var.range),
1497                container_name: None,
1498            });
1499        }
1500
1501        Ok(Some(WorkspaceSymbolResponse::Flat(symbols)))
1502    }
1503}
1504
1505impl CssVariableLsp {
1506    async fn literal_color_under_cursor(
1507        &self,
1508        uri: &Uri,
1509        position: Position,
1510    ) -> Option<NormalizedColorKey> {
1511        let occurrences = self
1512            .manager
1513            .get_literal_colors_at_position(uri, position)
1514            .await;
1515        occurrences
1516            .into_iter()
1517            .find(|occurrence| range_contains_position(&occurrence.range, position))
1518            .map(|occurrence| occurrence.normalized_color)
1519    }
1520
1521    /// Scan workspace folders for CSS and HTML files
1522    pub async fn scan_workspace_folders(&self, folders: Vec<WorkspaceFolder>) {
1523        let folder_uris: Vec<Uri> = folders.iter().map(|f| f.uri.clone()).collect();
1524
1525        self.client
1526            .log_message(
1527                MessageType::INFO,
1528                format!("Scanning {} workspace folders...", folder_uris.len()),
1529            )
1530            .await;
1531
1532        let manager = self.manager.clone();
1533        let client = self.client.clone();
1534
1535        let mut last_logged_percentage = 0;
1536        let result = crate::workspace::scan_workspace(folder_uris, &manager, |current, total| {
1537            if total == 0 {
1538                return;
1539            }
1540            let percentage = ((current as f64 / total as f64) * 100.0).round() as i32;
1541            if percentage - last_logged_percentage >= 20 || current == total {
1542                last_logged_percentage = percentage;
1543                let client = client.clone();
1544                tokio::spawn(async move {
1545                    client
1546                        .log_message(
1547                            MessageType::INFO,
1548                            format!(
1549                                "Scanning CSS files: {}/{} ({}%)",
1550                                current, total, percentage
1551                            ),
1552                        )
1553                        .await;
1554                });
1555            }
1556        })
1557        .await;
1558
1559        match result {
1560            Ok(_) => {
1561                let total_vars = manager.get_all_variables().await.len();
1562                self.client
1563                    .log_message(
1564                        MessageType::INFO,
1565                        format!(
1566                            "Workspace scan complete. Found {} CSS variables.",
1567                            total_vars
1568                        ),
1569                    )
1570                    .await;
1571            }
1572            Err(e) => {
1573                self.client
1574                    .log_message(MessageType::ERROR, format!("Workspace scan failed: {}", e))
1575                    .await;
1576            }
1577        }
1578
1579        self.validate_all_open_documents().await;
1580    }
1581}
1582
1583#[allow(clippy::too_many_arguments)]
1584async fn validate_document_text_with(
1585    client: &Client,
1586    manager: &CssVariableManager,
1587    usage_regex: &Regex,
1588    undefined_var_fallback: UndefinedVarFallbackMode,
1589    has_related_info: bool,
1590    uri: &Uri,
1591    text: &str,
1592    document_usage_map: &Arc<RwLock<HashMap<Uri, HashSet<String>>>>,
1593    usage_index: &Arc<RwLock<HashMap<String, HashSet<Uri>>>>,
1594    color_occurrence_uris: Option<&Arc<RwLock<HashSet<Uri>>>>,
1595) {
1596    let mut diagnostics = Vec::new();
1597    let mut current_usages = HashSet::new();
1598    let mut has_literal_colors = false;
1599    let default_severity = DiagnosticSeverity::WARNING;
1600
1601    for captures in usage_regex.captures_iter(text) {
1602        let match_all = captures.get(0).unwrap();
1603        let name = captures.get(1).unwrap().as_str();
1604        let fallback = captures.get(2).map(|m| m.as_str()).unwrap_or("");
1605        let has_fallback = !fallback.trim().is_empty();
1606
1607        current_usages.insert(name.to_string());
1608
1609        let definitions = manager.get_variables(name).await;
1610        if !definitions.is_empty() {
1611            continue;
1612        }
1613
1614        let severity = if has_fallback {
1615            match undefined_var_fallback {
1616                UndefinedVarFallbackMode::Warning => Some(default_severity),
1617                UndefinedVarFallbackMode::Info => Some(DiagnosticSeverity::INFORMATION),
1618                UndefinedVarFallbackMode::Off => None,
1619            }
1620        } else {
1621            Some(default_severity)
1622        };
1623        let severity = match severity {
1624            Some(severity) => severity,
1625            None => continue,
1626        };
1627        let range = Range::new(
1628            crate::types::offset_to_position(text, match_all.start()),
1629            crate::types::offset_to_position(text, match_all.end()),
1630        );
1631        diagnostics.push(Diagnostic {
1632            range,
1633            severity: Some(severity),
1634            code: Some(ls_types::NumberOrString::String(
1635                "css-variable-lsp.undefined-variable".to_string(),
1636            )),
1637            code_description: None,
1638            source: Some("css-variable-lsp".to_string()),
1639            message: format!("CSS variable '{}' is not defined in the workspace", name),
1640            related_information: if has_related_info {
1641                Some(Vec::new())
1642            } else {
1643                None
1644            },
1645            tags: None,
1646            data: Some(serde_json::json!({
1647                "name": name,
1648                "hasFallback": has_fallback,
1649                "range": {
1650                    "start": { "line": range.start.line, "character": range.start.character },
1651                    "end": { "line": range.end.line, "character": range.end.character }
1652                }
1653            })),
1654        });
1655    }
1656
1657    for occurrence in manager.get_document_literal_colors(uri).await {
1658        let all_replacements = manager
1659            .get_variables_by_color_key(&occurrence.normalized_color)
1660            .await;
1661
1662        // Filter out replacement variables where this color is in their own value.
1663        // This prevents suggesting replacing #ffd166 with --accent-2 when editing
1664        // the definition: --accent-2: #ffd166;
1665        let replacements: Vec<_> = all_replacements
1666            .iter()
1667            .filter(|var| {
1668                // Keep if the variable is in a different file
1669                if var.uri != *uri {
1670                    return true;
1671                }
1672                // Keep if no value_range available (can't determine overlap)
1673                let Some(value_range) = &var.value_range else {
1674                    return true;
1675                };
1676                // Filter out if occurrence is within this variable's value range
1677                !range_contains(value_range, &occurrence.range)
1678            })
1679            .cloned()
1680            .collect();
1681
1682        if replacements.is_empty() {
1683            continue;
1684        }
1685
1686        // Track that this document has literal color occurrences
1687        has_literal_colors = true;
1688
1689        let replacement_data: Vec<_> = replacements
1690            .iter()
1691            .map(|var| {
1692                serde_json::json!({
1693                    "name": var.name,
1694                    "value": var.value,
1695                    "uri": var.uri,
1696                })
1697            })
1698            .collect();
1699        let replacement_count = replacement_data.len();
1700        let message = if replacement_count == 1 {
1701            format!("Consider using {} for this color", replacements[0].name)
1702        } else {
1703            let var_names: Vec<&str> = replacements.iter().map(|v| v.name.as_str()).collect();
1704            format!(
1705                "Consider using one of these variables: {}",
1706                var_names.join(", ")
1707            )
1708        };
1709
1710        diagnostics.push(Diagnostic {
1711            range: occurrence.range,
1712            severity: Some(DiagnosticSeverity::INFORMATION),
1713            code: Some(ls_types::NumberOrString::String(
1714                "css-variable-lsp.literal-color-replaceable".to_string(),
1715            )),
1716            code_description: None,
1717            source: Some("css-variable-lsp".to_string()),
1718            message,
1719            related_information: None,
1720            tags: None,
1721            data: Some(serde_json::json!({
1722                "literal": occurrence.text,
1723                "usageContext": occurrence.usage_context,
1724                "replacements": replacement_data,
1725            })),
1726        });
1727    }
1728
1729    // Update usage maps
1730    {
1731        let mut usage_map = document_usage_map.write().await;
1732        let old_usages = usage_map.insert(uri.clone(), current_usages.clone());
1733
1734        let mut index = usage_index.write().await;
1735
1736        // Remove old usages from index
1737        if let Some(old_set) = old_usages {
1738            for name in old_set {
1739                if !current_usages.contains(&name) {
1740                    if let Some(uris) = index.get_mut(&name) {
1741                        uris.remove(uri);
1742                        if uris.is_empty() {
1743                            index.remove(&name);
1744                        }
1745                    }
1746                }
1747            }
1748        }
1749
1750        // Add new usages to index
1751        for name in current_usages {
1752            index
1753                .entry(name)
1754                .or_insert_with(HashSet::new)
1755                .insert(uri.clone());
1756        }
1757    }
1758
1759    // Update color occurrence URIs index
1760    if let Some(color_uris) = color_occurrence_uris {
1761        let mut color_uris = color_uris.write().await;
1762        if has_literal_colors {
1763            color_uris.insert(uri.clone());
1764        } else {
1765            color_uris.remove(uri);
1766        }
1767    }
1768
1769    client
1770        .publish_diagnostics(uri.clone(), diagnostics, None)
1771        .await;
1772}
1773
1774fn is_var_function_context_slice(before_cursor: &str) -> bool {
1775    let bytes = before_cursor.as_bytes();
1776    if bytes.is_empty() {
1777        return false;
1778    }
1779
1780    let mut i = bytes.len();
1781    if is_word_byte(bytes[i - 1]) {
1782        let mut start = i;
1783        while start > 0 && is_word_byte(bytes[start - 1]) {
1784            start -= 1;
1785        }
1786        if i - start < 2 || bytes[start] != b'-' || bytes[start + 1] != b'-' {
1787            return false;
1788        }
1789        i = start;
1790    }
1791
1792    while i > 0 && bytes[i - 1].is_ascii_whitespace() {
1793        i -= 1;
1794    }
1795
1796    if i == 0 || bytes[i - 1] != b'(' {
1797        return false;
1798    }
1799
1800    let paren_idx = i - 1;
1801    if paren_idx < 3 {
1802        return false;
1803    }
1804    let start = paren_idx - 3;
1805    if !bytes[start..paren_idx].eq_ignore_ascii_case(b"var") {
1806        return false;
1807    }
1808    if start == 0 {
1809        return true;
1810    }
1811    !is_word_byte(bytes[start - 1])
1812}
1813
1814#[cfg(test)]
1815mod tests {
1816    use super::*;
1817    use std::str::FromStr;
1818
1819    fn test_word_extraction(css: &str, cursor_pos: usize) -> Option<String> {
1820        use ls_types::Position;
1821        let position = Position {
1822            line: 0,
1823            character: cursor_pos as u32,
1824        };
1825
1826        let offset = position_to_offset(css, position)?;
1827        let offset = clamp_to_char_boundary(css, offset);
1828        let before = &css[..offset];
1829        let after = &css[offset..];
1830
1831        let left = before
1832            .rsplit(|c: char| !is_word_char(c))
1833            .next()
1834            .unwrap_or("");
1835        let right = after.split(|c: char| !is_word_char(c)).next().unwrap_or("");
1836        let word = format!("{}{}", left, right);
1837        if word.starts_with("--") {
1838            Some(word)
1839        } else {
1840            None
1841        }
1842    }
1843
1844    #[test]
1845    fn test_word_extraction_preserves_fallbacks() {
1846        // Test extraction of variable name from var() call with fallback
1847        let css = "background: var(--primary-color, blue);";
1848        let result = test_word_extraction(css, 20); // cursor on 'p' in --primary-color
1849        assert_eq!(result, Some("--primary-color".to_string()));
1850
1851        // Test that fallback is not included
1852        let css2 = "color: var(--secondary-color, #ccc);";
1853        let result2 = test_word_extraction(css2, 15); // cursor on 's' in --secondary-color
1854        assert_eq!(result2, Some("--secondary-color".to_string()));
1855
1856        // Test nested fallback - should still extract the main variable
1857        let css3 = "border: var(--accent-color, var(--fallback, black));";
1858        let result3 = test_word_extraction(css3, 16); // cursor on 'a' in --accent-color
1859        assert_eq!(result3, Some("--accent-color".to_string()));
1860
1861        // Test simple variable without var()
1862        let css4 = "--theme-color: red;";
1863        let result4 = test_word_extraction(css4, 5); // cursor on 't' in --theme-color
1864        assert_eq!(result4, Some("--theme-color".to_string()));
1865
1866        // Test variable at end of line
1867        let css5 = "margin: var(--spacing);";
1868        let result5 = test_word_extraction(css5, 15); // cursor on 's' in --spacing
1869        assert_eq!(result5, Some("--spacing".to_string()));
1870    }
1871
1872    #[test]
1873    fn test_var_function_context_open() {
1874        let text = "color: var(--primary";
1875        assert!(is_var_function_context_slice(text));
1876    }
1877
1878    #[test]
1879    fn test_var_function_context_closed() {
1880        let text = "color: var(--primary);";
1881        assert!(!is_var_function_context_slice(text));
1882    }
1883
1884    #[test]
1885    fn test_var_function_context_nested() {
1886        let text = "color: var(--primary, calc(100% - var(--secondary";
1887        assert!(is_var_function_context_slice(text));
1888    }
1889
1890    #[test]
1891    fn test_var_function_context_after_fallback() {
1892        let text = "color: var(--primary, ";
1893        assert!(!is_var_function_context_slice(text));
1894    }
1895
1896    #[test]
1897    fn test_var_function_context_requires_boundary() {
1898        let text = "navbar(--primary";
1899        assert!(!is_var_function_context_slice(text));
1900    }
1901
1902    #[test]
1903    fn test_var_function_context_case_insensitive() {
1904        let text = "color: VAR(--primary";
1905        assert!(is_var_function_context_slice(text));
1906    }
1907
1908    #[test]
1909    fn test_completion_value_context_slice_css() {
1910        let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
1911        let text = ".card { color: var(";
1912        let position = crate::types::offset_to_position(text, text.len());
1913        let uri = Uri::from_str("file:///styles.css").unwrap();
1914        let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
1915            .expect("expected css slice");
1916        assert_eq!(context.slice, text);
1917        assert!(!context.allow_without_braces);
1918    }
1919
1920    #[test]
1921    fn test_completion_value_context_slice_html_style_attribute() {
1922        let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
1923        let text = r#"<div style="color: var("#;
1924        let position = crate::types::offset_to_position(text, text.len());
1925        let uri = Uri::from_str("file:///index.html").unwrap();
1926        let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
1927            .expect("expected html style attribute slice");
1928        assert_eq!(context.slice, "color: var(");
1929        assert!(context.allow_without_braces);
1930    }
1931
1932    #[test]
1933    fn test_completion_value_context_slice_html_style_block() {
1934        let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
1935        let text = "<style>body { color: var(";
1936        let position = crate::types::offset_to_position(text, text.len());
1937        let uri = Uri::from_str("file:///index.html").unwrap();
1938        let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
1939            .expect("expected html style block slice");
1940        assert_eq!(context.slice, "body { color: var(");
1941        assert!(!context.allow_without_braces);
1942    }
1943
1944    #[test]
1945    fn test_completion_value_context_slice_js_string() {
1946        let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
1947        let text = r#"const css = "color: var("#;
1948        let position = crate::types::offset_to_position(text, text.len());
1949        let uri = Uri::from_str("file:///app.js").unwrap();
1950        let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
1951            .expect("expected js string slice");
1952        assert_eq!(context.slice, "color: var(");
1953        assert!(context.allow_without_braces);
1954    }
1955
1956    #[test]
1957    fn test_completion_value_context_slice_js_non_string() {
1958        let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
1959        let text = "const css = color: var(";
1960        let position = crate::types::offset_to_position(text, text.len());
1961        let uri = Uri::from_str("file:///app.js").unwrap();
1962        assert!(completion_value_context_slice(text, position, None, &uri, &lookup_map).is_none());
1963    }
1964
1965    #[test]
1966    fn test_completion_value_context_slice_unknown() {
1967        let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
1968        let text = "color: var(";
1969        let position = crate::types::offset_to_position(text, text.len());
1970        let uri = Uri::from_str("file:///notes.txt").unwrap();
1971        assert!(completion_value_context_slice(text, position, None, &uri, &lookup_map).is_none());
1972    }
1973
1974    #[test]
1975    fn test_html_style_attribute_slice_open() {
1976        let text = r#"<div style="color: var("#;
1977        let slice = find_html_style_attribute_slice(text).unwrap();
1978        assert_eq!(slice, "color: var(");
1979    }
1980
1981    #[test]
1982    fn test_html_style_attribute_slice_closed() {
1983        let text = r#"<div style="color: red">"#;
1984        assert!(find_html_style_attribute_slice(text).is_none());
1985    }
1986
1987    #[test]
1988    fn test_html_style_block_slice() {
1989        let text = "<style>body { color: var(";
1990        let slice = find_html_style_block_slice(text).unwrap();
1991        assert_eq!(slice, "body { color: var(");
1992    }
1993
1994    #[test]
1995    fn test_js_string_segment_basic() {
1996        let text = r#"const css = \"color: var("#;
1997        let slice = find_js_string_segment(text).unwrap();
1998        assert_eq!(slice, "color: var(");
1999    }
2000
2001    #[test]
2002    fn test_js_string_segment_template_after_expression() {
2003        let text = r#"const css = `color: ${theme}; background: var("#;
2004        let slice = find_js_string_segment(text).unwrap();
2005        assert_eq!(slice, "; background: var(");
2006    }
2007
2008    #[test]
2009    fn test_js_string_segment_template_expression() {
2010        let text = r#"const css = `color: ${theme"#;
2011        assert!(find_js_string_segment(text).is_none());
2012    }
2013
2014    #[test]
2015    fn resolve_document_kind_prefers_language_id() {
2016        let lookup_files = vec!["**/*.custom".to_string()];
2017        let lookup_map = build_lookup_extension_map(&lookup_files);
2018
2019        let kind = resolve_document_kind("file.custom", Some("html"), &lookup_map);
2020        assert_eq!(kind, Some(DocumentKind::Html));
2021    }
2022
2023    #[test]
2024    fn resolve_document_kind_uses_lookup_extensions() {
2025        let lookup_files = vec![
2026            "**/*.{css,scss}".to_string(),
2027            "**/*.vue".to_string(),
2028            "**/*.custom".to_string(),
2029        ];
2030        let lookup_map = build_lookup_extension_map(&lookup_files);
2031
2032        let css_kind = resolve_document_kind("styles.scss", None, &lookup_map);
2033        assert_eq!(css_kind, Some(DocumentKind::Css));
2034
2035        let html_kind = resolve_document_kind("component.vue", None, &lookup_map);
2036        assert_eq!(html_kind, Some(DocumentKind::Html));
2037
2038        let custom_kind = resolve_document_kind("theme.custom", None, &lookup_map);
2039        assert_eq!(custom_kind, Some(DocumentKind::Css));
2040    }
2041
2042    #[test]
2043    fn resolve_document_kind_returns_none_for_unknown() {
2044        let lookup_files = vec!["**/*.css".to_string()];
2045        let lookup_map = build_lookup_extension_map(&lookup_files);
2046
2047        let kind = resolve_document_kind("notes.txt", Some("plaintext"), &lookup_map);
2048        assert_eq!(kind, None);
2049    }
2050}