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        // Serialize disk-backed updates with didOpen/didChange/didClose. This
901        // keeps a rapid save or rename sequence from racing an editor update
902        // and leaving the indexes at different document versions.
903        let _document_lifecycle_guard = self.document_lifecycle_lock.lock().await;
904        for change in params.changes {
905            match change.typ {
906                FileChangeType::DELETED => {
907                    self.manager.remove_document(&change.uri).await;
908                }
909                FileChangeType::CREATED | FileChangeType::CHANGED
910                    if !self.is_document_open(&change.uri).await =>
911                {
912                    self.update_document_from_disk(&change.uri).await;
913                }
914                _ => {}
915            }
916        }
917
918        self.validate_all_open_documents().await;
919    }
920
921    async fn did_create_files(&self, params: CreateFilesParams) {
922        let _document_lifecycle_guard = self.document_lifecycle_lock.lock().await;
923        for file in params.files {
924            let uri = match Uri::from_str(&file.uri) {
925                Ok(uri) => uri,
926                Err(_) => continue,
927            };
928            if !self.is_document_open(&uri).await {
929                self.update_document_from_disk(&uri).await;
930            }
931        }
932        self.validate_all_open_documents().await;
933    }
934
935    async fn did_rename_files(&self, params: RenameFilesParams) {
936        let _document_lifecycle_guard = self.document_lifecycle_lock.lock().await;
937        for file in params.files {
938            let old_uri = match Uri::from_str(&file.old_uri) {
939                Ok(uri) => uri,
940                Err(_) => continue,
941            };
942            let new_uri = match Uri::from_str(&file.new_uri) {
943                Ok(uri) => uri,
944                Err(_) => continue,
945            };
946
947            // Remove old document data
948            self.manager.remove_document(&old_uri).await;
949
950            // If the new URI is not open, load it from disk.
951            if !self.is_document_open(&new_uri).await {
952                self.update_document_from_disk(&new_uri).await;
953            }
954        }
955        self.validate_all_open_documents().await;
956    }
957
958    async fn did_delete_files(&self, params: DeleteFilesParams) {
959        let _document_lifecycle_guard = self.document_lifecycle_lock.lock().await;
960        for file in params.files {
961            let uri = match Uri::from_str(&file.uri) {
962                Ok(uri) => uri,
963                Err(_) => continue,
964            };
965            self.manager.remove_document(&uri).await;
966        }
967        self.validate_all_open_documents().await;
968    }
969
970    async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
971        let mut current_paths = {
972            let paths = self.workspace_folder_paths.read().await;
973            paths.clone()
974        };
975
976        let mut removed_paths = Vec::new();
977        for removed in &params.event.removed {
978            if let Some(path) = to_normalized_fs_path(&removed.uri) {
979                current_paths.retain(|p| p != &path);
980                removed_paths.push(path);
981            }
982        }
983
984        let mut added_folders = Vec::new();
985        for added in params.event.added {
986            if let Some(path) = to_normalized_fs_path(&added.uri) {
987                if !current_paths.contains(&path) {
988                    current_paths.push(path);
989                    added_folders.push(added);
990                }
991            }
992        }
993
994        current_paths.sort_by_key(|b| std::cmp::Reverse(b.to_string_lossy().len()));
995
996        {
997            let mut stored = self.workspace_folder_paths.write().await;
998            *stored = current_paths.clone();
999        }
1000
1001        if !removed_paths.is_empty() {
1002            let open_uris: HashSet<Uri> = self.document_map.read().await.keys().cloned().collect();
1003            let stale_uris: HashSet<Uri> = self
1004                .manager
1005                .get_document_uris()
1006                .await
1007                .into_iter()
1008                .filter(|uri| !open_uris.contains(uri))
1009                .filter(|uri| {
1010                    let Some(path) = to_normalized_fs_path(uri) else {
1011                        return false;
1012                    };
1013                    removed_paths
1014                        .iter()
1015                        .any(|removed| path.starts_with(removed))
1016                        && !current_paths
1017                            .iter()
1018                            .any(|current| path.starts_with(current))
1019                })
1020                .collect();
1021            self.manager.remove_documents(&stale_uris).await;
1022        }
1023
1024        if added_folders.is_empty() {
1025            self.validate_all_open_documents().await;
1026        } else {
1027            self.scan_workspace_folders(added_folders).await;
1028        }
1029    }
1030
1031    async fn completion(
1032        &self,
1033        params: CompletionParams,
1034    ) -> tower_lsp_server::jsonrpc::Result<Option<CompletionResponse>> {
1035        let uri = params.text_document_position.text_document.uri;
1036        let position = params.text_document_position.position;
1037
1038        let text = {
1039            let docs = self.document_map.read().await;
1040            docs.get(&uri).cloned()
1041        };
1042        let text = match text {
1043            Some(text) => text,
1044            None => return Ok(Some(CompletionResponse::Array(Vec::new()))),
1045        };
1046
1047        let language_id = {
1048            let langs = self.document_language_map.read().await;
1049            langs.get(&uri).cloned()
1050        };
1051        let lookup_map = self.lookup_extension_map.read().await.clone();
1052        let context = completion_value_context_slice(
1053            &text,
1054            position,
1055            language_id.as_deref(),
1056            &uri,
1057            &lookup_map,
1058        );
1059        let context = match context {
1060            Some(context) => context,
1061            None => return Ok(Some(CompletionResponse::Array(Vec::new()))),
1062        };
1063        let value_context = get_value_context_info(context.slice, context.allow_without_braces);
1064        if !value_context.is_value_context {
1065            return Ok(Some(CompletionResponse::Array(Vec::new())));
1066        }
1067        let property_name = value_context.property_name;
1068        let in_var_context = is_var_function_context_slice(context.slice);
1069        let workspace_folder_paths = self.workspace_folder_paths.read().await.clone();
1070        let root_folder_path = self.root_folder_path.read().await.clone();
1071
1072        if !in_var_context {
1073            if let Some(color_key) = self.literal_color_under_cursor(&uri, position).await {
1074                let variables = self.manager.get_variables_by_color_key(&color_key).await;
1075                let items = variables
1076                    .into_iter()
1077                    .map(|var| {
1078                        let options = PathDisplayOptions {
1079                            mode: self.runtime_config.path_display_mode,
1080                            abbrev_length: self.runtime_config.path_display_abbrev_length,
1081                            workspace_folder_paths: &workspace_folder_paths,
1082                            root_folder_path: root_folder_path.as_ref(),
1083                        };
1084                        let display_path = format_uri_for_display(&var.uri, options);
1085                        CompletionItem {
1086                            label: var.name.clone(),
1087                            kind: Some(CompletionItemKind::VARIABLE),
1088                            detail: Some(format!(
1089                                "{} • {}",
1090                                variable_completion_detail(&var),
1091                                display_path
1092                            )),
1093                            insert_text: Some(format!("var({})", var.name)),
1094                            documentation: Some(ls_types::Documentation::String(
1095                                variable_completion_documentation(&var, &display_path),
1096                            )),
1097                            ..Default::default()
1098                        }
1099                    })
1100                    .collect();
1101                return Ok(Some(CompletionResponse::Array(items)));
1102            }
1103        }
1104
1105        let variables = self.manager.get_all_variables().await;
1106
1107        let mut unique_vars = HashMap::new();
1108        for var in variables {
1109            unique_vars.entry(var.name.clone()).or_insert(var);
1110        }
1111
1112        let mut scored_vars: Vec<(i32, _)> = unique_vars
1113            .values()
1114            .map(|var| {
1115                let score = score_variable_relevance(&var.name, property_name.as_deref());
1116                (score, var)
1117            })
1118            .collect();
1119
1120        scored_vars.retain(|(score, _)| *score != 0);
1121        scored_vars.sort_by(|(score_a, var_a), (score_b, var_b)| {
1122            if score_a != score_b {
1123                return score_b.cmp(score_a);
1124            }
1125            var_a.name.cmp(&var_b.name)
1126        });
1127
1128        let items = scored_vars
1129            .into_iter()
1130            .map(|(_, var)| {
1131                let options = PathDisplayOptions {
1132                    mode: self.runtime_config.path_display_mode,
1133                    abbrev_length: self.runtime_config.path_display_abbrev_length,
1134                    workspace_folder_paths: &workspace_folder_paths,
1135                    root_folder_path: root_folder_path.as_ref(),
1136                };
1137                let insert_text = if in_var_context {
1138                    var.name.clone()
1139                } else {
1140                    format!("var({})", var.name)
1141                };
1142                CompletionItem {
1143                    label: var.name.clone(),
1144                    kind: Some(CompletionItemKind::VARIABLE),
1145                    detail: Some(variable_completion_detail(var)),
1146                    insert_text: Some(insert_text),
1147                    documentation: Some(ls_types::Documentation::String(
1148                        variable_completion_documentation(
1149                            var,
1150                            &format_uri_for_display(&var.uri, options),
1151                        ),
1152                    )),
1153                    ..Default::default()
1154                }
1155            })
1156            .collect();
1157
1158        Ok(Some(CompletionResponse::Array(items)))
1159    }
1160
1161    async fn completion_resolve(
1162        &self,
1163        item: CompletionItem,
1164    ) -> tower_lsp_server::jsonrpc::Result<CompletionItem> {
1165        Ok(item)
1166    }
1167
1168    async fn hover(&self, params: HoverParams) -> tower_lsp_server::jsonrpc::Result<Option<Hover>> {
1169        let uri = params.text_document_position_params.text_document.uri;
1170        let position = params.text_document_position_params.position;
1171
1172        let text = {
1173            let docs = self.document_map.read().await;
1174            docs.get(&uri).cloned()
1175        };
1176        let text = match text {
1177            Some(text) => text,
1178            None => return Ok(None),
1179        };
1180
1181        let word = match self.get_word_at_position(&text, position) {
1182            Some(word) => word,
1183            None => return Ok(None),
1184        };
1185
1186        let mut definitions = self.manager.get_variables(&word).await;
1187        if definitions.is_empty() {
1188            return Ok(None);
1189        }
1190
1191        let usages = self.manager.get_usages(&word).await;
1192        let offset = match position_to_offset(&text, position) {
1193            Some(offset) => offset,
1194            None => return Ok(None),
1195        };
1196        let hover_usage = usages.iter().find(|usage| {
1197            if usage.uri != uri {
1198                return false;
1199            }
1200            let start = position_to_offset(&text, usage.range.start).unwrap_or(0);
1201            let end = position_to_offset(&text, usage.range.end).unwrap_or(0);
1202            offset >= start && offset <= end
1203        });
1204
1205        let usage_context = hover_usage
1206            .map(|u| u.usage_context.clone())
1207            .unwrap_or_default();
1208        let is_inline_style = usage_context == "inline-style";
1209        let dom_tree = self.manager.get_dom_tree(&uri).await;
1210        let dom_node = hover_usage.and_then(|u| u.dom_node.clone());
1211
1212        sort_by_cascade(&mut definitions);
1213
1214        let mut hover_text = format!("### CSS Variable: `{}`\n\n", word);
1215
1216        if definitions.len() == 1 {
1217            let var = &definitions[0];
1218            if !var.value.is_empty() {
1219                hover_text.push_str(&format!("**Value:** `{}`", var.value));
1220                if var.important {
1221                    hover_text.push_str(" **!important**");
1222                }
1223                hover_text.push_str("\n\n");
1224            }
1225            if let Some(source) = variable_source_label(var) {
1226                hover_text.push_str(&format!("**Generated by:** {}\n\n", source));
1227            }
1228            if !var.selector.is_empty() {
1229                let scope_label = if variable_source_label(var).is_some() {
1230                    "CSS scope"
1231                } else {
1232                    "Defined in"
1233                };
1234                hover_text.push_str(&format!("**{}:** `{}`\n", scope_label, var.selector));
1235                hover_text.push_str(&format!(
1236                    "**Specificity:** {}\n",
1237                    format_specificity(calculate_specificity(&var.selector))
1238                ));
1239            }
1240        } else {
1241            hover_text.push_str("**Definitions** (CSS cascade order):\n\n");
1242
1243            for (idx, var) in definitions.iter().enumerate() {
1244                let spec = calculate_specificity(&var.selector);
1245                let is_applicable = if usage_context.is_empty() {
1246                    true
1247                } else {
1248                    matches_context(
1249                        &var.selector,
1250                        &usage_context,
1251                        dom_tree.as_ref(),
1252                        dom_node.as_ref(),
1253                    )
1254                };
1255                let is_winner = idx == 0 && (is_applicable || is_inline_style);
1256
1257                let mut line = if var.value.is_empty() {
1258                    format!("{}.", idx + 1)
1259                } else {
1260                    format!("{}. `{}`", idx + 1, var.value)
1261                };
1262                if let Some(source) = variable_source_label(var) {
1263                    line.push_str(&format!(" generated by {}", source));
1264                }
1265                if var.important {
1266                    line.push_str(" **!important**");
1267                }
1268                if !var.selector.is_empty() {
1269                    line.push_str(&format!(
1270                        " from `{}` {}",
1271                        var.selector,
1272                        format_specificity(spec)
1273                    ));
1274                }
1275
1276                if is_winner && !usage_context.is_empty() {
1277                    if var.important {
1278                        line.push_str(" ✓ **Wins (!important)**");
1279                    } else if is_inline_style {
1280                        line.push_str(" ✓ **Would apply (inline style)**");
1281                    } else if dom_tree.is_some() && dom_node.is_some() {
1282                        line.push_str(" ✓ **Applies (DOM match)**");
1283                    } else {
1284                        line.push_str(" ✓ **Applies here**");
1285                    }
1286                } else if !is_applicable && !usage_context.is_empty() && !is_inline_style {
1287                    line.push_str(" _(selector doesn't match)_");
1288                } else if idx > 0 && !usage_context.is_empty() {
1289                    let winner = &definitions[0];
1290                    if winner.important && !var.important {
1291                        line.push_str(" _(overridden by !important)_");
1292                    } else {
1293                        let winner_spec = calculate_specificity(&winner.selector);
1294                        let cmp = compare_specificity(winner_spec, spec);
1295                        if cmp > 0 {
1296                            line.push_str(" _(lower specificity)_");
1297                        } else if cmp == 0 {
1298                            line.push_str(" _(earlier in source)_");
1299                        }
1300                    }
1301                }
1302
1303                hover_text.push_str(&line);
1304                hover_text.push('\n');
1305            }
1306
1307            if !usage_context.is_empty() {
1308                if is_inline_style {
1309                    hover_text.push_str("\n_Context: Inline style (highest priority)_");
1310                } else if dom_tree.is_some() && dom_node.is_some() {
1311                    hover_text.push_str(&format!(
1312                        "\n_Context: `{}` (DOM-aware matching)_",
1313                        usage_context
1314                    ));
1315                } else {
1316                    hover_text.push_str(&format!("\n_Context: `{}`_", usage_context));
1317                }
1318            }
1319        }
1320
1321        Ok(Some(Hover {
1322            contents: HoverContents::Markup(MarkupContent {
1323                kind: MarkupKind::Markdown,
1324                value: hover_text,
1325            }),
1326            range: None,
1327        }))
1328    }
1329
1330    async fn goto_definition(
1331        &self,
1332        params: GotoDefinitionParams,
1333    ) -> tower_lsp_server::jsonrpc::Result<Option<GotoDefinitionResponse>> {
1334        let uri = params.text_document_position_params.text_document.uri;
1335        let position = params.text_document_position_params.position;
1336
1337        let text = {
1338            let docs = self.document_map.read().await;
1339            docs.get(&uri).cloned()
1340        };
1341        let text = match text {
1342            Some(text) => text,
1343            None => return Ok(None),
1344        };
1345
1346        let word = match self.get_word_at_position(&text, position) {
1347            Some(word) => word,
1348            None => return Ok(None),
1349        };
1350
1351        let definitions = self.manager.get_variables(&word).await;
1352        let first = match definitions.first() {
1353            Some(def) => def,
1354            None => return Ok(None),
1355        };
1356
1357        Ok(Some(GotoDefinitionResponse::Scalar(Location::new(
1358            first.uri.clone(),
1359            first.range,
1360        ))))
1361    }
1362
1363    async fn code_action(
1364        &self,
1365        params: CodeActionParams,
1366    ) -> tower_lsp_server::jsonrpc::Result<Option<CodeActionResponse>> {
1367        let uri = params.text_document.uri;
1368        let text = {
1369            let docs = self.document_map.read().await;
1370            docs.get(&uri).cloned()
1371        };
1372        let text = match text {
1373            Some(text) => text,
1374            None => return Ok(Some(Vec::new())),
1375        };
1376
1377        let mut actions: Vec<CodeActionOrCommand> = Vec::new();
1378
1379        let language_id = {
1380            let languages = self.document_language_map.read().await;
1381            languages.get(&uri).cloned()
1382        };
1383        let lookup_map = self.lookup_extension_map.read().await.clone();
1384        let allow_create_definition = matches!(
1385            resolve_document_kind(uri.path().as_str(), language_id.as_deref(), &lookup_map),
1386            Some(DocumentKind::Css)
1387        );
1388
1389        // 1) Quick-fix undefined variable diagnostics.
1390        actions.extend(code_actions_for_undefined_variables(
1391            &uri,
1392            &text,
1393            &params.context,
1394            &self.runtime_config,
1395            allow_create_definition,
1396        ));
1397        actions.extend(code_actions_for_replaceable_literal_colors(
1398            &uri,
1399            &params.context,
1400            &self.runtime_config,
1401        ));
1402
1403        Ok(Some(actions))
1404    }
1405
1406    async fn references(
1407        &self,
1408        params: ReferenceParams,
1409    ) -> tower_lsp_server::jsonrpc::Result<Option<Vec<Location>>> {
1410        let uri = params.text_document_position.text_document.uri;
1411        let position = params.text_document_position.position;
1412
1413        let text = {
1414            let docs = self.document_map.read().await;
1415            docs.get(&uri).cloned()
1416        };
1417        let text = match text {
1418            Some(text) => text,
1419            None => return Ok(None),
1420        };
1421
1422        let word = match self.get_word_at_position(&text, position) {
1423            Some(word) => word,
1424            None => return Ok(None),
1425        };
1426
1427        let (definitions, usages) = self.manager.get_references(&word).await;
1428        let mut locations = Vec::new();
1429        for def in definitions {
1430            locations.push(Location::new(def.uri, def.range));
1431        }
1432        for usage in usages {
1433            locations.push(Location::new(usage.uri, usage.range));
1434        }
1435
1436        Ok(Some(locations))
1437    }
1438
1439    async fn document_color(
1440        &self,
1441        params: DocumentColorParams,
1442    ) -> tower_lsp_server::jsonrpc::Result<Vec<ColorInformation>> {
1443        let config = self.manager.get_config().await;
1444        if !config.enable_color_provider {
1445            return Ok(Vec::new());
1446        }
1447
1448        let uri = params.text_document.uri;
1449        let text = {
1450            let docs = self.document_map.read().await;
1451            docs.get(&uri).cloned()
1452        };
1453        let text = match text {
1454            Some(text) => text,
1455            None => return Ok(Vec::new()),
1456        };
1457
1458        let mut colors = Vec::new();
1459        let mut seen_ranges: HashSet<(u32, u32, u32, u32)> = HashSet::new();
1460        let range_key = |range: &Range| {
1461            (
1462                range.start.line,
1463                range.start.character,
1464                range.end.line,
1465                range.end.character,
1466            )
1467        };
1468
1469        if !config.color_only_on_variables {
1470            let definitions = self.manager.get_document_variables(&uri).await;
1471            for def in definitions {
1472                if let Some(color) = parse_color(&def.value) {
1473                    if let Some(value_range) = def.value_range {
1474                        if seen_ranges.insert(range_key(&value_range)) {
1475                            colors.push(ColorInformation {
1476                                range: value_range,
1477                                color,
1478                            });
1479                        }
1480                    } else if let Some(range) = find_value_range_in_definition(&text, &def) {
1481                        if seen_ranges.insert(range_key(&range)) {
1482                            colors.push(ColorInformation { range, color });
1483                        }
1484                    }
1485                }
1486            }
1487        }
1488
1489        let usages = self.manager.get_document_usages(&uri).await;
1490        for usage in usages {
1491            if let Some(color) = self.manager.resolve_variable_color(&usage.name).await {
1492                if seen_ranges.insert(range_key(&usage.range)) {
1493                    colors.push(ColorInformation {
1494                        range: usage.range,
1495                        color,
1496                    });
1497                }
1498            }
1499        }
1500
1501        for caps in self.var_usage_regex.captures_iter(&text) {
1502            let match_all = caps.get(0).unwrap();
1503            let var_name = caps.get(1).unwrap().as_str();
1504            let range = Range::new(
1505                crate::types::offset_to_position(&text, match_all.start()),
1506                crate::types::offset_to_position(&text, match_all.end()),
1507            );
1508            if !seen_ranges.insert(range_key(&range)) {
1509                continue;
1510            }
1511            if let Some(color) = self.manager.resolve_variable_color(var_name).await {
1512                colors.push(ColorInformation { range, color });
1513            }
1514        }
1515
1516        Ok(colors)
1517    }
1518
1519    async fn color_presentation(
1520        &self,
1521        params: ColorPresentationParams,
1522    ) -> tower_lsp_server::jsonrpc::Result<Vec<ColorPresentation>> {
1523        if !self.runtime_config.enable_color_provider {
1524            return Ok(Vec::new());
1525        }
1526        Ok(generate_color_presentations(params.color, params.range))
1527    }
1528
1529    async fn prepare_rename(
1530        &self,
1531        params: TextDocumentPositionParams,
1532    ) -> tower_lsp_server::jsonrpc::Result<Option<PrepareRenameResponse>> {
1533        let uri = params.text_document.uri;
1534        let position = params.position;
1535
1536        let text = {
1537            let docs = self.document_map.read().await;
1538            docs.get(&uri).cloned()
1539        };
1540        let text = match text {
1541            Some(text) => text,
1542            None => return Ok(None),
1543        };
1544
1545        let (_, range) = match self.get_word_and_range_at_position(&text, position) {
1546            Some(word_and_range) => word_and_range,
1547            None => return Ok(None),
1548        };
1549
1550        Ok(Some(PrepareRenameResponse::Range(range)))
1551    }
1552
1553    async fn rename(
1554        &self,
1555        params: RenameParams,
1556    ) -> tower_lsp_server::jsonrpc::Result<Option<WorkspaceEdit>> {
1557        let uri = params.text_document_position.text_document.uri;
1558        let position = params.text_document_position.position;
1559        let new_name = params.new_name;
1560
1561        let text = {
1562            let docs = self.document_map.read().await;
1563            docs.get(&uri).cloned()
1564        };
1565        let text = match text {
1566            Some(text) => text,
1567            None => return Ok(None),
1568        };
1569
1570        let old_name = match self.get_word_at_position(&text, position) {
1571            Some(word) => word,
1572            None => return Ok(None),
1573        };
1574
1575        let (definitions, usages) = self.manager.get_references(&old_name).await;
1576        let mut changes: HashMap<Uri, Vec<TextEdit>> = HashMap::new();
1577
1578        for def in definitions {
1579            let range = def.name_range.unwrap_or(def.range);
1580            changes.entry(def.uri.clone()).or_default().push(TextEdit {
1581                range,
1582                new_text: new_name.clone(),
1583            });
1584        }
1585
1586        for usage in usages {
1587            let range = usage.name_range.unwrap_or(usage.range);
1588            changes
1589                .entry(usage.uri.clone())
1590                .or_default()
1591                .push(TextEdit {
1592                    range,
1593                    new_text: new_name.clone(),
1594                });
1595        }
1596
1597        Ok(Some(WorkspaceEdit {
1598            changes: Some(changes),
1599            document_changes: None,
1600            change_annotations: None,
1601        }))
1602    }
1603
1604    #[allow(deprecated)]
1605    async fn document_symbol(
1606        &self,
1607        params: DocumentSymbolParams,
1608    ) -> tower_lsp_server::jsonrpc::Result<Option<DocumentSymbolResponse>> {
1609        let vars = self
1610            .manager
1611            .get_document_variables(&params.text_document.uri)
1612            .await;
1613        let symbols: Vec<DocumentSymbol> = vars
1614            .into_iter()
1615            .map(|var| {
1616                let detail = variable_completion_detail(&var);
1617                DocumentSymbol {
1618                    name: var.name,
1619                    detail: Some(detail),
1620                    kind: SymbolKind::VARIABLE,
1621                    tags: None,
1622                    deprecated: None,
1623                    range: var.range,
1624                    selection_range: var.range,
1625                    children: None,
1626                }
1627            })
1628            .collect();
1629
1630        Ok(Some(DocumentSymbolResponse::Nested(symbols)))
1631    }
1632
1633    #[allow(deprecated)]
1634    async fn symbol(
1635        &self,
1636        params: WorkspaceSymbolParams,
1637    ) -> tower_lsp_server::jsonrpc::Result<Option<WorkspaceSymbolResponse>> {
1638        let query = params.query.to_lowercase();
1639        let vars = self.manager.get_all_variables().await;
1640        let mut symbols = Vec::new();
1641
1642        for var in vars {
1643            if !query.is_empty() && !var.name.to_lowercase().contains(&query) {
1644                continue;
1645            }
1646            symbols.push(SymbolInformation {
1647                name: var.name,
1648                kind: SymbolKind::VARIABLE,
1649                tags: None,
1650                deprecated: None,
1651                location: Location::new(var.uri.clone(), var.range),
1652                container_name: None,
1653            });
1654        }
1655
1656        Ok(Some(WorkspaceSymbolResponse::Flat(symbols)))
1657    }
1658}
1659
1660impl CssVariableLsp {
1661    async fn literal_color_under_cursor(
1662        &self,
1663        uri: &Uri,
1664        position: Position,
1665    ) -> Option<NormalizedColorKey> {
1666        let occurrences = self
1667            .manager
1668            .get_literal_colors_at_position(uri, position)
1669            .await;
1670        occurrences
1671            .into_iter()
1672            .find(|occurrence| range_contains_position(&occurrence.range, position))
1673            .map(|occurrence| occurrence.normalized_color)
1674    }
1675
1676    /// Scan workspace folders for CSS and HTML files
1677    pub async fn scan_workspace_folders(&self, folders: Vec<WorkspaceFolder>) {
1678        let folder_uris: Vec<Uri> = folders.iter().map(|f| f.uri.clone()).collect();
1679
1680        self.client
1681            .log_message(
1682                MessageType::INFO,
1683                format!("Scanning {} workspace folders...", folder_uris.len()),
1684            )
1685            .await;
1686
1687        let manager = self.manager.clone();
1688        let client = self.client.clone();
1689
1690        let mut last_logged_percentage = 0;
1691        let result = crate::workspace::scan_workspace(folder_uris, &manager, |current, total| {
1692            if total == 0 {
1693                return;
1694            }
1695            let percentage = ((current as f64 / total as f64) * 100.0).round() as i32;
1696            if percentage - last_logged_percentage >= 20 || current == total {
1697                last_logged_percentage = percentage;
1698                let client = client.clone();
1699                tokio::spawn(async move {
1700                    client
1701                        .log_message(
1702                            MessageType::INFO,
1703                            format!(
1704                                "Scanning CSS files: {}/{} ({}%)",
1705                                current, total, percentage
1706                            ),
1707                        )
1708                        .await;
1709                });
1710            }
1711        })
1712        .await;
1713
1714        match result {
1715            Ok(_) => {
1716                let total_vars = manager.get_all_variables().await.len();
1717                self.client
1718                    .log_message(
1719                        MessageType::INFO,
1720                        format!(
1721                            "Workspace scan complete. Found {} CSS variables.",
1722                            total_vars
1723                        ),
1724                    )
1725                    .await;
1726            }
1727            Err(e) => {
1728                self.client
1729                    .log_message(MessageType::ERROR, format!("Workspace scan failed: {}", e))
1730                    .await;
1731            }
1732        }
1733
1734        // A disk scan can encounter an open document. Restore its in-memory text so
1735        // unsaved editor contents remain authoritative after any initial or repeat scan.
1736        let _document_lifecycle_guard = self.document_lifecycle_lock.lock().await;
1737        let open_documents = {
1738            let documents = self.document_map.read().await;
1739            let languages = self.document_language_map.read().await;
1740            documents
1741                .iter()
1742                .map(|(uri, text)| (uri.clone(), text.clone(), languages.get(uri).cloned()))
1743                .collect::<Vec<_>>()
1744        };
1745        for (uri, text, language_id) in open_documents {
1746            let version = if is_supported_config_path(std::path::Path::new(uri.path().as_str())) {
1747                self.manager.get_document_version(&uri).await
1748            } else {
1749                None
1750            };
1751            self.parse_document_text(&uri, &text, language_id.as_deref(), version)
1752                .await;
1753        }
1754
1755        self.validate_all_open_documents().await;
1756    }
1757}
1758
1759#[allow(clippy::too_many_arguments)]
1760async fn validate_document_text_with(
1761    client: &Client,
1762    manager: &CssVariableManager,
1763    usage_regex: &Regex,
1764    undefined_var_fallback: UndefinedVarFallbackMode,
1765    has_related_info: bool,
1766    uri: &Uri,
1767    text: &str,
1768    document_usage_map: &Arc<RwLock<HashMap<Uri, HashSet<String>>>>,
1769    usage_index: &Arc<RwLock<HashMap<String, HashSet<Uri>>>>,
1770    color_occurrence_uris: Option<&Arc<RwLock<HashSet<Uri>>>>,
1771) {
1772    let mut diagnostics = Vec::new();
1773    let mut current_usages = HashSet::new();
1774    let mut has_literal_colors = false;
1775    let default_severity = DiagnosticSeverity::WARNING;
1776
1777    for captures in usage_regex.captures_iter(text) {
1778        let match_all = captures.get(0).unwrap();
1779        let name = captures.get(1).unwrap().as_str();
1780        let fallback = captures.get(2).map(|m| m.as_str()).unwrap_or("");
1781        let has_fallback = !fallback.trim().is_empty();
1782
1783        current_usages.insert(name.to_string());
1784
1785        let definitions = manager.get_variables(name).await;
1786        if !definitions.is_empty() {
1787            continue;
1788        }
1789
1790        let severity = if has_fallback {
1791            match undefined_var_fallback {
1792                UndefinedVarFallbackMode::Warning => Some(default_severity),
1793                UndefinedVarFallbackMode::Info => Some(DiagnosticSeverity::INFORMATION),
1794                UndefinedVarFallbackMode::Off => None,
1795            }
1796        } else {
1797            Some(default_severity)
1798        };
1799        let severity = match severity {
1800            Some(severity) => severity,
1801            None => continue,
1802        };
1803        let range = Range::new(
1804            crate::types::offset_to_position(text, match_all.start()),
1805            crate::types::offset_to_position(text, match_all.end()),
1806        );
1807        diagnostics.push(Diagnostic {
1808            range,
1809            severity: Some(severity),
1810            code: Some(ls_types::NumberOrString::String(
1811                "css-variable-lsp.undefined-variable".to_string(),
1812            )),
1813            code_description: None,
1814            source: Some("css-variable-lsp".to_string()),
1815            message: format!("CSS variable '{}' is not defined in the workspace", name),
1816            related_information: if has_related_info {
1817                Some(Vec::new())
1818            } else {
1819                None
1820            },
1821            tags: None,
1822            data: Some(serde_json::json!({
1823                "name": name,
1824                "hasFallback": has_fallback,
1825                "range": {
1826                    "start": { "line": range.start.line, "character": range.start.character },
1827                    "end": { "line": range.end.line, "character": range.end.character }
1828                }
1829            })),
1830        });
1831    }
1832
1833    for occurrence in manager.get_document_literal_colors(uri).await {
1834        let all_replacements = manager
1835            .get_variables_by_color_key(&occurrence.normalized_color)
1836            .await;
1837
1838        // Filter out replacement variables where this color is in their own value.
1839        // This prevents suggesting replacing #ffd166 with --accent-2 when editing
1840        // the definition: --accent-2: #ffd166;
1841        let replacements: Vec<_> = all_replacements
1842            .iter()
1843            .filter(|var| {
1844                // Keep if the variable is in a different file
1845                if var.uri != *uri {
1846                    return true;
1847                }
1848                // Keep if no value_range available (can't determine overlap)
1849                let Some(value_range) = &var.value_range else {
1850                    return true;
1851                };
1852                // Filter out if occurrence is within this variable's value range
1853                !range_contains(value_range, &occurrence.range)
1854            })
1855            .cloned()
1856            .collect();
1857
1858        if replacements.is_empty() {
1859            continue;
1860        }
1861
1862        // Track that this document has literal color occurrences
1863        has_literal_colors = true;
1864
1865        let replacement_data: Vec<_> = replacements
1866            .iter()
1867            .map(|var| {
1868                serde_json::json!({
1869                    "name": var.name,
1870                    "value": var.value,
1871                    "uri": var.uri,
1872                })
1873            })
1874            .collect();
1875        let replacement_count = replacement_data.len();
1876        let message = if replacement_count == 1 {
1877            format!("Consider using {} for this color", replacements[0].name)
1878        } else {
1879            let var_names: Vec<&str> = replacements.iter().map(|v| v.name.as_str()).collect();
1880            format!(
1881                "Consider using one of these variables: {}",
1882                var_names.join(", ")
1883            )
1884        };
1885
1886        diagnostics.push(Diagnostic {
1887            range: occurrence.range,
1888            severity: Some(DiagnosticSeverity::INFORMATION),
1889            code: Some(ls_types::NumberOrString::String(
1890                "css-variable-lsp.literal-color-replaceable".to_string(),
1891            )),
1892            code_description: None,
1893            source: Some("css-variable-lsp".to_string()),
1894            message,
1895            related_information: None,
1896            tags: None,
1897            data: Some(serde_json::json!({
1898                "literal": occurrence.text,
1899                "usageContext": occurrence.usage_context,
1900                "replacements": replacement_data,
1901            })),
1902        });
1903    }
1904
1905    // Update usage maps
1906    {
1907        let mut usage_map = document_usage_map.write().await;
1908        let old_usages = usage_map.insert(uri.clone(), current_usages.clone());
1909
1910        let mut index = usage_index.write().await;
1911
1912        // Remove old usages from index
1913        if let Some(old_set) = old_usages {
1914            for name in old_set {
1915                if !current_usages.contains(&name) {
1916                    if let Some(uris) = index.get_mut(&name) {
1917                        uris.remove(uri);
1918                        if uris.is_empty() {
1919                            index.remove(&name);
1920                        }
1921                    }
1922                }
1923            }
1924        }
1925
1926        // Add new usages to index
1927        for name in current_usages {
1928            index
1929                .entry(name)
1930                .or_insert_with(HashSet::new)
1931                .insert(uri.clone());
1932        }
1933    }
1934
1935    // Update color occurrence URIs index
1936    if let Some(color_uris) = color_occurrence_uris {
1937        let mut color_uris = color_uris.write().await;
1938        if has_literal_colors {
1939            color_uris.insert(uri.clone());
1940        } else {
1941            color_uris.remove(uri);
1942        }
1943    }
1944
1945    client
1946        .publish_diagnostics(uri.clone(), diagnostics, None)
1947        .await;
1948}
1949
1950fn is_var_function_context_slice(before_cursor: &str) -> bool {
1951    let bytes = before_cursor.as_bytes();
1952    if bytes.is_empty() {
1953        return false;
1954    }
1955
1956    let mut i = bytes.len();
1957    if is_word_byte(bytes[i - 1]) {
1958        let mut start = i;
1959        while start > 0 && is_word_byte(bytes[start - 1]) {
1960            start -= 1;
1961        }
1962        if i - start < 2 || bytes[start] != b'-' || bytes[start + 1] != b'-' {
1963            return false;
1964        }
1965        i = start;
1966    }
1967
1968    while i > 0 && bytes[i - 1].is_ascii_whitespace() {
1969        i -= 1;
1970    }
1971
1972    if i == 0 || bytes[i - 1] != b'(' {
1973        return false;
1974    }
1975
1976    let paren_idx = i - 1;
1977    if paren_idx < 3 {
1978        return false;
1979    }
1980    let start = paren_idx - 3;
1981    if !bytes[start..paren_idx].eq_ignore_ascii_case(b"var") {
1982        return false;
1983    }
1984    if start == 0 {
1985        return true;
1986    }
1987    !is_word_byte(bytes[start - 1])
1988}
1989
1990#[cfg(test)]
1991mod tests {
1992    use super::*;
1993    use std::str::FromStr;
1994
1995    fn test_word_extraction(css: &str, cursor_pos: usize) -> Option<String> {
1996        use ls_types::Position;
1997        let position = Position {
1998            line: 0,
1999            character: cursor_pos as u32,
2000        };
2001
2002        let offset = position_to_offset(css, position)?;
2003        let offset = clamp_to_char_boundary(css, offset);
2004        let before = &css[..offset];
2005        let after = &css[offset..];
2006
2007        let left = before
2008            .rsplit(|c: char| !is_word_char(c))
2009            .next()
2010            .unwrap_or("");
2011        let right = after.split(|c: char| !is_word_char(c)).next().unwrap_or("");
2012        let word = format!("{}{}", left, right);
2013        if word.starts_with("--") {
2014            Some(word)
2015        } else {
2016            None
2017        }
2018    }
2019
2020    #[test]
2021    fn test_word_extraction_preserves_fallbacks() {
2022        // Test extraction of variable name from var() call with fallback
2023        let css = "background: var(--primary-color, blue);";
2024        let result = test_word_extraction(css, 20); // cursor on 'p' in --primary-color
2025        assert_eq!(result, Some("--primary-color".to_string()));
2026
2027        // Test that fallback is not included
2028        let css2 = "color: var(--secondary-color, #ccc);";
2029        let result2 = test_word_extraction(css2, 15); // cursor on 's' in --secondary-color
2030        assert_eq!(result2, Some("--secondary-color".to_string()));
2031
2032        // Test nested fallback - should still extract the main variable
2033        let css3 = "border: var(--accent-color, var(--fallback, black));";
2034        let result3 = test_word_extraction(css3, 16); // cursor on 'a' in --accent-color
2035        assert_eq!(result3, Some("--accent-color".to_string()));
2036
2037        // Test simple variable without var()
2038        let css4 = "--theme-color: red;";
2039        let result4 = test_word_extraction(css4, 5); // cursor on 't' in --theme-color
2040        assert_eq!(result4, Some("--theme-color".to_string()));
2041
2042        // Test variable at end of line
2043        let css5 = "margin: var(--spacing);";
2044        let result5 = test_word_extraction(css5, 15); // cursor on 's' in --spacing
2045        assert_eq!(result5, Some("--spacing".to_string()));
2046    }
2047
2048    #[test]
2049    fn test_var_function_context_open() {
2050        let text = "color: var(--primary";
2051        assert!(is_var_function_context_slice(text));
2052    }
2053
2054    #[test]
2055    fn test_var_function_context_closed() {
2056        let text = "color: var(--primary);";
2057        assert!(!is_var_function_context_slice(text));
2058    }
2059
2060    #[test]
2061    fn test_var_function_context_nested() {
2062        let text = "color: var(--primary, calc(100% - var(--secondary";
2063        assert!(is_var_function_context_slice(text));
2064    }
2065
2066    #[test]
2067    fn test_var_function_context_after_fallback() {
2068        let text = "color: var(--primary, ";
2069        assert!(!is_var_function_context_slice(text));
2070    }
2071
2072    #[test]
2073    fn test_var_function_context_requires_boundary() {
2074        let text = "navbar(--primary";
2075        assert!(!is_var_function_context_slice(text));
2076    }
2077
2078    #[test]
2079    fn test_var_function_context_case_insensitive() {
2080        let text = "color: VAR(--primary";
2081        assert!(is_var_function_context_slice(text));
2082    }
2083
2084    #[test]
2085    fn test_completion_value_context_slice_css() {
2086        let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
2087        let text = ".card { color: var(";
2088        let position = crate::types::offset_to_position(text, text.len());
2089        let uri = Uri::from_str("file:///styles.css").unwrap();
2090        let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
2091            .expect("expected css slice");
2092        assert!(context.slice.ends_with("color: var("));
2093        assert!(!context.allow_without_braces);
2094        let value_context = get_value_context_info(context.slice, context.allow_without_braces);
2095        assert!(value_context.is_value_context);
2096        assert_eq!(value_context.property_name.as_deref(), Some("color"));
2097    }
2098
2099    #[test]
2100    fn test_completion_value_context_slice_html_style_attribute() {
2101        let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
2102        let text = r#"<div style="color: var("#;
2103        let position = crate::types::offset_to_position(text, text.len());
2104        let uri = Uri::from_str("file:///index.html").unwrap();
2105        let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
2106            .expect("expected html style attribute slice");
2107        assert_eq!(context.slice, "color: var(");
2108        assert!(context.allow_without_braces);
2109    }
2110
2111    #[test]
2112    fn test_completion_value_context_slice_html_style_block() {
2113        let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
2114        let text = "<style>body { color: var(";
2115        let position = crate::types::offset_to_position(text, text.len());
2116        let uri = Uri::from_str("file:///index.html").unwrap();
2117        let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
2118            .expect("expected html style block slice");
2119        assert_eq!(context.slice, "body { color: var(");
2120        assert!(!context.allow_without_braces);
2121    }
2122
2123    #[test]
2124    fn test_completion_value_context_slice_js_string() {
2125        let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
2126        let text = r#"const css = "color: var("#;
2127        let position = crate::types::offset_to_position(text, text.len());
2128        let uri = Uri::from_str("file:///app.js").unwrap();
2129        let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
2130            .expect("expected js string slice");
2131        assert_eq!(context.slice, "color: var(");
2132        assert!(context.allow_without_braces);
2133    }
2134
2135    #[test]
2136    fn test_completion_value_context_slice_js_non_string() {
2137        let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
2138        let text = "const css = color: var(";
2139        let position = crate::types::offset_to_position(text, text.len());
2140        let uri = Uri::from_str("file:///app.js").unwrap();
2141        assert!(completion_value_context_slice(text, position, None, &uri, &lookup_map).is_none());
2142    }
2143
2144    #[test]
2145    fn test_completion_value_context_slice_unknown() {
2146        let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
2147        let text = "color: var(";
2148        let position = crate::types::offset_to_position(text, text.len());
2149        let uri = Uri::from_str("file:///notes.txt").unwrap();
2150        assert!(completion_value_context_slice(text, position, None, &uri, &lookup_map).is_none());
2151    }
2152
2153    #[test]
2154    fn test_html_style_attribute_slice_open() {
2155        let text = r#"<div style="color: var("#;
2156        let slice = find_html_style_attribute_slice(text).unwrap();
2157        assert_eq!(slice, "color: var(");
2158    }
2159
2160    #[test]
2161    fn test_html_style_attribute_slice_closed() {
2162        let text = r#"<div style="color: red">"#;
2163        assert!(find_html_style_attribute_slice(text).is_none());
2164    }
2165
2166    #[test]
2167    fn test_html_style_block_slice() {
2168        let text = "<style>body { color: var(";
2169        let slice = find_html_style_block_slice(text).unwrap();
2170        assert_eq!(slice, "body { color: var(");
2171    }
2172
2173    #[test]
2174    fn test_js_string_segment_basic() {
2175        let text = r#"const css = \"color: var("#;
2176        let slice = find_js_string_segment(text).unwrap();
2177        assert_eq!(slice, "color: var(");
2178    }
2179
2180    #[test]
2181    fn test_js_string_segment_template_after_expression() {
2182        let text = r#"const css = `color: ${theme}; background: var("#;
2183        let slice = find_js_string_segment(text).unwrap();
2184        assert_eq!(slice, "; background: var(");
2185    }
2186
2187    #[test]
2188    fn test_js_string_segment_template_expression() {
2189        let text = r#"const css = `color: ${theme"#;
2190        assert!(find_js_string_segment(text).is_none());
2191    }
2192
2193    #[test]
2194    fn resolve_document_kind_prefers_language_id() {
2195        let lookup_files = vec!["**/*.custom".to_string()];
2196        let lookup_map = build_lookup_extension_map(&lookup_files);
2197
2198        let kind = resolve_document_kind("file.custom", Some("html"), &lookup_map);
2199        assert_eq!(kind, Some(DocumentKind::Html));
2200    }
2201
2202    #[test]
2203    fn resolve_document_kind_uses_lookup_extensions() {
2204        let lookup_files = vec![
2205            "**/*.{css,scss}".to_string(),
2206            "**/*.vue".to_string(),
2207            "**/*.custom".to_string(),
2208        ];
2209        let lookup_map = build_lookup_extension_map(&lookup_files);
2210
2211        let css_kind = resolve_document_kind("styles.scss", None, &lookup_map);
2212        assert_eq!(css_kind, Some(DocumentKind::Css));
2213
2214        let html_kind = resolve_document_kind("component.vue", None, &lookup_map);
2215        assert_eq!(html_kind, Some(DocumentKind::Html));
2216
2217        let custom_kind = resolve_document_kind("theme.custom", None, &lookup_map);
2218        assert_eq!(custom_kind, Some(DocumentKind::Css));
2219    }
2220
2221    #[test]
2222    fn resolve_document_kind_returns_none_for_unknown() {
2223        let lookup_files = vec!["**/*.css".to_string()];
2224        let lookup_map = build_lookup_extension_map(&lookup_files);
2225
2226        let kind = resolve_document_kind("notes.txt", Some("plaintext"), &lookup_map);
2227        assert_eq!(kind, None);
2228    }
2229}