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