Skip to main content

css_variable_lsp/
lsp_server.rs

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