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