Skip to main content

css_variable_lsp/
lsp_server.rs

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