Skip to main content

css_variable_lsp/
manager.rs

1use ls_types::{Position, Uri};
2use std::collections::{HashMap, HashSet};
3use std::sync::Arc;
4use tokio::sync::RwLock;
5
6use crate::color::{color_from_key, normalize_color, parse_color, NormalizedColorKey};
7use crate::dom_tree::DomTree;
8use crate::specificity::sort_by_cascade;
9use crate::types::{Config, CssVariable, CssVariableUsage, LiteralColorOccurrence};
10
11type LiteralColorMap = HashMap<Uri, HashMap<u32, Vec<LiteralColorOccurrence>>>;
12
13/// Manages CSS variables across the workspace
14#[derive(Clone)]
15pub struct CssVariableManager {
16    /// Map of variable name -> list of definitions
17    variables: Arc<RwLock<HashMap<String, Vec<CssVariable>>>>,
18
19    /// Map of variable name -> list of usages
20    usages: Arc<RwLock<HashMap<String, Vec<CssVariableUsage>>>>,
21
22    /// Literal color occurrences grouped by document and line
23    /// Outer map: URI -> Inner map (line number -> colors on that line)
24    literal_colors: Arc<RwLock<LiteralColorMap>>,
25
26    /// Map of normalized colors to matching variable names
27    color_variables: Arc<RwLock<HashMap<NormalizedColorKey, HashSet<String>>>>,
28
29    /// Configuration
30    config: Arc<RwLock<Config>>,
31
32    /// DOM trees for HTML documents
33    dom_trees: Arc<RwLock<HashMap<Uri, DomTree>>>,
34
35    /// Set of tracked document URIs (for counting unique documents)
36    tracked_documents: Arc<RwLock<HashSet<Uri>>>,
37
38    /// Latest accepted LSP version for each open document.
39    document_versions: Arc<RwLock<HashMap<Uri, i64>>>,
40}
41
42impl CssVariableManager {
43    pub fn new(config: Config) -> Self {
44        Self {
45            variables: Arc::new(RwLock::new(HashMap::new())),
46            usages: Arc::new(RwLock::new(HashMap::new())),
47            literal_colors: Arc::new(RwLock::new(HashMap::new())),
48            color_variables: Arc::new(RwLock::new(HashMap::new())),
49            config: Arc::new(RwLock::new(config)),
50            dom_trees: Arc::new(RwLock::new(HashMap::new())),
51            tracked_documents: Arc::new(RwLock::new(HashSet::new())),
52            document_versions: Arc::new(RwLock::new(HashMap::new())),
53        }
54    }
55
56    /// Record a document version if it is newer than the last accepted version.
57    ///
58    /// Returning `false` for equal or older versions lets LSP notification handlers
59    /// discard duplicate and stale changes before mutating their text snapshots.
60    pub async fn accept_document_version(&self, uri: &Uri, version: i64) -> bool {
61        let mut versions = self.document_versions.write().await;
62        if versions.get(uri).is_some_and(|current| version <= *current) {
63            return false;
64        }
65        versions.insert(uri.clone(), version);
66        true
67    }
68
69    /// Forget the version associated with a document that is no longer open.
70    pub async fn clear_document_version(&self, uri: &Uri) {
71        let mut versions = self.document_versions.write().await;
72        versions.remove(uri);
73    }
74
75    /// Return the latest accepted version for an open document.
76    pub async fn get_document_version(&self, uri: &Uri) -> Option<i64> {
77        let versions = self.document_versions.read().await;
78        versions.get(uri).copied()
79    }
80
81    /// Add a variable definition
82    pub async fn add_variable(&self, variable: CssVariable) -> Result<(), String> {
83        self.add_variables(vec![variable]).await
84    }
85
86    /// Add variable definitions atomically under one variables lock.
87    pub async fn add_variables(&self, variables: Vec<CssVariable>) -> Result<(), String> {
88        if variables.is_empty() {
89            return Ok(());
90        }
91
92        let max_documents = self.config.read().await.max_documents;
93        let mut tracked = self.tracked_documents.write().await;
94
95        let new_documents: HashSet<_> = variables
96            .iter()
97            .map(|variable| variable.uri.clone())
98            .filter(|uri| !tracked.contains(uri))
99            .collect();
100        if max_documents > 0 && tracked.len() + new_documents.len() > max_documents {
101            return Err(format!(
102                "Maximum document limit ({max_documents}) reached. Cannot add more documents."
103            ));
104        }
105        tracked.extend(new_documents);
106
107        // Keep the tracked-documents -> variables lock order consistent with removals.
108        let mut vars = self.variables.write().await;
109        for variable in variables {
110            vars.entry(variable.name.clone())
111                .or_default()
112                .push(variable);
113        }
114
115        Ok(())
116    }
117
118    /// Atomically replace all variable definitions owned by one document.
119    pub async fn replace_document_variables(
120        &self,
121        uri: &Uri,
122        variables: Vec<CssVariable>,
123    ) -> Result<(), String> {
124        if variables.iter().any(|variable| &variable.uri != uri) {
125            return Err("Replacement variables must belong to the target document".to_string());
126        }
127
128        let max_documents = self.config.read().await.max_documents;
129        let mut tracked = self.tracked_documents.write().await;
130        let introduces_document = !variables.is_empty() && !tracked.contains(uri);
131        if max_documents > 0 && introduces_document && tracked.len() >= max_documents {
132            return Err(format!(
133                "Maximum document limit ({max_documents}) reached. Cannot add more documents."
134            ));
135        }
136
137        let mut vars = self.variables.write().await;
138        for definitions in vars.values_mut() {
139            definitions.retain(|variable| &variable.uri != uri);
140        }
141        vars.retain(|_, definitions| !definitions.is_empty());
142
143        if variables.is_empty() {
144            tracked.remove(uri);
145        } else {
146            tracked.insert(uri.clone());
147        }
148        for variable in variables {
149            vars.entry(variable.name.clone())
150                .or_default()
151                .push(variable);
152        }
153
154        Ok(())
155    }
156
157    /// Atomically replace variable definitions and usages owned by one document.
158    pub async fn replace_document_analysis(
159        &self,
160        uri: &Uri,
161        variables: Vec<CssVariable>,
162        usages: Vec<CssVariableUsage>,
163    ) -> Result<(), String> {
164        self.replace_document_analysis_inner(uri, variables, usages)
165            .await
166    }
167
168    /// Replace one document's analysis only if its version is still current.
169    ///
170    /// The version lock is held for the complete tracked/variables/usages
171    /// replacement, so a newer accepted version cannot make this parse obsolete
172    /// between the check and the commit.
173    pub async fn replace_document_analysis_at_version(
174        &self,
175        uri: &Uri,
176        version: i64,
177        variables: Vec<CssVariable>,
178        usages: Vec<CssVariableUsage>,
179    ) -> Result<bool, String> {
180        let versions = self.document_versions.write().await;
181        if versions.get(uri).copied() != Some(version) {
182            return Ok(false);
183        }
184
185        self.replace_document_analysis_inner(uri, variables, usages)
186            .await?;
187        Ok(true)
188    }
189
190    async fn replace_document_analysis_inner(
191        &self,
192        uri: &Uri,
193        variables: Vec<CssVariable>,
194        usages: Vec<CssVariableUsage>,
195    ) -> Result<(), String> {
196        if variables.iter().any(|variable| &variable.uri != uri)
197            || usages.iter().any(|usage| &usage.uri != uri)
198        {
199            return Err("Replacement analysis must belong to the target document".to_string());
200        }
201
202        let max_documents = self.config.read().await.max_documents;
203        let mut tracked = self.tracked_documents.write().await;
204        let has_analysis = !variables.is_empty() || !usages.is_empty();
205        let introduces_document = has_analysis && !tracked.contains(uri);
206        if max_documents > 0 && introduces_document && tracked.len() >= max_documents {
207            return Err(format!(
208                "Maximum document limit ({max_documents}) reached. Cannot add more documents."
209            ));
210        }
211
212        // Keep the same tracked -> variables -> usages order as document removal.
213        let mut vars = self.variables.write().await;
214        let mut stored_usages = self.usages.write().await;
215
216        for definitions in vars.values_mut() {
217            definitions.retain(|variable| &variable.uri != uri);
218        }
219        vars.retain(|_, definitions| !definitions.is_empty());
220
221        for document_usages in stored_usages.values_mut() {
222            document_usages.retain(|usage| &usage.uri != uri);
223        }
224        stored_usages.retain(|_, document_usages| !document_usages.is_empty());
225
226        if !has_analysis {
227            tracked.remove(uri);
228        } else {
229            tracked.insert(uri.clone());
230        }
231        for variable in variables {
232            vars.entry(variable.name.clone())
233                .or_default()
234                .push(variable);
235        }
236
237        for usage in usages {
238            stored_usages
239                .entry(usage.name.clone())
240                .or_default()
241                .push(usage);
242        }
243
244        Ok(())
245    }
246
247    /// Add a variable usage
248    pub async fn add_usage(&self, usage: CssVariableUsage) {
249        let max_documents = self.config.read().await.max_documents;
250        let mut tracked = self.tracked_documents.write().await;
251        if !tracked.contains(&usage.uri) {
252            if max_documents > 0 && tracked.len() >= max_documents {
253                return;
254            }
255            tracked.insert(usage.uri.clone());
256        }
257
258        let mut usages = self.usages.write().await;
259        usages
260            .entry(usage.name.clone())
261            .or_insert_with(Vec::new)
262            .push(usage);
263    }
264
265    /// Add a literal color occurrence
266    pub async fn add_literal_color(&self, occurrence: LiteralColorOccurrence) {
267        let mut literal_colors = self.literal_colors.write().await;
268        let line = occurrence.range.start.line;
269        literal_colors
270            .entry(occurrence.uri.clone())
271            .or_default()
272            .entry(line)
273            .or_default()
274            .push(occurrence);
275    }
276
277    /// Get all definitions of a variable
278    pub async fn get_variables(&self, name: &str) -> Vec<CssVariable> {
279        let vars = self.variables.read().await;
280        vars.get(name).cloned().unwrap_or_default()
281    }
282
283    /// Get all usages of a variable
284    pub async fn get_usages(&self, name: &str) -> Vec<CssVariableUsage> {
285        let usages = self.usages.read().await;
286        usages.get(name).cloned().unwrap_or_default()
287    }
288
289    /// Resolve a variable name to a color using cascade ordering and var() chains.
290    pub async fn resolve_variable_color(&self, name: &str) -> Option<ls_types::Color> {
291        self.resolve_variable_color_key(name)
292            .await
293            .map(color_from_key)
294    }
295
296    /// Resolve a variable name to a normalized color key using cascade ordering and var() chains.
297    pub async fn resolve_variable_color_key(&self, name: &str) -> Option<NormalizedColorKey> {
298        let vars = self.variables.read().await;
299        resolve_variable_color_key_from_map(name, &vars)
300    }
301
302    /// Get all variables (for completion)
303    pub async fn get_all_variables(&self) -> Vec<CssVariable> {
304        let vars = self.variables.read().await;
305        vars.values().flatten().cloned().collect()
306    }
307
308    /// Get all references (definitions + usages) for a variable
309    pub async fn get_references(&self, name: &str) -> (Vec<CssVariable>, Vec<CssVariableUsage>) {
310        let definitions = self.get_variables(name).await;
311        let usages = self.get_usages(name).await;
312        (definitions, usages)
313    }
314
315    /// Get literal color occurrences in a specific document.
316    pub async fn get_document_literal_colors(&self, uri: &Uri) -> Vec<LiteralColorOccurrence> {
317        let literal_colors = self.literal_colors.read().await;
318        literal_colors
319            .get(uri)
320            .map(|by_line| by_line.values().flatten().cloned().collect())
321            .unwrap_or_default()
322    }
323
324    /// Get literal color occurrences at a specific position (O(1) line lookup + O(k) scan).
325    pub async fn get_literal_colors_at_position(
326        &self,
327        uri: &Uri,
328        position: Position,
329    ) -> Vec<LiteralColorOccurrence> {
330        let literal_colors = self.literal_colors.read().await;
331        literal_colors
332            .get(uri)
333            .and_then(|by_line| by_line.get(&position.line).cloned())
334            .unwrap_or_default()
335    }
336
337    /// Get all variables whose resolved color exactly matches the normalized color key.
338    pub async fn get_variables_by_color_key(&self, key: &NormalizedColorKey) -> Vec<CssVariable> {
339        let names = {
340            let index = self.color_variables.read().await;
341            index.get(key).cloned().unwrap_or_default()
342        };
343        let vars = self.variables.read().await;
344        let mut matches = Vec::new();
345        for name in names {
346            if let Some(definitions) = vars.get(&name) {
347                let mut definitions = definitions.clone();
348                sort_by_cascade(&mut definitions);
349                if let Some(variable) = definitions.into_iter().next() {
350                    matches.push(variable);
351                }
352            }
353        }
354        matches.sort_by(|a, b| a.name.cmp(&b.name));
355        matches
356    }
357
358    /// Get the set of resolved variable colors currently defined in a specific document.
359    pub async fn get_document_resolved_color_keys(&self, uri: &Uri) -> HashSet<NormalizedColorKey> {
360        let names = self.get_document_variable_names(uri).await;
361        let vars = self.variables.read().await;
362        names
363            .into_iter()
364            .filter_map(|name| resolve_variable_color_key_from_map(&name, &vars))
365            .collect()
366    }
367
368    /// Remove all data for a document
369    pub async fn remove_document(&self, uri: &Uri) {
370        self.remove_documents(&HashSet::from([uri.clone()])).await;
371    }
372
373    /// Remove all data for a set of documents.
374    pub async fn remove_documents(&self, uris: &HashSet<Uri>) {
375        if uris.is_empty() {
376            return;
377        }
378
379        // This order must match add_variable: tracked_documents before variables.
380        let mut tracked = self.tracked_documents.write().await;
381        let mut vars = self.variables.write().await;
382        let mut usages = self.usages.write().await;
383        let mut literal_colors = self.literal_colors.write().await;
384        let mut dom_trees = self.dom_trees.write().await;
385
386        tracked.retain(|uri| !uris.contains(uri));
387
388        for var_list in vars.values_mut() {
389            var_list.retain(|variable| !uris.contains(&variable.uri));
390        }
391        vars.retain(|_, var_list| !var_list.is_empty());
392
393        for usage_list in usages.values_mut() {
394            usage_list.retain(|usage| !uris.contains(&usage.uri));
395        }
396        usages.retain(|_, usage_list| !usage_list.is_empty());
397
398        literal_colors.retain(|uri, _| !uris.contains(uri));
399        dom_trees.retain(|uri, _| !uris.contains(uri));
400
401        // Rebuild the color index after releasing all document-data locks.
402        drop(tracked);
403        drop(vars);
404        drop(usages);
405        drop(literal_colors);
406        drop(dom_trees);
407        self.rebuild_color_index().await;
408    }
409
410    /// Get every document URI currently represented in manager state.
411    pub async fn get_document_uris(&self) -> HashSet<Uri> {
412        let mut uris = self.tracked_documents.read().await.clone();
413
414        {
415            let usages = self.usages.read().await;
416            uris.extend(usages.values().flatten().map(|usage| usage.uri.clone()));
417        }
418        {
419            let literal_colors = self.literal_colors.read().await;
420            uris.extend(literal_colors.keys().cloned());
421        }
422        {
423            let dom_trees = self.dom_trees.read().await;
424            uris.extend(dom_trees.keys().cloned());
425        }
426
427        uris
428    }
429
430    /// Get all variables defined in a specific document
431    pub async fn get_document_variables(&self, uri: &Uri) -> Vec<CssVariable> {
432        let vars = self.variables.read().await;
433        vars.values()
434            .flatten()
435            .filter(|v| &v.uri == uri)
436            .cloned()
437            .collect()
438    }
439
440    /// Get the set of variable names defined in a specific document
441    pub async fn get_document_variable_names(&self, uri: &Uri) -> HashSet<String> {
442        let vars = self.get_document_variables(uri).await;
443        vars.into_iter().map(|v| v.name).collect()
444    }
445
446    /// Get all variable usages in a specific document
447    pub async fn get_document_usages(&self, uri: &Uri) -> Vec<CssVariableUsage> {
448        let usages = self.usages.read().await;
449        usages
450            .values()
451            .flatten()
452            .filter(|u| &u.uri == uri)
453            .cloned()
454            .collect()
455    }
456
457    /// Set DOM tree for a document
458    pub async fn set_dom_tree(&self, uri: Uri, dom_tree: DomTree) {
459        let mut dom_trees = self.dom_trees.write().await;
460        dom_trees.insert(uri, dom_tree);
461    }
462
463    /// Get DOM tree for a document
464    pub async fn get_dom_tree(&self, uri: &Uri) -> Option<DomTree> {
465        let dom_trees = self.dom_trees.read().await;
466        dom_trees.get(uri).cloned()
467    }
468
469    /// Get current configuration
470    pub async fn get_config(&self) -> Config {
471        self.config.read().await.clone()
472    }
473
474    /// Replace the current configuration.
475    pub async fn set_config(&self, config: Config) {
476        let mut stored = self.config.write().await;
477        *stored = config;
478    }
479
480    /// Rebuild the normalized-color -> variable-name lookup from current workspace state.
481    pub async fn rebuild_color_index(&self) {
482        let snapshot = {
483            let vars = self.variables.read().await;
484            vars.clone()
485        };
486
487        let mut color_variables: HashMap<NormalizedColorKey, HashSet<String>> = HashMap::new();
488        for name in snapshot.keys() {
489            if let Some(key) = resolve_variable_color_key_from_map(name, &snapshot) {
490                color_variables.entry(key).or_default().insert(name.clone());
491            }
492        }
493
494        let mut stored = self.color_variables.write().await;
495        *stored = color_variables;
496    }
497}
498
499fn extract_var_reference(value: &str) -> Option<String> {
500    let trimmed = value.trim();
501    let start = trimmed.find("var(")?;
502    let mut idx = start + 4;
503    let bytes = trimmed.as_bytes();
504    let mut depth = 1i32;
505    while idx < bytes.len() {
506        match bytes[idx] {
507            b'(' => depth += 1,
508            b')' => {
509                depth -= 1;
510                if depth == 0 {
511                    break;
512                }
513            }
514            _ => {}
515        }
516        idx += 1;
517    }
518    if depth != 0 {
519        return None;
520    }
521
522    let inner = trimmed[start + 4..idx].trim_start();
523    let inner = inner.strip_prefix("--")?;
524    let mut name_len = 0usize;
525    for ch in inner.chars() {
526        if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
527            name_len += ch.len_utf8();
528        } else {
529            break;
530        }
531    }
532    if name_len == 0 {
533        return None;
534    }
535    Some(format!("--{}", &inner[..name_len]))
536}
537
538fn resolve_variable_color_key_from_map(
539    name: &str,
540    variables: &HashMap<String, Vec<CssVariable>>,
541) -> Option<NormalizedColorKey> {
542    let mut seen = HashSet::new();
543    let mut current = name.to_string();
544
545    loop {
546        if seen.contains(&current) {
547            return None;
548        }
549        seen.insert(current.clone());
550
551        let mut definitions = variables.get(&current)?.clone();
552        if definitions.is_empty() {
553            return None;
554        }
555
556        sort_by_cascade(&mut definitions);
557        let variable = &definitions[0];
558
559        if let Some(next_name) = extract_var_reference(&variable.value) {
560            current = next_name;
561            continue;
562        }
563
564        return parse_color(&variable.value).map(normalize_color);
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use ls_types::{Position, Range, Uri};
572    use std::str::FromStr;
573
574    fn create_test_variable(name: &str, value: &str, selector: &str, uri: &str) -> CssVariable {
575        CssVariable {
576            name: name.to_string(),
577            value: value.to_string(),
578            selector: selector.to_string(),
579            range: Range::new(Position::new(0, 0), Position::new(0, 10)),
580            name_range: None,
581            value_range: None,
582            uri: Uri::from_str(uri).unwrap(),
583            important: false,
584            inline: false,
585            source_position: 0,
586        }
587    }
588
589    fn create_test_usage(name: &str, context: &str, uri: &str) -> CssVariableUsage {
590        CssVariableUsage {
591            name: name.to_string(),
592            range: Range::new(Position::new(0, 0), Position::new(0, 10)),
593            name_range: None,
594            uri: Uri::from_str(uri).unwrap(),
595            usage_context: context.to_string(),
596            dom_node: None,
597        }
598    }
599
600    fn create_literal_color(
601        text: &str,
602        uri: &str,
603        color: &str,
604        context: &str,
605    ) -> LiteralColorOccurrence {
606        LiteralColorOccurrence {
607            text: text.to_string(),
608            uri: Uri::from_str(uri).unwrap(),
609            range: Range::new(Position::new(0, 0), Position::new(0, text.len() as u32)),
610            usage_context: context.to_string(),
611            normalized_color: crate::color::normalized_color_key(color).unwrap(),
612        }
613    }
614
615    #[test]
616    fn extract_var_reference_allows_fallbacks_and_trailing_tokens() {
617        assert_eq!(
618            extract_var_reference("var(--primary, #fff)"),
619            Some("--primary".to_string())
620        );
621        assert_eq!(
622            extract_var_reference("var(--primary) !important"),
623            Some("--primary".to_string())
624        );
625        assert_eq!(
626            extract_var_reference("calc(1px + var(--spacing))"),
627            Some("--spacing".to_string())
628        );
629    }
630
631    #[tokio::test]
632    async fn test_manager_add_and_get_variables() {
633        let manager = CssVariableManager::new(Config::default());
634        let var = create_test_variable("--primary", "#3b82f6", ":root", "file:///test.css");
635
636        manager
637            .add_variable(var.clone())
638            .await
639            .expect("add_variable failed");
640
641        let variables = manager.get_variables("--primary").await;
642        assert_eq!(variables.len(), 1);
643        assert_eq!(variables[0].name, "--primary");
644        assert_eq!(variables[0].value, "#3b82f6");
645    }
646
647    #[tokio::test]
648    async fn test_manager_multiple_definitions() {
649        let manager = CssVariableManager::new(Config::default());
650
651        let var1 = create_test_variable("--color", "red", ":root", "file:///test.css");
652        let var2 = create_test_variable("--color", "blue", ".class", "file:///test.css");
653
654        manager
655            .add_variable(var1)
656            .await
657            .expect("add_variable failed");
658        manager
659            .add_variable(var2)
660            .await
661            .expect("add_variable failed");
662
663        let variables = manager.get_variables("--color").await;
664        assert_eq!(variables.len(), 2);
665    }
666
667    #[tokio::test]
668    async fn replace_document_variables_is_atomic_and_respects_limits() {
669        let manager = CssVariableManager::new(Config {
670            max_documents: 1,
671            ..Config::default()
672        });
673        let first_uri = Uri::from_str("file:///first.css").unwrap();
674        let second_uri = Uri::from_str("file:///second.css").unwrap();
675
676        manager
677            .replace_document_variables(
678                &first_uri,
679                vec![create_test_variable(
680                    "--old",
681                    "red",
682                    ":root",
683                    first_uri.as_str(),
684                )],
685            )
686            .await
687            .unwrap();
688
689        let error = manager
690            .replace_document_variables(
691                &second_uri,
692                vec![create_test_variable(
693                    "--blocked",
694                    "blue",
695                    ":root",
696                    second_uri.as_str(),
697                )],
698            )
699            .await
700            .unwrap_err();
701        assert!(error.contains("Maximum document limit"));
702        assert_eq!(manager.get_variables("--old").await.len(), 1);
703        assert!(manager.get_variables("--blocked").await.is_empty());
704
705        manager
706            .replace_document_variables(
707                &first_uri,
708                vec![
709                    create_test_variable("--new-a", "red", ":root", first_uri.as_str()),
710                    create_test_variable("--new-b", "blue", ":root", first_uri.as_str()),
711                ],
712            )
713            .await
714            .unwrap();
715        assert!(manager.get_variables("--old").await.is_empty());
716        assert_eq!(manager.get_variables("--new-a").await.len(), 1);
717        assert_eq!(manager.get_variables("--new-b").await.len(), 1);
718    }
719
720    #[tokio::test]
721    async fn replace_document_analysis_replaces_definitions_and_usages_together() {
722        let manager = CssVariableManager::new(Config::default());
723        let uri = Uri::from_str("file:///vite.config.ts").unwrap();
724
725        manager
726            .replace_document_analysis(
727                &uri,
728                vec![create_test_variable("--old", "red", ":root", uri.as_str())],
729                vec![create_test_usage("--old-dependency", ":root", uri.as_str())],
730            )
731            .await
732            .unwrap();
733
734        manager
735            .replace_document_analysis(
736                &uri,
737                vec![create_test_variable("--new", "blue", ":root", uri.as_str())],
738                vec![create_test_usage("--new-dependency", ":root", uri.as_str())],
739            )
740            .await
741            .unwrap();
742
743        assert!(manager.get_variables("--old").await.is_empty());
744        assert!(manager.get_usages("--old-dependency").await.is_empty());
745        assert_eq!(manager.get_variables("--new").await.len(), 1);
746        assert_eq!(manager.get_usages("--new-dependency").await.len(), 1);
747    }
748
749    #[tokio::test]
750    async fn versioned_analysis_rejects_obsolete_replacements() {
751        let manager = CssVariableManager::new(Config::default());
752        let uri = Uri::from_str("file:///vite.config.ts").unwrap();
753
754        assert!(manager.accept_document_version(&uri, 1).await);
755        assert_eq!(manager.get_document_version(&uri).await, Some(1));
756        assert!(manager
757            .replace_document_analysis_at_version(
758                &uri,
759                1,
760                vec![create_test_variable("--old", "red", ":root", uri.as_str())],
761                vec![create_test_usage("--old-use", ":root", uri.as_str())],
762            )
763            .await
764            .unwrap());
765
766        assert!(manager.accept_document_version(&uri, 2).await);
767        assert!(!manager.accept_document_version(&uri, 2).await);
768        assert!(!manager.accept_document_version(&uri, 1).await);
769
770        assert!(!manager
771            .replace_document_analysis_at_version(
772                &uri,
773                1,
774                vec![create_test_variable(
775                    "--stale",
776                    "blue",
777                    ":root",
778                    uri.as_str()
779                )],
780                vec![create_test_usage("--stale-use", ":root", uri.as_str())],
781            )
782            .await
783            .unwrap());
784        assert_eq!(manager.get_variables("--old").await.len(), 1);
785        assert!(manager.get_variables("--stale").await.is_empty());
786        assert_eq!(manager.get_usages("--old-use").await.len(), 1);
787
788        assert!(manager
789            .replace_document_analysis_at_version(
790                &uri,
791                2,
792                vec![create_test_variable("--new", "blue", ":root", uri.as_str())],
793                vec![create_test_usage("--new-use", ":root", uri.as_str())],
794            )
795            .await
796            .unwrap());
797        assert!(manager.get_variables("--old").await.is_empty());
798        assert_eq!(manager.get_variables("--new").await.len(), 1);
799        assert!(manager.get_usages("--old-use").await.is_empty());
800        assert_eq!(manager.get_usages("--new-use").await.len(), 1);
801
802        manager.clear_document_version(&uri).await;
803        assert_eq!(manager.get_document_version(&uri).await, None);
804    }
805
806    #[tokio::test]
807    async fn usage_only_analysis_is_tracked_and_respects_document_limits() {
808        let manager = CssVariableManager::new(Config {
809            max_documents: 1,
810            ..Config::default()
811        });
812        let usage_uri = Uri::from_str("file:///vite.config.ts").unwrap();
813        let blocked_uri = Uri::from_str("file:///blocked.css").unwrap();
814
815        manager
816            .replace_document_analysis(
817                &usage_uri,
818                Vec::new(),
819                vec![create_test_usage(
820                    "--dependency",
821                    ":root",
822                    usage_uri.as_str(),
823                )],
824            )
825            .await
826            .unwrap();
827
828        assert!(manager.get_document_uris().await.contains(&usage_uri));
829        let error = manager
830            .replace_document_analysis(
831                &blocked_uri,
832                vec![create_test_variable(
833                    "--blocked",
834                    "red",
835                    ":root",
836                    blocked_uri.as_str(),
837                )],
838                Vec::new(),
839            )
840            .await
841            .unwrap_err();
842        assert!(error.contains("Maximum document limit"));
843        assert_eq!(manager.get_usages("--dependency").await.len(), 1);
844        assert!(manager.get_variables("--blocked").await.is_empty());
845    }
846
847    #[tokio::test]
848    async fn test_manager_add_and_get_usages() {
849        let manager = CssVariableManager::new(Config::default());
850        let usage = create_test_usage("--primary", ".button", "file:///test.css");
851
852        manager.add_usage(usage.clone()).await;
853
854        let usages = manager.get_usages("--primary").await;
855        assert_eq!(usages.len(), 1);
856        assert_eq!(usages[0].name, "--primary");
857        assert_eq!(usages[0].usage_context, ".button");
858    }
859
860    #[tokio::test]
861    async fn incremental_usage_insertion_respects_document_limits() {
862        let manager = CssVariableManager::new(Config {
863            max_documents: 1,
864            ..Config::default()
865        });
866        let first_uri = Uri::from_str("file:///first.css").unwrap();
867        let blocked_uri = Uri::from_str("file:///blocked.css").unwrap();
868
869        manager
870            .add_usage(create_test_usage(
871                "--first-dependency",
872                ".first",
873                first_uri.as_str(),
874            ))
875            .await;
876        manager
877            .add_usage(create_test_usage(
878                "--blocked-dependency",
879                ".blocked",
880                blocked_uri.as_str(),
881            ))
882            .await;
883
884        assert!(manager.get_document_uris().await.contains(&first_uri));
885        assert_eq!(manager.get_usages("--first-dependency").await.len(), 1);
886        assert!(manager.get_usages("--blocked-dependency").await.is_empty());
887    }
888
889    #[tokio::test]
890    async fn test_manager_get_references() {
891        let manager = CssVariableManager::new(Config::default());
892
893        let var = create_test_variable("--spacing", "1rem", ":root", "file:///test.css");
894        let usage = create_test_usage("--spacing", ".card", "file:///test.css");
895
896        manager
897            .add_variable(var)
898            .await
899            .expect("add_variable failed");
900        manager.add_usage(usage).await;
901
902        let (defs, usages) = manager.get_references("--spacing").await;
903        assert_eq!(defs.len(), 1);
904        assert_eq!(usages.len(), 1);
905    }
906
907    #[tokio::test]
908    async fn test_manager_remove_document() {
909        let manager = CssVariableManager::new(Config::default());
910        let uri = Uri::from_str("file:///test.css").unwrap();
911
912        let var = create_test_variable("--primary", "blue", ":root", "file:///test.css");
913        let usage = create_test_usage("--primary", ".button", "file:///test.css");
914        let literal = create_literal_color("blue", "file:///test.css", "blue", ".button");
915
916        manager
917            .add_variable(var)
918            .await
919            .expect("add_variable failed");
920        manager.add_usage(usage).await;
921        manager.add_literal_color(literal).await;
922
923        // Verify they exist
924        assert_eq!(manager.get_variables("--primary").await.len(), 1);
925        assert_eq!(manager.get_usages("--primary").await.len(), 1);
926        assert_eq!(manager.get_document_literal_colors(&uri).await.len(), 1);
927
928        // Remove document
929        manager.remove_document(&uri).await;
930
931        // Verify they're gone
932        assert_eq!(manager.get_variables("--primary").await.len(), 0);
933        assert_eq!(manager.get_usages("--primary").await.len(), 0);
934        assert_eq!(manager.get_document_literal_colors(&uri).await.len(), 0);
935    }
936
937    #[tokio::test]
938    async fn test_manager_get_all_variables() {
939        let manager = CssVariableManager::new(Config::default());
940
941        manager
942            .add_variable(create_test_variable(
943                "--primary",
944                "blue",
945                ":root",
946                "file:///test.css",
947            ))
948            .await
949            .expect("add_variable failed");
950        manager
951            .add_variable(create_test_variable(
952                "--secondary",
953                "red",
954                ":root",
955                "file:///test.css",
956            ))
957            .await
958            .expect("add_variable failed");
959        manager
960            .add_variable(create_test_variable(
961                "--spacing",
962                "1rem",
963                ":root",
964                "file:///test.css",
965            ))
966            .await
967            .expect("add_variable failed");
968
969        let all_vars = manager.get_all_variables().await;
970        assert_eq!(all_vars.len(), 3);
971    }
972
973    #[tokio::test]
974    async fn test_manager_resolve_variable_color() {
975        let manager = CssVariableManager::new(Config::default());
976
977        let var = create_test_variable("--primary-color", "#3b82f6", ":root", "file:///test.css");
978        manager
979            .add_variable(var)
980            .await
981            .expect("add_variable failed");
982
983        let color = manager.resolve_variable_color("--primary-color").await;
984        assert!(color.is_some());
985    }
986
987    #[tokio::test]
988    async fn test_manager_resolve_variable_color_key_chain() {
989        let manager = CssVariableManager::new(Config::default());
990
991        manager
992            .add_variable(create_test_variable(
993                "--base-color",
994                "#fff",
995                ":root",
996                "file:///test.css",
997            ))
998            .await
999            .expect("add_variable failed");
1000        manager
1001            .add_variable(create_test_variable(
1002                "--alias-color",
1003                "var(--base-color)",
1004                ":root",
1005                "file:///test.css",
1006            ))
1007            .await
1008            .expect("add_variable failed");
1009
1010        let key = manager.resolve_variable_color_key("--alias-color").await;
1011        assert_eq!(key, crate::color::normalized_color_key("white"));
1012    }
1013
1014    #[tokio::test]
1015    async fn test_manager_get_variables_by_color_key_excludes_non_colors() {
1016        let manager = CssVariableManager::new(Config::default());
1017
1018        manager
1019            .add_variable(create_test_variable(
1020                "--spacing",
1021                "1rem",
1022                ":root",
1023                "file:///test.css",
1024            ))
1025            .await
1026            .expect("add_variable failed");
1027        manager
1028            .add_variable(create_test_variable(
1029                "--text-color",
1030                "#fff",
1031                ":root",
1032                "file:///test.css",
1033            ))
1034            .await
1035            .expect("add_variable failed");
1036
1037        manager.rebuild_color_index().await;
1038
1039        let matches = manager
1040            .get_variables_by_color_key(&crate::color::normalized_color_key("white").unwrap())
1041            .await;
1042        assert_eq!(matches.len(), 1);
1043        assert_eq!(matches[0].name, "--text-color");
1044    }
1045
1046    #[tokio::test]
1047    async fn test_manager_get_variables_by_color_key_multiple_names() {
1048        let manager = CssVariableManager::new(Config::default());
1049
1050        manager
1051            .add_variable(create_test_variable(
1052                "--text-color",
1053                "#fff",
1054                ":root",
1055                "file:///test.css",
1056            ))
1057            .await
1058            .expect("add_variable failed");
1059        manager
1060            .add_variable(create_test_variable(
1061                "--surface",
1062                "rgb(255 255 255)",
1063                ":root",
1064                "file:///test.css",
1065            ))
1066            .await
1067            .expect("add_variable failed");
1068
1069        manager.rebuild_color_index().await;
1070
1071        let matches = manager
1072            .get_variables_by_color_key(&crate::color::normalized_color_key("white").unwrap())
1073            .await;
1074        assert_eq!(matches.len(), 2);
1075        assert!(matches.iter().any(|var| var.name == "--surface"));
1076        assert!(matches.iter().any(|var| var.name == "--text-color"));
1077    }
1078
1079    #[tokio::test]
1080    async fn test_manager_cross_file_references() {
1081        let manager = CssVariableManager::new(Config::default());
1082
1083        // Variable defined in one file
1084        let var = create_test_variable("--theme", "dark", ":root", "file:///variables.css");
1085        manager
1086            .add_variable(var)
1087            .await
1088            .expect("add_variable failed");
1089
1090        // Used in another file
1091        let usage = create_test_usage("--theme", ".app", "file:///app.css");
1092        manager.add_usage(usage).await;
1093
1094        let (defs, usages) = manager.get_references("--theme").await;
1095        assert_eq!(defs.len(), 1);
1096        assert_eq!(usages.len(), 1);
1097        assert_ne!(defs[0].uri, usages[0].uri);
1098    }
1099
1100    #[tokio::test]
1101    async fn test_manager_document_isolation() {
1102        let manager = CssVariableManager::new(Config::default());
1103        let uri1 = Uri::from_str("file:///file1.css").unwrap();
1104        let _uri2 = Uri::from_str("file:///file2.css").unwrap();
1105
1106        manager
1107            .add_variable(create_test_variable(
1108                "--color",
1109                "red",
1110                ":root",
1111                "file:///file1.css",
1112            ))
1113            .await
1114            .expect("add_variable failed");
1115        manager
1116            .add_variable(create_test_variable(
1117                "--color",
1118                "blue",
1119                ":root",
1120                "file:///file2.css",
1121            ))
1122            .await
1123            .expect("add_variable failed");
1124
1125        // Should have both definitions
1126        assert_eq!(manager.get_variables("--color").await.len(), 2);
1127
1128        // Remove one document
1129        manager.remove_document(&uri1).await;
1130
1131        // Should only have one definition now
1132        let vars = manager.get_variables("--color").await;
1133        assert_eq!(vars.len(), 1);
1134        assert_eq!(vars[0].value, "blue");
1135    }
1136
1137    #[tokio::test]
1138    async fn test_manager_color_index_stale_after_remove() {
1139        // Regression test: color_variables becomes stale after remove_document()
1140        // when rebuild_color_index() is not called
1141        let manager = CssVariableManager::new(Config::default());
1142        let uri = Uri::from_str("file:///test.css").unwrap();
1143        let white_key = crate::color::normalized_color_key("white").unwrap();
1144
1145        // Add a color variable and build the index
1146        let var = create_test_variable("--bg", "#ffffff", ":root", "file:///test.css");
1147        manager
1148            .add_variable(var)
1149            .await
1150            .expect("add_variable failed");
1151        manager.rebuild_color_index().await;
1152
1153        // Verify it's indexed
1154        assert_eq!(
1155            manager.get_variables_by_color_key(&white_key).await.len(),
1156            1,
1157            "Variable should be indexed by color"
1158        );
1159
1160        // Remove document WITHOUT rebuilding index (simulates the bug)
1161        manager.remove_document(&uri).await;
1162
1163        // After removal, the variable should be gone from both collections
1164        assert_eq!(
1165            manager.get_variables("--bg").await.len(),
1166            0,
1167            "Variable should be removed from variables map"
1168        );
1169
1170        // The bug: color_variables still contains the stale entry,
1171        // but get_variables_by_color_key() silently skips names not found in variables.
1172        // This test verifies the current (buggy) behavior - returns 0 matches.
1173        let color_matches = manager.get_variables_by_color_key(&white_key).await;
1174        assert_eq!(
1175            color_matches.len(),
1176            0,
1177            "BUG: color index is stale, returns 0 instead of correctly handling removal"
1178        );
1179
1180        // Workaround: manually rebuild to get correct behavior
1181        manager.rebuild_color_index().await;
1182        assert_eq!(
1183            manager.get_variables_by_color_key(&white_key).await.len(),
1184            0,
1185            "After rebuild, color index is correct"
1186        );
1187    }
1188
1189    // Note: extract_var_name is not a public function, so we skip testing it directly
1190
1191    #[tokio::test]
1192    async fn test_manager_important_flag() {
1193        let manager = CssVariableManager::new(Config::default());
1194
1195        let mut var = create_test_variable("--color", "red", ":root", "file:///test.css");
1196        var.important = true;
1197
1198        manager
1199            .add_variable(var)
1200            .await
1201            .expect("add_variable failed");
1202
1203        let vars = manager.get_variables("--color").await;
1204        assert_eq!(vars.len(), 1);
1205        assert!(vars[0].important);
1206    }
1207
1208    #[tokio::test]
1209    async fn test_manager_inline_flag() {
1210        let manager = CssVariableManager::new(Config::default());
1211
1212        let mut var = create_test_variable(
1213            "--inline-color",
1214            "green",
1215            "inline-style",
1216            "file:///test.html",
1217        );
1218        var.inline = true;
1219
1220        manager
1221            .add_variable(var)
1222            .await
1223            .expect("add_variable failed");
1224
1225        let vars = manager.get_variables("--inline-color").await;
1226        assert_eq!(vars.len(), 1);
1227        assert!(vars[0].inline);
1228    }
1229
1230    #[tokio::test]
1231    async fn test_manager_empty_queries() {
1232        let manager = CssVariableManager::new(Config::default());
1233
1234        // Query for non-existent variable
1235        let vars = manager.get_variables("--does-not-exist").await;
1236        assert_eq!(vars.len(), 0);
1237
1238        let usages = manager.get_usages("--does-not-exist").await;
1239        assert_eq!(usages.len(), 0);
1240
1241        let (defs, usages) = manager.get_references("--does-not-exist").await;
1242        assert_eq!(defs.len(), 0);
1243        assert_eq!(usages.len(), 0);
1244    }
1245
1246    /// Memory limit enforcement in CssVariableManager
1247    ///
1248    /// ISSUE: The manager uses unbounded HashMaps that can grow indefinitely.
1249    /// Large workspaces could accumulate many documents without cleanup.
1250    ///
1251    /// After fix: Should have a document limit enforced.
1252    #[tokio::test]
1253    async fn test_manager_has_memory_limits() {
1254        use ls_types::{Position, Range};
1255        use std::str::FromStr;
1256
1257        let config = Config {
1258            max_documents: 100,
1259            ..Default::default()
1260        };
1261
1262        let manager = CssVariableManager::new(config);
1263
1264        // Try to add 200 documents (beyond the limit of 100)
1265        let mut success_count = 0;
1266        let mut failure_count = 0;
1267
1268        for i in 0..200 {
1269            let var = CssVariable {
1270                name: format!("--var-{}", i),
1271                value: "red".to_string(),
1272                selector: ":root".to_string(),
1273                range: Range::new(Position::new(0, 0), Position::new(0, 10)),
1274                name_range: None,
1275                value_range: None,
1276                uri: Uri::from_str(&format!("file:///test/doc_{}.css", i)).unwrap(),
1277                important: false,
1278                inline: false,
1279                source_position: 0,
1280            };
1281
1282            match manager.add_variable(var).await {
1283                Ok(()) => success_count += 1,
1284                Err(_) => failure_count += 1,
1285            }
1286        }
1287
1288        // Verify the limit was enforced
1289        assert_eq!(
1290            success_count, 100,
1291            "Should successfully add exactly 100 documents (the limit)"
1292        );
1293        assert_eq!(
1294            failure_count, 100,
1295            "Should fail to add the remaining 100 documents beyond the limit"
1296        );
1297
1298        // Check how many documents are actually stored
1299        let vars = manager.variables.read().await;
1300        assert!(
1301            vars.len() <= 100,
1302            "Manager should not have more than 100 documents, but has {}",
1303            vars.len()
1304        );
1305    }
1306
1307    #[tokio::test]
1308    async fn test_add_and_remove_use_consistent_lock_order() {
1309        use tokio::time::{timeout, Duration};
1310
1311        let manager = CssVariableManager::new(Config::default());
1312        let existing_uri = Uri::from_str("file:///existing.css").unwrap();
1313        manager
1314            .add_variable(create_test_variable(
1315                "--existing",
1316                "red",
1317                ":root",
1318                existing_uri.as_str(),
1319            ))
1320            .await
1321            .unwrap();
1322
1323        // Hold variables so remove queues for that lock. With the old variables -> tracked
1324        // removal order, the later add held tracked while waiting behind remove, deadlocking.
1325        let variables_guard = manager.variables.write().await;
1326        let remove_manager = manager.clone();
1327        let remove_task = tokio::spawn(async move {
1328            remove_manager.remove_document(&existing_uri).await;
1329        });
1330        tokio::task::yield_now().await;
1331
1332        let add_manager = manager.clone();
1333        let add_task = tokio::spawn(async move {
1334            add_manager
1335                .add_variable(create_test_variable(
1336                    "--added",
1337                    "blue",
1338                    ":root",
1339                    "file:///added.css",
1340                ))
1341                .await
1342        });
1343        tokio::task::yield_now().await;
1344        drop(variables_guard);
1345
1346        timeout(Duration::from_secs(1), async {
1347            remove_task.await.unwrap();
1348            add_task.await.unwrap().unwrap();
1349        })
1350        .await
1351        .expect("concurrent add/remove should not deadlock");
1352    }
1353
1354    /// Bug demonstration: Color index can be stale during concurrent access
1355    ///
1356    /// ISSUE: rebuild_color_index() reads from variables and writes to color_variables.
1357    /// While individual operations are atomic, there's a brief window where the
1358    /// color index could be stale during concurrent updates.
1359    ///
1360    /// EXPECTED TO FAIL: This test proves the race condition exists.
1361    /// After fix: Color index should be properly synchronized.
1362    #[tokio::test]
1363    async fn test_manager_color_index_concurrent_consistency() {
1364        use ls_types::{Position, Range};
1365        use std::str::FromStr;
1366        use std::sync::atomic::{AtomicUsize, Ordering};
1367        use std::sync::Arc;
1368
1369        let config = Config::default();
1370        let manager = CssVariableManager::new(config);
1371
1372        // Track inconsistencies between color index and variables
1373        let inconsistencies = Arc::new(AtomicUsize::new(0));
1374
1375        // Add initial color variables
1376        let var = CssVariable {
1377            name: "--color-primary".to_string(),
1378            value: "#ff0000".to_string(),
1379            selector: ":root".to_string(),
1380            range: Range::new(Position::new(0, 0), Position::new(0, 10)),
1381            name_range: None,
1382            value_range: None,
1383            uri: Uri::from_str("file:///test/colors.css").unwrap(),
1384            important: false,
1385            inline: false,
1386            source_position: 0,
1387        };
1388        manager
1389            .add_variable(var)
1390            .await
1391            .expect("add_variable failed");
1392        manager.rebuild_color_index().await;
1393
1394        // Spawn concurrent readers and writers
1395        let mut handles = vec![];
1396
1397        // Writer: Continuously add color variables
1398        for i in 0..100 {
1399            let manager_clone = manager.clone();
1400            handles.push(tokio::spawn(async move {
1401                let var = CssVariable {
1402                    name: format!("--color-{}", i),
1403                    value: format!("hsl({}, 100%, 50%)", i * 3),
1404                    selector: ":root".to_string(),
1405                    range: Range::new(Position::new(0, 0), Position::new(0, 10)),
1406                    name_range: None,
1407                    value_range: None,
1408                    uri: Uri::from_str(&format!("file:///test/color_{}.css", i)).unwrap(),
1409                    important: false,
1410                    inline: false,
1411                    source_position: 0,
1412                };
1413                manager_clone
1414                    .add_variable(var)
1415                    .await
1416                    .expect("add_variable failed");
1417                // Rebuild index after each add to simulate real usage
1418                manager_clone.rebuild_color_index().await;
1419            }));
1420        }
1421
1422        // Reader: Continuously check color index consistency
1423        let inconsistencies_clone = inconsistencies.clone();
1424        let manager_reader = manager.clone();
1425        handles.push(tokio::spawn(async move {
1426            for _ in 0..50 {
1427                // Get all variables
1428                let all_vars = manager_reader.get_all_variables().await;
1429
1430                // Count color variables by checking if they have color values
1431                let color_count = all_vars
1432                    .iter()
1433                    .filter(|v| crate::color::parse_color(&v.value).is_some())
1434                    .count();
1435
1436                // Rebuild and check the color index
1437                manager_reader.rebuild_color_index().await;
1438
1439                // Get variables by a sample color key
1440                let white_key = crate::color::normalized_color_key("white").unwrap();
1441                let white_matches = manager_reader.get_variables_by_color_key(&white_key).await;
1442
1443                // If the counts are wildly different, there's an inconsistency
1444                // (This is a simplified check - real race conditions are harder to detect)
1445                if white_matches.len() > color_count + 10 {
1446                    inconsistencies_clone.fetch_add(1, Ordering::SeqCst);
1447                }
1448
1449                tokio::time::sleep(tokio::time::Duration::from_micros(1)).await;
1450            }
1451        }));
1452
1453        // Wait for all operations
1454        for handle in handles {
1455            let _ = handle.await;
1456        }
1457
1458        // BUG: Currently this assertion will FAIL because race condition exists
1459        // The color index may be temporarily stale during concurrent updates
1460        // After fix: inconsistencies should be 0
1461        assert_eq!(
1462            inconsistencies.load(Ordering::SeqCst),
1463            0,
1464            "Color index had {} inconsistencies during concurrent access",
1465            inconsistencies.load(Ordering::SeqCst)
1466        );
1467    }
1468}