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