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 }