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