/ src / analysis / workspace_index.rs
workspace_index.rs
   1  use crate::analysis::document::DocumentAnalysis;
   2  use crate::types::FileExportEntry;
   3  use compact_str::CompactString;
   4  use dashmap::{DashMap, DashSet};
   5  use parking_lot::RwLock;
   6  use quick_cache::sync::Cache;
   7  use rustc_hash::FxHashSet;
   8  use std::hash::{Hash, Hasher};
   9  use std::path::PathBuf;
  10  use std::sync::atomic::{AtomicUsize, Ordering};
  11  use std::sync::Arc;
  12  use std::time::SystemTime;
  13  use tower_lsp::lsp_types::{Range, Url};
  14  
  15  /// Maximum number of module resolution cache entries.
  16  /// This bounds memory growth from import resolution.
  17  const MAX_MODULE_RESOLUTION_CACHE_SIZE: usize = 2000;
  18  
  19  /// Key type for module resolution cache that implements proper hashing.
  20  #[derive(Clone, Debug, PartialEq, Eq)]
  21  struct ModuleResolutionKey {
  22      importer: Url,
  23      specifier: CompactString,
  24  }
  25  
  26  impl Hash for ModuleResolutionKey {
  27      fn hash<H: Hasher>(&self, state: &mut H) {
  28          self.importer.as_str().hash(state);
  29          self.specifier.hash(state);
  30      }
  31  }
  32  
  33  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  34  pub enum LocationKind {
  35      DirectReference,
  36  
  37      BindingDeclaration,
  38  
  39      BindingUsage,
  40  
  41      PropertyAccess,
  42  
  43      DestructuredProperty,
  44  
  45      EnvFileDefinition,
  46  }
  47  
  48  #[derive(Debug, Clone)]
  49  pub struct EnvVarLocation {
  50      pub range: Range,
  51  
  52      pub kind: LocationKind,
  53  
  54      pub binding_name: Option<CompactString>,
  55  }
  56  
  57  #[derive(Debug)]
  58  pub struct FileIndexEntry {
  59      pub mtime: SystemTime,
  60  
  61      pub env_vars: FxHashSet<CompactString>,
  62  
  63      pub is_env_file: bool,
  64  
  65      pub path: PathBuf,
  66  }
  67  
  68  #[derive(Debug, Default)]
  69  pub struct IndexState {
  70      pub total_files: usize,
  71  
  72      pub indexed_files: AtomicUsize,
  73  
  74      pub indexing_in_progress: bool,
  75  
  76      pub last_full_index: Option<SystemTime>,
  77  }
  78  
  79  impl IndexState {
  80      pub fn increment_indexed(&self) {
  81          self.indexed_files.fetch_add(1, Ordering::Relaxed);
  82      }
  83  
  84      pub fn indexed_count(&self) -> usize {
  85          self.indexed_files.load(Ordering::Relaxed)
  86      }
  87  
  88      pub fn progress_percent(&self) -> u8 {
  89          if self.total_files == 0 {
  90              return 100;
  91          }
  92          let indexed = self.indexed_count();
  93          ((indexed * 100) / self.total_files).min(100) as u8
  94      }
  95  }
  96  
  97  pub struct WorkspaceIndex {
  98      env_to_files: DashMap<CompactString, FxHashSet<Url>>,
  99  
 100      file_entries: DashMap<Url, FileIndexEntry>,
 101  
 102      analysis_by_file: DashMap<Url, Arc<DocumentAnalysis>>,
 103  
 104      state: RwLock<IndexState>,
 105  
 106      export_index: DashMap<Url, FileExportEntry>,
 107  
 108      env_export_to_files: DashMap<CompactString, FxHashSet<Url>>,
 109  
 110      /// LRU cache for module resolution with bounded size.
 111      /// Key: (importer_url, specifier), Value: resolved URL or None for failed lookups.
 112      /// Bounded to MAX_MODULE_RESOLUTION_CACHE_SIZE entries to prevent unbounded memory growth.
 113      module_resolution_cache: Cache<ModuleResolutionKey, Option<Url>>,
 114  
 115      /// Files that this file imports from (dependencies)
 116      /// Key: importer file, Value: list of files it imports from
 117      file_dependencies: DashMap<Url, Vec<Url>>,
 118  
 119      /// Files that import this file (reverse dependency index)
 120      /// Key: imported file, Value: list of files that import it
 121      file_dependents: DashMap<Url, Vec<Url>>,
 122  
 123      /// Files that need re-analysis after a dependency change
 124      dirty_files: DashSet<Url>,
 125  }
 126  
 127  impl WorkspaceIndex {
 128      pub fn new() -> Self {
 129          Self {
 130              env_to_files: DashMap::new(),
 131              file_entries: DashMap::new(),
 132              analysis_by_file: DashMap::new(),
 133              state: RwLock::new(IndexState::default()),
 134              export_index: DashMap::new(),
 135              env_export_to_files: DashMap::new(),
 136              module_resolution_cache: Cache::new(MAX_MODULE_RESOLUTION_CACHE_SIZE),
 137              file_dependencies: DashMap::new(),
 138              file_dependents: DashMap::new(),
 139              dirty_files: DashSet::new(),
 140          }
 141      }
 142  
 143      pub fn files_for_env_var(&self, name: &str) -> Vec<Url> {
 144          self.env_to_files
 145              .get(name)
 146              .map(|set| set.iter().cloned().collect())
 147              .unwrap_or_default()
 148      }
 149  
 150      pub fn is_file_indexed(&self, uri: &Url) -> bool {
 151          self.file_entries.contains_key(uri)
 152      }
 153  
 154      pub fn env_vars_in_file(&self, uri: &Url) -> Option<FxHashSet<CompactString>> {
 155          self.file_entries.get(uri).map(|e| e.env_vars.clone())
 156      }
 157  
 158      pub fn is_file_stale(&self, uri: &Url, current_mtime: SystemTime) -> bool {
 159          self.file_entries
 160              .get(uri)
 161              .map(|e| current_mtime > e.mtime)
 162              .unwrap_or(true)
 163      }
 164  
 165      pub fn all_env_vars(&self) -> Vec<CompactString> {
 166          self.env_to_files
 167              .iter()
 168              .map(|entry| entry.key().clone())
 169              .collect()
 170      }
 171  
 172      pub fn stats(&self) -> IndexStats {
 173          IndexStats {
 174              total_files: self.file_entries.len(),
 175              total_env_vars: self.env_to_files.len(),
 176              env_files: self.file_entries.iter().filter(|e| e.is_env_file).count(),
 177          }
 178      }
 179  
 180      pub fn get_exports(&self, uri: &Url) -> Option<FileExportEntry> {
 181          self.export_index.get(uri).map(|e| e.clone())
 182      }
 183  
 184      pub fn get_exports_ref(
 185          &self,
 186          uri: &Url,
 187      ) -> Option<dashmap::mapref::one::Ref<'_, Url, FileExportEntry>> {
 188          self.export_index.get(uri)
 189      }
 190  
 191      pub fn files_exporting_env_var(&self, name: &str) -> Vec<Url> {
 192          self.env_export_to_files
 193              .get(name)
 194              .map(|set| set.iter().cloned().collect())
 195              .unwrap_or_default()
 196      }
 197  
 198      pub fn has_exports(&self, uri: &Url) -> bool {
 199          self.export_index
 200              .get(uri)
 201              .map(|e| !e.is_empty())
 202              .unwrap_or(false)
 203      }
 204  
 205      /// Get cached module resolution result.
 206      /// Returns Some(Some(url)) if resolved, Some(None) if confirmed not resolvable, None if not cached.
 207      pub fn cached_module_resolution(&self, importer: &Url, specifier: &str) -> Option<Option<Url>> {
 208          let key = ModuleResolutionKey {
 209              importer: importer.clone(),
 210              specifier: CompactString::from(specifier),
 211          };
 212          self.module_resolution_cache.get(&key)
 213      }
 214  
 215      pub fn all_exported_env_vars(&self) -> Vec<CompactString> {
 216          self.env_export_to_files
 217              .iter()
 218              .map(|entry| entry.key().clone())
 219              .collect()
 220      }
 221  
 222      pub fn update_file(&self, uri: &Url, entry: FileIndexEntry) {
 223          if let Some(old_entry) = self.file_entries.get(uri) {
 224              for env_var in &old_entry.env_vars {
 225                  if let Some(mut files) = self.env_to_files.get_mut(env_var) {
 226                      files.remove(uri);
 227  
 228                      if files.is_empty() {
 229                          drop(files);
 230                          self.env_to_files.remove(env_var);
 231                      }
 232                  }
 233              }
 234          }
 235  
 236          for env_var in &entry.env_vars {
 237              self.env_to_files
 238                  .entry(env_var.clone())
 239                  .or_default()
 240                  .insert(uri.clone());
 241          }
 242  
 243          self.file_entries.insert(uri.clone(), entry);
 244      }
 245  
 246      pub fn remove_file(&self, uri: &Url) {
 247          if let Some((_, entry)) = self.file_entries.remove(uri) {
 248              for env_var in entry.env_vars {
 249                  if let Some(mut files) = self.env_to_files.get_mut(&env_var) {
 250                      files.remove(uri);
 251                      if files.is_empty() {
 252                          drop(files);
 253                          self.env_to_files.remove(&env_var);
 254                      }
 255                  }
 256              }
 257          }
 258  
 259          self.remove_exports(uri);
 260          self.analysis_by_file.remove(uri);
 261  
 262          self.invalidate_resolution_cache(uri);
 263  
 264          self.remove_from_dependency_graph(uri);
 265      }
 266  
 267      pub fn clear(&self) {
 268          self.env_to_files.clear();
 269          self.file_entries.clear();
 270          self.analysis_by_file.clear();
 271          self.export_index.clear();
 272          self.env_export_to_files.clear();
 273          self.module_resolution_cache.clear();
 274          self.file_dependencies.clear();
 275          self.file_dependents.clear();
 276          self.dirty_files.clear();
 277      }
 278  
 279      pub fn update_exports(&self, uri: &Url, exports: FileExportEntry) {
 280          if let Some(old_exports) = self.export_index.get(uri) {
 281              for env_var in old_exports.exported_env_vars() {
 282                  if let Some(mut files) = self.env_export_to_files.get_mut(&env_var) {
 283                      files.remove(uri);
 284                      if files.is_empty() {
 285                          drop(files);
 286                          self.env_export_to_files.remove(&env_var);
 287                      }
 288                  }
 289              }
 290          }
 291  
 292          for env_var in exports.exported_env_vars() {
 293              self.env_export_to_files
 294                  .entry(env_var)
 295                  .or_default()
 296                  .insert(uri.clone());
 297          }
 298  
 299          self.export_index.insert(uri.clone(), exports);
 300      }
 301  
 302      pub fn update_analysis(&self, uri: &Url, analysis: Arc<DocumentAnalysis>) {
 303          self.analysis_by_file.insert(uri.clone(), analysis);
 304      }
 305  
 306      pub fn get_analysis(&self, uri: &Url) -> Option<Arc<DocumentAnalysis>> {
 307          self.analysis_by_file
 308              .get(uri)
 309              .map(|entry| Arc::clone(entry.value()))
 310      }
 311  
 312      pub fn remove_analysis(&self, uri: &Url) {
 313          self.analysis_by_file.remove(uri);
 314      }
 315  
 316      fn remove_exports(&self, uri: &Url) {
 317          if let Some((_, exports)) = self.export_index.remove(uri) {
 318              for env_var in exports.exported_env_vars() {
 319                  if let Some(mut files) = self.env_export_to_files.get_mut(&env_var) {
 320                      files.remove(uri);
 321                      if files.is_empty() {
 322                          drop(files);
 323                          self.env_export_to_files.remove(&env_var);
 324                      }
 325                  }
 326              }
 327          }
 328      }
 329  
 330      /// Cache a module resolution result.
 331      pub fn cache_module_resolution(&self, importer: &Url, specifier: &str, resolved: Option<Url>) {
 332          let key = ModuleResolutionKey {
 333              importer: importer.clone(),
 334              specifier: CompactString::from(specifier),
 335          };
 336          self.module_resolution_cache.insert(key, resolved);
 337      }
 338  
 339      /// Invalidate module resolution cache entries related to a changed file.
 340      /// This selectively removes only entries that:
 341      /// 1. Were resolved FROM the changed file (importer matches)
 342      /// 2. Were resolved TO the changed file (resolved URL matches)
 343      ///
 344      /// This is more efficient than clearing the entire cache.
 345      pub fn invalidate_resolution_cache(&self, changed_uri: &Url) {
 346          // Use retain to keep only entries NOT related to the changed file.
 347          // An entry is related if:
 348          // - The importer (source file) is the changed file
 349          // - The resolved target is the changed file
 350          self.module_resolution_cache.retain(|key, resolved| {
 351              // Remove if importer matches changed URI
 352              if &key.importer == changed_uri {
 353                  return false;
 354              }
 355              // Remove if resolved target matches changed URI
 356              if let Some(target) = resolved {
 357                  if target == changed_uri {
 358                      return false;
 359                  }
 360              }
 361              // Keep all other entries
 362              true
 363          });
 364      }
 365  
 366      /// Clear all module resolution cache entries.
 367      pub fn clear_resolution_cache(&self) {
 368          self.module_resolution_cache.clear();
 369      }
 370  
 371      /// Get module resolution cache statistics for monitoring.
 372      pub fn module_cache_len(&self) -> usize {
 373          self.module_resolution_cache.len()
 374      }
 375  
 376      // =========================================================================
 377      // Dependency Graph Methods
 378      // =========================================================================
 379  
 380      /// Update the dependency graph for a file based on its imports.
 381      /// This should be called during indexing when imports are extracted.
 382      /// `dependencies` is the list of resolved file URIs that this file imports from.
 383      pub fn update_dependency_graph(&self, file_uri: &Url, dependencies: Vec<Url>) {
 384          // Remove old forward edges
 385          if let Some((_, old_deps)) = self.file_dependencies.remove(file_uri) {
 386              for dep in old_deps {
 387                  if let Some(mut dependents) = self.file_dependents.get_mut(&dep) {
 388                      dependents.retain(|u| u != file_uri);
 389                  }
 390              }
 391          }
 392  
 393          // Add new forward edges
 394          for dep in &dependencies {
 395              self.file_dependents
 396                  .entry(dep.clone())
 397                  .or_default()
 398                  .push(file_uri.clone());
 399          }
 400  
 401          // Store forward edges
 402          if !dependencies.is_empty() {
 403              self.file_dependencies
 404                  .insert(file_uri.clone(), dependencies);
 405          }
 406      }
 407  
 408      /// Invalidate caches for a file change and mark dependents as dirty.
 409      /// Call this when a file is modified.
 410      pub fn invalidate_for_file_change(&self, changed_uri: &Url) {
 411          // Mark all dependents (files that import this file) as dirty
 412          if let Some(dependents) = self.file_dependents.get(changed_uri) {
 413              for dep in dependents.iter() {
 414                  self.dirty_files.insert(dep.clone());
 415              }
 416          }
 417  
 418          // Invalidate module resolution cache entries related to this file
 419          self.invalidate_resolution_cache(changed_uri);
 420      }
 421  
 422      /// Get all files that are marked as dirty and need re-analysis.
 423      pub fn get_dirty_files(&self) -> Vec<Url> {
 424          self.dirty_files.iter().map(|u| u.clone()).collect()
 425      }
 426  
 427      /// Clear the dirty flag for a file after it has been re-analyzed.
 428      pub fn clear_dirty(&self, uri: &Url) {
 429          self.dirty_files.remove(uri);
 430      }
 431  
 432      /// Check if any files are dirty.
 433      pub fn has_dirty_files(&self) -> bool {
 434          !self.dirty_files.is_empty()
 435      }
 436  
 437      /// Get the number of dirty files.
 438      pub fn dirty_count(&self) -> usize {
 439          self.dirty_files.len()
 440      }
 441  
 442      /// Remove a file from the dependency graph completely.
 443      /// Call this when a file is deleted.
 444      pub fn remove_from_dependency_graph(&self, uri: &Url) {
 445          // Remove forward edges (files this file imports from)
 446          if let Some((_, deps)) = self.file_dependencies.remove(uri) {
 447              for dep in deps {
 448                  if let Some(mut dependents) = self.file_dependents.get_mut(&dep) {
 449                      dependents.retain(|u| u != uri);
 450                      if dependents.is_empty() {
 451                          drop(dependents);
 452                          self.file_dependents.remove(&dep);
 453                      }
 454                  }
 455              }
 456          }
 457  
 458          // Remove reverse edges (files that import this file)
 459          if let Some((_, dependents)) = self.file_dependents.remove(uri) {
 460              // Mark all files that imported this file as dirty
 461              for dep in dependents {
 462                  self.dirty_files.insert(dep.clone());
 463                  // Remove this file from their dependencies list
 464                  if let Some(mut deps) = self.file_dependencies.get_mut(&dep) {
 465                      deps.retain(|u| u != uri);
 466                  }
 467              }
 468          }
 469  
 470          // Remove from dirty files
 471          self.dirty_files.remove(uri);
 472      }
 473  
 474      /// Get the files that a given file imports from (its dependencies).
 475      pub fn get_dependencies(&self, uri: &Url) -> Vec<Url> {
 476          self.file_dependencies
 477              .get(uri)
 478              .map(|deps| deps.clone())
 479              .unwrap_or_default()
 480      }
 481  
 482      /// Get the files that import a given file (its dependents).
 483      pub fn get_dependents(&self, uri: &Url) -> Vec<Url> {
 484          self.file_dependents
 485              .get(uri)
 486              .map(|deps| deps.clone())
 487              .unwrap_or_default()
 488      }
 489  
 490      pub fn set_indexing(&self, in_progress: bool) {
 491          let mut state = self.state.write();
 492          state.indexing_in_progress = in_progress;
 493          if in_progress {
 494              state.indexed_files.store(0, Ordering::Relaxed);
 495          } else {
 496              state.last_full_index = Some(SystemTime::now());
 497          }
 498      }
 499  
 500      pub fn set_total_files(&self, count: usize) {
 501          self.state.write().total_files = count;
 502      }
 503  
 504      pub fn increment_indexed(&self) {
 505          self.state.read().increment_indexed();
 506      }
 507  
 508      pub fn is_indexing(&self) -> bool {
 509          self.state.read().indexing_in_progress
 510      }
 511  
 512      pub fn indexing_progress(&self) -> u8 {
 513          self.state.read().progress_percent()
 514      }
 515  
 516      pub fn get_state(&self) -> IndexStateSnapshot {
 517          let state = self.state.read();
 518          IndexStateSnapshot {
 519              total_files: state.total_files,
 520              indexed_files: state.indexed_count(),
 521              indexing_in_progress: state.indexing_in_progress,
 522              last_full_index: state.last_full_index,
 523          }
 524      }
 525  }
 526  
 527  impl Default for WorkspaceIndex {
 528      fn default() -> Self {
 529          Self::new()
 530      }
 531  }
 532  
 533  #[derive(Debug, Clone)]
 534  pub struct IndexStats {
 535      pub total_files: usize,
 536      pub total_env_vars: usize,
 537      pub env_files: usize,
 538  }
 539  
 540  #[derive(Debug, Clone)]
 541  pub struct IndexStateSnapshot {
 542      pub total_files: usize,
 543      pub indexed_files: usize,
 544      pub indexing_in_progress: bool,
 545      pub last_full_index: Option<SystemTime>,
 546  }
 547  
 548  #[cfg(test)]
 549  mod tests {
 550      use super::*;
 551  
 552      fn url(path: &str) -> Url {
 553          Url::parse(&format!("file://{}", path)).unwrap()
 554      }
 555  
 556      fn make_entry(env_vars: &[&str], is_env_file: bool) -> FileIndexEntry {
 557          FileIndexEntry {
 558              mtime: SystemTime::now(),
 559              env_vars: env_vars.iter().map(|s| CompactString::from(*s)).collect(),
 560              is_env_file,
 561              path: PathBuf::from("/test"),
 562          }
 563      }
 564  
 565      #[test]
 566      fn test_update_file_adds_reverse_index() {
 567          let index = WorkspaceIndex::new();
 568          let uri = url("/test.js");
 569  
 570          index.update_file(&uri, make_entry(&["API_KEY", "DB_URL"], false));
 571  
 572          assert_eq!(index.files_for_env_var("API_KEY"), vec![uri.clone()]);
 573          assert_eq!(index.files_for_env_var("DB_URL"), vec![uri.clone()]);
 574          assert!(index.files_for_env_var("NONEXISTENT").is_empty());
 575      }
 576  
 577      #[test]
 578      fn test_update_file_removes_old_associations() {
 579          let index = WorkspaceIndex::new();
 580          let uri = url("/test.js");
 581  
 582          index.update_file(&uri, make_entry(&["OLD_VAR"], false));
 583          assert!(!index.files_for_env_var("OLD_VAR").is_empty());
 584  
 585          index.update_file(&uri, make_entry(&["NEW_VAR"], false));
 586  
 587          assert!(index.files_for_env_var("OLD_VAR").is_empty());
 588          assert!(!index.files_for_env_var("NEW_VAR").is_empty());
 589      }
 590  
 591      #[test]
 592      fn test_remove_file_cleans_up() {
 593          let index = WorkspaceIndex::new();
 594          let uri = url("/test.js");
 595  
 596          index.update_file(&uri, make_entry(&["API_KEY"], false));
 597          assert!(!index.files_for_env_var("API_KEY").is_empty());
 598  
 599          index.remove_file(&uri);
 600  
 601          assert!(index.files_for_env_var("API_KEY").is_empty());
 602          assert!(!index.is_file_indexed(&uri));
 603      }
 604  
 605      #[test]
 606      fn test_multiple_files_same_env_var() {
 607          let index = WorkspaceIndex::new();
 608          let uri1 = url("/a.js");
 609          let uri2 = url("/b.ts");
 610          let uri3 = url("/c.py");
 611  
 612          index.update_file(&uri1, make_entry(&["API_KEY"], false));
 613          index.update_file(&uri2, make_entry(&["API_KEY", "DB_URL"], false));
 614          index.update_file(&uri3, make_entry(&["API_KEY"], false));
 615  
 616          let files = index.files_for_env_var("API_KEY");
 617          assert_eq!(files.len(), 3);
 618          assert!(files.contains(&uri1));
 619          assert!(files.contains(&uri2));
 620          assert!(files.contains(&uri3));
 621  
 622          let db_files = index.files_for_env_var("DB_URL");
 623          assert_eq!(db_files.len(), 1);
 624          assert!(db_files.contains(&uri2));
 625      }
 626  
 627      #[test]
 628      fn test_stats() {
 629          let index = WorkspaceIndex::new();
 630  
 631          index.update_file(&url("/a.js"), make_entry(&["VAR1", "VAR2"], false));
 632          index.update_file(&url("/b.ts"), make_entry(&["VAR1"], false));
 633          index.update_file(&url("/.env"), make_entry(&["VAR1", "VAR2", "VAR3"], true));
 634  
 635          let stats = index.stats();
 636          assert_eq!(stats.total_files, 3);
 637          assert_eq!(stats.total_env_vars, 3);
 638          assert_eq!(stats.env_files, 1);
 639      }
 640  
 641      #[test]
 642      fn test_is_file_stale() {
 643          let index = WorkspaceIndex::new();
 644          let uri = url("/test.js");
 645  
 646          let old_time = SystemTime::UNIX_EPOCH;
 647          let entry = FileIndexEntry {
 648              mtime: old_time,
 649              env_vars: FxHashSet::default(),
 650              is_env_file: false,
 651              path: PathBuf::from("/test.js"),
 652          };
 653          index.update_file(&uri, entry);
 654  
 655          assert!(index.is_file_stale(&uri, SystemTime::now()));
 656  
 657          assert!(!index.is_file_stale(&uri, old_time));
 658      }
 659  
 660      #[test]
 661      fn test_all_env_vars() {
 662          let index = WorkspaceIndex::new();
 663  
 664          index.update_file(&url("/a.js"), make_entry(&["VAR1", "VAR2"], false));
 665          index.update_file(&url("/b.ts"), make_entry(&["VAR3"], false));
 666  
 667          let vars = index.all_env_vars();
 668          assert_eq!(vars.len(), 3);
 669      }
 670  
 671      #[test]
 672      fn test_indexing_state() {
 673          let index = WorkspaceIndex::new();
 674  
 675          assert!(!index.is_indexing());
 676  
 677          index.set_total_files(100);
 678          index.set_indexing(true);
 679          assert!(index.is_indexing());
 680          assert_eq!(index.indexing_progress(), 0);
 681  
 682          for _ in 0..50 {
 683              index.increment_indexed();
 684          }
 685          assert_eq!(index.indexing_progress(), 50);
 686  
 687          index.set_indexing(false);
 688          assert!(!index.is_indexing());
 689  
 690          let state = index.get_state();
 691          assert!(state.last_full_index.is_some());
 692      }
 693  
 694      #[test]
 695      fn test_clear() {
 696          let index = WorkspaceIndex::new();
 697  
 698          index.update_file(&url("/a.js"), make_entry(&["VAR1"], false));
 699          index.update_file(&url("/b.ts"), make_entry(&["VAR2"], false));
 700  
 701          assert_eq!(index.stats().total_files, 2);
 702  
 703          index.clear();
 704  
 705          assert_eq!(index.stats().total_files, 0);
 706          assert_eq!(index.stats().total_env_vars, 0);
 707      }
 708  
 709      #[test]
 710      fn test_env_vars_in_file() {
 711          let index = WorkspaceIndex::new();
 712          let uri = url("/test.js");
 713  
 714          index.update_file(&uri, make_entry(&["VAR1", "VAR2"], false));
 715  
 716          let vars = index.env_vars_in_file(&uri).unwrap();
 717          assert!(vars.contains("VAR1"));
 718          assert!(vars.contains("VAR2"));
 719          assert!(!vars.contains("VAR3"));
 720  
 721          assert!(index.env_vars_in_file(&url("/nonexistent.js")).is_none());
 722      }
 723  
 724      use crate::types::{ExportResolution, ModuleExport};
 725      use std::collections::HashMap;
 726      use tower_lsp::lsp_types::Range;
 727  
 728      fn make_export_entry(exports: &[(&str, &str)]) -> FileExportEntry {
 729          let mut named_exports = HashMap::new();
 730          for (name, env_var) in exports {
 731              named_exports.insert(
 732                  CompactString::from(*name),
 733                  ModuleExport {
 734                      exported_name: CompactString::from(*name),
 735                      local_name: None,
 736                      resolution: ExportResolution::EnvVar {
 737                          name: CompactString::from(*env_var),
 738                      },
 739                      declaration_range: Range::default(),
 740                      is_default: false,
 741                  },
 742              );
 743          }
 744          FileExportEntry {
 745              named_exports,
 746              default_export: None,
 747              wildcard_reexports: vec![],
 748          }
 749      }
 750  
 751      #[test]
 752      fn test_update_exports() {
 753          let index = WorkspaceIndex::new();
 754          let uri = url("/config.js");
 755  
 756          let exports = make_export_entry(&[("dbUrl", "DATABASE_URL")]);
 757          index.update_exports(&uri, exports);
 758  
 759          assert!(index.has_exports(&uri));
 760          let retrieved = index.get_exports(&uri).unwrap();
 761          assert!(retrieved.named_exports.contains_key("dbUrl"));
 762      }
 763  
 764      #[test]
 765      fn test_files_exporting_env_var() {
 766          let index = WorkspaceIndex::new();
 767          let uri1 = url("/config.js");
 768          let uri2 = url("/utils.js");
 769  
 770          index.update_exports(&uri1, make_export_entry(&[("dbUrl", "DATABASE_URL")]));
 771          index.update_exports(
 772              &uri2,
 773              make_export_entry(&[("apiKey", "API_KEY"), ("dbConn", "DATABASE_URL")]),
 774          );
 775  
 776          let db_files = index.files_exporting_env_var("DATABASE_URL");
 777          assert_eq!(db_files.len(), 2);
 778          assert!(db_files.contains(&uri1));
 779          assert!(db_files.contains(&uri2));
 780  
 781          let api_files = index.files_exporting_env_var("API_KEY");
 782          assert_eq!(api_files.len(), 1);
 783          assert!(api_files.contains(&uri2));
 784      }
 785  
 786      #[test]
 787      fn test_update_exports_removes_old() {
 788          let index = WorkspaceIndex::new();
 789          let uri = url("/config.js");
 790  
 791          index.update_exports(&uri, make_export_entry(&[("oldVar", "OLD_VAR")]));
 792          assert!(!index.files_exporting_env_var("OLD_VAR").is_empty());
 793  
 794          index.update_exports(&uri, make_export_entry(&[("newVar", "NEW_VAR")]));
 795  
 796          assert!(index.files_exporting_env_var("OLD_VAR").is_empty());
 797          assert!(!index.files_exporting_env_var("NEW_VAR").is_empty());
 798      }
 799  
 800      #[test]
 801      fn test_remove_file_clears_exports() {
 802          let index = WorkspaceIndex::new();
 803          let uri = url("/config.js");
 804  
 805          index.update_exports(&uri, make_export_entry(&[("dbUrl", "DATABASE_URL")]));
 806          assert!(!index.files_exporting_env_var("DATABASE_URL").is_empty());
 807  
 808          index.remove_file(&uri);
 809  
 810          assert!(index.files_exporting_env_var("DATABASE_URL").is_empty());
 811          assert!(!index.has_exports(&uri));
 812      }
 813  
 814      #[test]
 815      fn test_module_resolution_cache() {
 816          let index = WorkspaceIndex::new();
 817          let importer = url("/app.js");
 818          let resolved = url("/config.js");
 819  
 820          assert!(index
 821              .cached_module_resolution(&importer, "./config")
 822              .is_none());
 823  
 824          index.cache_module_resolution(&importer, "./config", Some(resolved.clone()));
 825  
 826          let cached = index.cached_module_resolution(&importer, "./config");
 827          assert_eq!(cached, Some(Some(resolved.clone())));
 828  
 829          index.cache_module_resolution(&importer, "./missing", None);
 830          let cached_none = index.cached_module_resolution(&importer, "./missing");
 831          assert_eq!(cached_none, Some(None));
 832      }
 833  
 834      #[test]
 835      fn test_invalidate_resolution_cache() {
 836          let index = WorkspaceIndex::new();
 837          let app = url("/app.js");
 838          let config = url("/config.js");
 839          let utils = url("/utils.js");
 840          let other = url("/other.js");
 841  
 842          // Cache some resolutions
 843          // app -> config (resolves TO config)
 844          index.cache_module_resolution(&app, "./config", Some(config.clone()));
 845          // app -> utils (resolves TO utils)
 846          index.cache_module_resolution(&app, "./utils", Some(utils.clone()));
 847          // config -> utils (resolves FROM config, TO utils)
 848          index.cache_module_resolution(&config, "./utils", Some(utils.clone()));
 849          // other -> app (unrelated to config)
 850          index.cache_module_resolution(&other, "./app", Some(app.clone()));
 851  
 852          // Verify all cached
 853          assert!(index.cached_module_resolution(&app, "./config").is_some());
 854          assert!(index.cached_module_resolution(&app, "./utils").is_some());
 855          assert!(index.cached_module_resolution(&config, "./utils").is_some());
 856          assert!(index.cached_module_resolution(&other, "./app").is_some());
 857  
 858          // Invalidate config: should remove entries FROM config and TO config
 859          index.invalidate_resolution_cache(&config);
 860  
 861          // Entries TO config should be invalidated (app -> config)
 862          assert!(index.cached_module_resolution(&app, "./config").is_none());
 863          // Entries FROM config should be invalidated (config -> utils)
 864          assert!(index.cached_module_resolution(&config, "./utils").is_none());
 865          // Unrelated entries should remain (app -> utils, other -> app)
 866          assert!(index.cached_module_resolution(&app, "./utils").is_some());
 867          assert!(index.cached_module_resolution(&other, "./app").is_some());
 868      }
 869  
 870      #[test]
 871      fn test_invalidate_resolution_cache_none_values() {
 872          let index = WorkspaceIndex::new();
 873          let app = url("/app.js");
 874          let config = url("/config.js");
 875  
 876          // Cache a failed resolution (None value)
 877          index.cache_module_resolution(&app, "./missing", None);
 878          // Cache a successful resolution
 879          index.cache_module_resolution(&config, "./missing", None);
 880  
 881          // Verify both cached
 882          assert!(index.cached_module_resolution(&app, "./missing").is_some());
 883          assert!(index
 884              .cached_module_resolution(&config, "./missing")
 885              .is_some());
 886  
 887          // Invalidate app: should remove entries FROM app
 888          index.invalidate_resolution_cache(&app);
 889  
 890          // Entry FROM app should be invalidated
 891          assert!(index.cached_module_resolution(&app, "./missing").is_none());
 892          // Entry FROM config should remain
 893          assert!(index
 894              .cached_module_resolution(&config, "./missing")
 895              .is_some());
 896      }
 897  
 898      #[test]
 899      fn test_clear_clears_exports() {
 900          let index = WorkspaceIndex::new();
 901  
 902          index.update_exports(
 903              &url("/config.js"),
 904              make_export_entry(&[("dbUrl", "DATABASE_URL")]),
 905          );
 906          index.cache_module_resolution(&url("/app.js"), "./config", Some(url("/config.js")));
 907  
 908          index.clear();
 909  
 910          assert!(index.files_exporting_env_var("DATABASE_URL").is_empty());
 911          assert!(index
 912              .cached_module_resolution(&url("/app.js"), "./config")
 913              .is_none());
 914      }
 915  
 916      #[test]
 917      fn test_all_exported_env_vars() {
 918          let index = WorkspaceIndex::new();
 919  
 920          index.update_exports(
 921              &url("/config.js"),
 922              make_export_entry(&[("db", "DATABASE_URL"), ("api", "API_KEY")]),
 923          );
 924          index.update_exports(
 925              &url("/utils.js"),
 926              make_export_entry(&[("secret", "SECRET")]),
 927          );
 928  
 929          let vars = index.all_exported_env_vars();
 930          assert_eq!(vars.len(), 3);
 931      }
 932  
 933      // =========================================================================
 934      // Task 3: Module Dependency Graph Tests - update_dependency_graph
 935      // =========================================================================
 936  
 937      #[test]
 938      fn test_update_dependency_graph_single_dependency() {
 939          let index = WorkspaceIndex::new();
 940          let app = url("/app.js");
 941          let config = url("/config.js");
 942  
 943          index.update_dependency_graph(&app, vec![config.clone()]);
 944  
 945          // Forward edge: app depends on config
 946          let deps = index.get_dependencies(&app);
 947          assert_eq!(deps.len(), 1);
 948          assert!(deps.contains(&config));
 949  
 950          // Reverse edge: config is depended on by app
 951          let dependents = index.get_dependents(&config);
 952          assert_eq!(dependents.len(), 1);
 953          assert!(dependents.contains(&app));
 954      }
 955  
 956      #[test]
 957      fn test_update_dependency_graph_multiple_dependencies() {
 958          let index = WorkspaceIndex::new();
 959          let app = url("/app.js");
 960          let config = url("/config.js");
 961          let utils = url("/utils.js");
 962          let helpers = url("/helpers.js");
 963  
 964          index.update_dependency_graph(&app, vec![config.clone(), utils.clone(), helpers.clone()]);
 965  
 966          let deps = index.get_dependencies(&app);
 967          assert_eq!(deps.len(), 3);
 968          assert!(deps.contains(&config));
 969          assert!(deps.contains(&utils));
 970          assert!(deps.contains(&helpers));
 971  
 972          // Each dependency should have app as a dependent
 973          assert!(index.get_dependents(&config).contains(&app));
 974          assert!(index.get_dependents(&utils).contains(&app));
 975          assert!(index.get_dependents(&helpers).contains(&app));
 976      }
 977  
 978      #[test]
 979      fn test_update_dependency_graph_replaces_old_dependencies() {
 980          let index = WorkspaceIndex::new();
 981          let app = url("/app.js");
 982          let old_dep = url("/old.js");
 983          let new_dep = url("/new.js");
 984  
 985          // First update with old dependency
 986          index.update_dependency_graph(&app, vec![old_dep.clone()]);
 987          assert!(index.get_dependencies(&app).contains(&old_dep));
 988          assert!(index.get_dependents(&old_dep).contains(&app));
 989  
 990          // Update with new dependency (replaces old)
 991          index.update_dependency_graph(&app, vec![new_dep.clone()]);
 992  
 993          // Old dependency should be removed
 994          assert!(!index.get_dependencies(&app).contains(&old_dep));
 995          assert!(!index.get_dependents(&old_dep).contains(&app));
 996  
 997          // New dependency should be present
 998          assert!(index.get_dependencies(&app).contains(&new_dep));
 999          assert!(index.get_dependents(&new_dep).contains(&app));
1000      }
1001  
1002      #[test]
1003      fn test_update_dependency_graph_empty_dependencies() {
1004          let index = WorkspaceIndex::new();
1005          let app = url("/app.js");
1006          let config = url("/config.js");
1007  
1008          // First add some dependencies
1009          index.update_dependency_graph(&app, vec![config.clone()]);
1010          assert!(!index.get_dependencies(&app).is_empty());
1011  
1012          // Update with empty dependencies
1013          index.update_dependency_graph(&app, vec![]);
1014  
1015          // All dependencies should be cleared
1016          assert!(index.get_dependencies(&app).is_empty());
1017          assert!(!index.get_dependents(&config).contains(&app));
1018      }
1019  
1020      #[test]
1021      fn test_update_dependency_graph_bidirectional_consistency() {
1022          let index = WorkspaceIndex::new();
1023          let a = url("/a.js");
1024          let b = url("/b.js");
1025          let c = url("/c.js");
1026  
1027          // a depends on b and c
1028          index.update_dependency_graph(&a, vec![b.clone(), c.clone()]);
1029          // b depends on c
1030          index.update_dependency_graph(&b, vec![c.clone()]);
1031  
1032          // Check forward edges
1033          assert!(index.get_dependencies(&a).contains(&b));
1034          assert!(index.get_dependencies(&a).contains(&c));
1035          assert!(index.get_dependencies(&b).contains(&c));
1036  
1037          // Check reverse edges
1038          let c_dependents = index.get_dependents(&c);
1039          assert!(c_dependents.contains(&a));
1040          assert!(c_dependents.contains(&b));
1041  
1042          let b_dependents = index.get_dependents(&b);
1043          assert!(b_dependents.contains(&a));
1044      }
1045  
1046      #[test]
1047      fn test_update_dependency_graph_circular_dependencies() {
1048          let index = WorkspaceIndex::new();
1049          let a = url("/a.js");
1050          let b = url("/b.js");
1051  
1052          // Create circular dependency: a -> b -> a
1053          index.update_dependency_graph(&a, vec![b.clone()]);
1054          index.update_dependency_graph(&b, vec![a.clone()]);
1055  
1056          // Both should have each other as dependencies
1057          assert!(index.get_dependencies(&a).contains(&b));
1058          assert!(index.get_dependencies(&b).contains(&a));
1059  
1060          // Both should have each other as dependents
1061          assert!(index.get_dependents(&a).contains(&b));
1062          assert!(index.get_dependents(&b).contains(&a));
1063      }
1064  
1065      // =========================================================================
1066      // Task 3: Module Dependency Graph Tests - invalidate_for_file_change
1067      // =========================================================================
1068  
1069      #[test]
1070      fn test_invalidate_for_file_change_marks_dependents_dirty() {
1071          let index = WorkspaceIndex::new();
1072          let config = url("/config.js");
1073          let app1 = url("/app1.js");
1074          let app2 = url("/app2.js");
1075  
1076          // app1 and app2 both depend on config
1077          index.update_dependency_graph(&app1, vec![config.clone()]);
1078          index.update_dependency_graph(&app2, vec![config.clone()]);
1079  
1080          // Change config - should mark app1 and app2 as dirty
1081          index.invalidate_for_file_change(&config);
1082  
1083          let dirty = index.get_dirty_files();
1084          assert!(dirty.contains(&app1));
1085          assert!(dirty.contains(&app2));
1086      }
1087  
1088      #[test]
1089      fn test_invalidate_for_file_change_no_dependents() {
1090          let index = WorkspaceIndex::new();
1091          let config = url("/config.js");
1092  
1093          // config has no dependents
1094          index.invalidate_for_file_change(&config);
1095  
1096          // No dirty files
1097          assert!(index.get_dirty_files().is_empty());
1098      }
1099  
1100      #[test]
1101      fn test_invalidate_for_file_change_calls_invalidate_resolution_cache() {
1102          let index = WorkspaceIndex::new();
1103          let app = url("/app.js");
1104          let config = url("/config.js");
1105  
1106          // Cache a resolution
1107          index.cache_module_resolution(&app, "./config", Some(config.clone()));
1108          assert!(index.cached_module_resolution(&app, "./config").is_some());
1109  
1110          // Invalidate config
1111          index.invalidate_for_file_change(&config);
1112  
1113          // Cache entry that resolved TO config should be invalidated
1114          assert!(index.cached_module_resolution(&app, "./config").is_none());
1115      }
1116  
1117      #[test]
1118      fn test_invalidate_for_file_change_only_direct_dependents() {
1119          let index = WorkspaceIndex::new();
1120          let a = url("/a.js");
1121          let b = url("/b.js");
1122          let c = url("/c.js");
1123  
1124          // a -> b -> c (a depends on b, b depends on c)
1125          index.update_dependency_graph(&a, vec![b.clone()]);
1126          index.update_dependency_graph(&b, vec![c.clone()]);
1127  
1128          // Change c - should only mark b as dirty (direct dependent), not a
1129          index.invalidate_for_file_change(&c);
1130  
1131          let dirty = index.get_dirty_files();
1132          assert!(dirty.contains(&b));
1133          assert!(!dirty.contains(&a)); // a is transitive, not direct
1134      }
1135  
1136      // =========================================================================
1137      // Task 3: Module Dependency Graph Tests - Dirty Files Management
1138      // =========================================================================
1139  
1140      #[test]
1141      fn test_get_dirty_files_empty() {
1142          let index = WorkspaceIndex::new();
1143          assert!(index.get_dirty_files().is_empty());
1144      }
1145  
1146      #[test]
1147      fn test_get_dirty_files_after_invalidation() {
1148          let index = WorkspaceIndex::new();
1149          let config = url("/config.js");
1150          let app1 = url("/app1.js");
1151          let app2 = url("/app2.js");
1152          let app3 = url("/app3.js");
1153  
1154          // Set up dependencies
1155          index.update_dependency_graph(&app1, vec![config.clone()]);
1156          index.update_dependency_graph(&app2, vec![config.clone()]);
1157          index.update_dependency_graph(&app3, vec![config.clone()]);
1158  
1159          // Invalidate
1160          index.invalidate_for_file_change(&config);
1161  
1162          let dirty = index.get_dirty_files();
1163          assert_eq!(dirty.len(), 3);
1164          assert!(dirty.contains(&app1));
1165          assert!(dirty.contains(&app2));
1166          assert!(dirty.contains(&app3));
1167      }
1168  
1169      #[test]
1170      fn test_clear_dirty_single_file() {
1171          let index = WorkspaceIndex::new();
1172          let config = url("/config.js");
1173          let app1 = url("/app1.js");
1174          let app2 = url("/app2.js");
1175  
1176          index.update_dependency_graph(&app1, vec![config.clone()]);
1177          index.update_dependency_graph(&app2, vec![config.clone()]);
1178          index.invalidate_for_file_change(&config);
1179  
1180          assert!(index.get_dirty_files().contains(&app1));
1181          assert!(index.get_dirty_files().contains(&app2));
1182  
1183          // Clear only app1
1184          index.clear_dirty(&app1);
1185  
1186          assert!(!index.get_dirty_files().contains(&app1));
1187          assert!(index.get_dirty_files().contains(&app2));
1188      }
1189  
1190      #[test]
1191      fn test_has_dirty_files_true_and_false() {
1192          let index = WorkspaceIndex::new();
1193          let config = url("/config.js");
1194          let app = url("/app.js");
1195  
1196          // Initially no dirty files
1197          assert!(!index.has_dirty_files());
1198  
1199          index.update_dependency_graph(&app, vec![config.clone()]);
1200          index.invalidate_for_file_change(&config);
1201  
1202          // Now has dirty files
1203          assert!(index.has_dirty_files());
1204  
1205          // Clear all dirty files
1206          index.clear_dirty(&app);
1207  
1208          // No more dirty files
1209          assert!(!index.has_dirty_files());
1210      }
1211  
1212      #[test]
1213      fn test_dirty_count() {
1214          let index = WorkspaceIndex::new();
1215          let config = url("/config.js");
1216          let app1 = url("/app1.js");
1217          let app2 = url("/app2.js");
1218          let app3 = url("/app3.js");
1219  
1220          assert_eq!(index.dirty_count(), 0);
1221  
1222          index.update_dependency_graph(&app1, vec![config.clone()]);
1223          index.update_dependency_graph(&app2, vec![config.clone()]);
1224          index.update_dependency_graph(&app3, vec![config.clone()]);
1225          index.invalidate_for_file_change(&config);
1226  
1227          assert_eq!(index.dirty_count(), 3);
1228  
1229          index.clear_dirty(&app1);
1230          assert_eq!(index.dirty_count(), 2);
1231  
1232          index.clear_dirty(&app2);
1233          index.clear_dirty(&app3);
1234          assert_eq!(index.dirty_count(), 0);
1235      }
1236  
1237      // =========================================================================
1238      // Task 3: Module Dependency Graph Tests - remove_from_dependency_graph
1239      // =========================================================================
1240  
1241      #[test]
1242      fn test_remove_from_dependency_graph_removes_forward_edges() {
1243          let index = WorkspaceIndex::new();
1244          let app = url("/app.js");
1245          let config = url("/config.js");
1246          let utils = url("/utils.js");
1247  
1248          // app depends on config and utils
1249          index.update_dependency_graph(&app, vec![config.clone(), utils.clone()]);
1250  
1251          // Remove app from dependency graph
1252          index.remove_from_dependency_graph(&app);
1253  
1254          // app should have no dependencies
1255          assert!(index.get_dependencies(&app).is_empty());
1256  
1257          // config and utils should not have app as a dependent
1258          assert!(!index.get_dependents(&config).contains(&app));
1259          assert!(!index.get_dependents(&utils).contains(&app));
1260      }
1261  
1262      #[test]
1263      fn test_remove_from_dependency_graph_removes_reverse_edges() {
1264          let index = WorkspaceIndex::new();
1265          let config = url("/config.js");
1266          let app1 = url("/app1.js");
1267          let app2 = url("/app2.js");
1268  
1269          // app1 and app2 depend on config
1270          index.update_dependency_graph(&app1, vec![config.clone()]);
1271          index.update_dependency_graph(&app2, vec![config.clone()]);
1272  
1273          // Remove config
1274          index.remove_from_dependency_graph(&config);
1275  
1276          // config should have no dependents
1277          assert!(index.get_dependents(&config).is_empty());
1278      }
1279  
1280      #[test]
1281      fn test_remove_from_dependency_graph_marks_importers_dirty() {
1282          let index = WorkspaceIndex::new();
1283          let config = url("/config.js");
1284          let app = url("/app.js");
1285  
1286          // app depends on config
1287          index.update_dependency_graph(&app, vec![config.clone()]);
1288  
1289          // Remove config (simulating file deletion)
1290          index.remove_from_dependency_graph(&config);
1291  
1292          // app should be marked as dirty because it imported config
1293          assert!(index.get_dirty_files().contains(&app));
1294      }
1295  
1296      #[test]
1297      fn test_remove_from_dependency_graph_cleans_empty_dependents() {
1298          let index = WorkspaceIndex::new();
1299          let config = url("/config.js");
1300          let app = url("/app.js");
1301  
1302          // app depends on config
1303          index.update_dependency_graph(&app, vec![config.clone()]);
1304  
1305          // Verify config has dependents
1306          assert!(!index.get_dependents(&config).is_empty());
1307  
1308          // Remove app
1309          index.remove_from_dependency_graph(&app);
1310  
1311          // config's dependents list should be cleaned up (empty)
1312          // Note: The entry might be removed entirely or left empty
1313          assert!(index.get_dependents(&config).is_empty());
1314      }
1315  
1316      #[test]
1317      fn test_remove_file_calls_remove_from_dependency_graph() {
1318          let index = WorkspaceIndex::new();
1319          let config = url("/config.js");
1320          let app = url("/app.js");
1321  
1322          // Add file entry and dependency
1323          index.update_file(&config, make_entry(&["DB_URL"], false));
1324          index.update_dependency_graph(&app, vec![config.clone()]);
1325  
1326          // Verify setup
1327          assert!(index.is_file_indexed(&config));
1328          assert!(index.get_dependents(&config).contains(&app));
1329  
1330          // Remove file
1331          index.remove_file(&config);
1332  
1333          // File should be removed and dependency graph updated
1334          assert!(!index.is_file_indexed(&config));
1335          assert!(!index.get_dependents(&config).contains(&app));
1336          // app should be marked dirty
1337          assert!(index.get_dirty_files().contains(&app));
1338      }
1339  
1340      // =========================================================================
1341      // Task 3: Module Dependency Graph Tests - Getters
1342      // =========================================================================
1343  
1344      #[test]
1345      fn test_get_dependencies_empty() {
1346          let index = WorkspaceIndex::new();
1347          let unknown = url("/unknown.js");
1348  
1349          // Unknown file should return empty list
1350          let deps = index.get_dependencies(&unknown);
1351          assert!(deps.is_empty());
1352      }
1353  
1354      #[test]
1355      fn test_get_dependencies_returns_correct_list() {
1356          let index = WorkspaceIndex::new();
1357          let app = url("/app.js");
1358          let dep1 = url("/dep1.js");
1359          let dep2 = url("/dep2.js");
1360          let dep3 = url("/dep3.js");
1361  
1362          index.update_dependency_graph(&app, vec![dep1.clone(), dep2.clone(), dep3.clone()]);
1363  
1364          let deps = index.get_dependencies(&app);
1365          assert_eq!(deps.len(), 3);
1366          assert!(deps.contains(&dep1));
1367          assert!(deps.contains(&dep2));
1368          assert!(deps.contains(&dep3));
1369      }
1370  
1371      #[test]
1372      fn test_get_dependents_empty() {
1373          let index = WorkspaceIndex::new();
1374          let config = url("/config.js");
1375  
1376          // File with no importers should return empty list
1377          let dependents = index.get_dependents(&config);
1378          assert!(dependents.is_empty());
1379      }
1380  
1381      #[test]
1382      fn test_get_dependents_returns_correct_list() {
1383          let index = WorkspaceIndex::new();
1384          let config = url("/config.js");
1385          let app1 = url("/app1.js");
1386          let app2 = url("/app2.js");
1387          let app3 = url("/app3.js");
1388  
1389          index.update_dependency_graph(&app1, vec![config.clone()]);
1390          index.update_dependency_graph(&app2, vec![config.clone()]);
1391          index.update_dependency_graph(&app3, vec![config.clone()]);
1392  
1393          let dependents = index.get_dependents(&config);
1394          assert_eq!(dependents.len(), 3);
1395          assert!(dependents.contains(&app1));
1396          assert!(dependents.contains(&app2));
1397          assert!(dependents.contains(&app3));
1398      }
1399  
1400      #[test]
1401      fn test_clear_clears_dependency_graph() {
1402          let index = WorkspaceIndex::new();
1403          let app = url("/app.js");
1404          let config = url("/config.js");
1405  
1406          // Set up some state
1407          index.update_file(&app, make_entry(&["VAR1"], false));
1408          index.update_dependency_graph(&app, vec![config.clone()]);
1409          index.invalidate_for_file_change(&config);
1410  
1411          // Verify state
1412          assert!(!index.get_dependencies(&app).is_empty());
1413          assert!(index.has_dirty_files());
1414  
1415          // Clear everything
1416          index.clear();
1417  
1418          // Dependency graph should be cleared
1419          assert!(index.get_dependencies(&app).is_empty());
1420          assert!(index.get_dependents(&config).is_empty());
1421          assert!(!index.has_dirty_files());
1422      }
1423  
1424      // =========================================================================
1425      // Additional Edge Case Tests
1426      // =========================================================================
1427  
1428      #[test]
1429      fn test_dependency_graph_self_reference() {
1430          let index = WorkspaceIndex::new();
1431          let file = url("/file.js");
1432  
1433          // File depending on itself (unusual but possible)
1434          index.update_dependency_graph(&file, vec![file.clone()]);
1435  
1436          assert!(index.get_dependencies(&file).contains(&file));
1437          assert!(index.get_dependents(&file).contains(&file));
1438      }
1439  
1440      #[test]
1441      fn test_dependency_graph_update_partial() {
1442          let index = WorkspaceIndex::new();
1443          let app = url("/app.js");
1444          let dep1 = url("/dep1.js");
1445          let dep2 = url("/dep2.js");
1446          let dep3 = url("/dep3.js");
1447  
1448          // Initial: app depends on dep1 and dep2
1449          index.update_dependency_graph(&app, vec![dep1.clone(), dep2.clone()]);
1450  
1451          // Update: app now depends on dep2 and dep3 (dep1 removed, dep3 added)
1452          index.update_dependency_graph(&app, vec![dep2.clone(), dep3.clone()]);
1453  
1454          let deps = index.get_dependencies(&app);
1455          assert!(!deps.contains(&dep1));
1456          assert!(deps.contains(&dep2));
1457          assert!(deps.contains(&dep3));
1458  
1459          // dep1 should no longer have app as a dependent
1460          assert!(!index.get_dependents(&dep1).contains(&app));
1461      }
1462  
1463      #[test]
1464      fn test_multiple_invalidations_same_file() {
1465          let index = WorkspaceIndex::new();
1466          let config = url("/config.js");
1467          let app = url("/app.js");
1468  
1469          index.update_dependency_graph(&app, vec![config.clone()]);
1470  
1471          // Multiple invalidations should not duplicate dirty entries
1472          index.invalidate_for_file_change(&config);
1473          index.invalidate_for_file_change(&config);
1474          index.invalidate_for_file_change(&config);
1475  
1476          // Should still only have one dirty entry for app
1477          let dirty = index.get_dirty_files();
1478          assert_eq!(dirty.iter().filter(|u| *u == &app).count(), 1);
1479      }
1480  }