//! Channel-based IndexManager for incremental reindexing. //! //! This module provides a clean, channel-based architecture for managing //! the code graph index with support for incremental updates from file system events. //! //! ## Architecture //! //! ```text //! ┌─────────────────┐ FileEvent ┌─────────────────┐ //! │ FSNotify │ ──────────────────▶│ IndexManager │ //! │ (debounced) │ │ (owns index) │ //! └─────────────────┘ └────────┬────────┘ //! │ //! ▼ //! ┌─────────────────┐ //! │ ScopeGraphIndex │ //! │ (mutations) │ //! └─────────────────┘ //! ``` //! //! The IndexManager runs in its own task and processes events sequentially, //! eliminating the need for Arc around the index. //! //! **Note**: Debouncing is handled externally by notify-debouncer-full (FSEvents). //! Events arriving here are already debounced, so we process them immediately. //! //! ## Deduplication //! //! The module ensures that: //! - At most one `IndexManager` exists per workspace per process (via `ACTIVE_MANAGERS`) //! - Concurrent index operations are coordinated via locks (see `manager::lock`) //! - Background refresh operations are deduplicated across processes use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Weak}; use std::time::Instant; /// Maximum file size we'll attempt to index (5 MB). /// Files larger than this are skipped to avoid pathological memory usage /// from tree-sitter AST construction on huge or binary files. pub const MAX_INDEXABLE_FILE_SIZE: u64 = 5 * 1024 * 1024; use crossbeam::channel::{self, Receiver, Sender}; use dashmap::DashMap; use kigi_paths::to_relative_path; use once_cell::sync::Lazy; use crate::languages::LanguageRegistry; use crate::manager::IndexBuilder; use crate::scope_graph::ScopeGraphIndex; use crate::types::{FileMeta, IndexStats}; /// Global registry of active IndexManager handles per workspace. /// /// This ensures that at most one IndexManager exists per workspace per process. /// Uses `Weak` references so handles are automatically cleaned up when dropped. static ACTIVE_MANAGERS: Lazy>> = Lazy::new(DashMap::new); /// File system event kind - maps from notify::EventKind. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum FileEventKind { /// File was created Created, /// File was modified Modified, /// File was deleted Removed, /// File was renamed (old path in the event, new path in paths[1] if available) Renamed, } /// A file system event that triggers index updates. #[derive(Debug, Clone)] pub struct FileEvent { /// The affected file path(s) pub paths: Vec, /// The kind of event pub kind: FileEventKind, } impl FileEvent { /// Create a new file event. pub fn new(paths: Vec, kind: FileEventKind) -> Self { Self { paths, kind } } /// Create a "file created" event. pub fn created(path: PathBuf) -> Self { Self::new(vec![path], FileEventKind::Created) } /// Create a "file modified" event. pub fn modified(path: PathBuf) -> Self { Self::new(vec![path], FileEventKind::Modified) } /// Create a "file removed" event. pub fn removed(path: PathBuf) -> Self { Self::new(vec![path], FileEventKind::Removed) } /// Create a "file renamed" event. pub fn renamed(from: PathBuf, to: PathBuf) -> Self { Self::new(vec![from, to], FileEventKind::Renamed) } } /// Commands that can be sent to the IndexManager. pub enum IndexCommand { /// Process a file event FileEvent(FileEvent), /// Process a batch of file events (more efficient) FileEventBatch(Vec), /// Rebuild the entire index Rebuild, /// Get a shared snapshot of the current index (response sent via oneshot). /// Returns an `Arc` — cloning the sender is zero-cost when no mutations /// are in flight. GetSnapshot(tokio::sync::oneshot::Sender>), /// Go to definition query GotoDefinition { file_path: PathBuf, row: usize, col: usize, response_tx: tokio::sync::oneshot::Sender>, }, /// Go to references query GotoReferences { file_path: PathBuf, row: usize, col: usize, include_definition: bool, response_tx: tokio::sync::oneshot::Sender>, }, /// Find definitions by symbol name FindDefinitions { symbol: String, context_file: Option, response_tx: tokio::sync::oneshot::Sender>, }, /// Find references by symbol name FindReferences { symbol: String, context_file: Option, response_tx: tokio::sync::oneshot::Sender>, }, /// Background refresh: reindex stale/new files discovered in background BackgroundRefresh { /// Files that need reindexing (stale or new) stale_files: Vec, deleted_files: Vec, }, /// Get the number of indexed files (lightweight, no clone) GetFileCount(tokio::sync::oneshot::Sender), /// Get index statistics (lightweight, no clone) GetStats(tokio::sync::oneshot::Sender), /// Get the query version stamp of the current index (lightweight, no clone) GetQueryVersion(tokio::sync::oneshot::Sender), /// Check whether a symbol has any definitions (lightweight, no clone) HasDefinition { symbol: String, response_tx: tokio::sync::oneshot::Sender, }, /// Shutdown the manager Shutdown, } /// Result of a query operation. #[derive(Debug, Clone)] pub struct QueryResult { /// The symbol that was found at the query position. pub symbol: String, /// List of locations where the symbol is defined/referenced. pub locations: Vec, } /// A symbol location in a file. /// /// `path` is stored as **relative** to the index root_path (for portability across machines/sessions). /// Use `to_relative_path` helper when creating from absolute paths. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SymbolLocation { /// Path to the file (relative to index root_path). pub path: String, /// 1-indexed line number. pub line: usize, /// Optional: the matched symbol name (useful for aliases). pub matched_symbol: Option, } impl SymbolLocation { /// Create a new location with relative path (to index root_path). pub fn new(path: impl Into, line: usize) -> Self { Self { path: path.into(), line, matched_symbol: None, } } /// Create a new location with relative path and matched symbol. pub fn with_symbol(path: impl Into, line: usize, symbol: String) -> Self { Self { path: path.into(), line, matched_symbol: Some(symbol), } } /// Get the path as a Path reference (relative to index root_path). pub fn as_path(&self) -> &Path { Path::new(&self.path) } } /// Error type for query operations. #[derive(Debug, Clone)] pub enum QueryError { /// File not found or could not be read. FileNotFound(PathBuf), /// No symbol found at the given position. NoSymbolAtPosition { row: usize, col: usize }, /// Language not supported. UnsupportedLanguage(String), /// Parse error. ParseError(String), } /// Handle for sending commands to the IndexManager. #[derive(Clone)] pub struct IndexManagerHandle { command_tx: Sender, /// Test-only: true once the actor thread exits. #[cfg(test)] exit_signal: Arc, } impl IndexManagerHandle { /// Test-only: has the actor thread's `run_loop` returned yet? #[cfg(test)] pub(crate) fn has_run_loop_exited(&self) -> bool { self.exit_signal.load(std::sync::atomic::Ordering::SeqCst) } /// Send a file event to the manager. pub fn send_event(&self, event: FileEvent) -> Result<(), channel::SendError> { self.command_tx.send(IndexCommand::FileEvent(event)) } /// Send a batch of file events (more efficient than sending one at a time). pub fn send_events( &self, events: Vec, ) -> Result<(), channel::SendError> { if events.is_empty() { return Ok(()); } self.command_tx.send(IndexCommand::FileEventBatch(events)) } /// Request a full rebuild of the index. pub fn rebuild(&self) -> Result<(), channel::SendError> { self.command_tx.send(IndexCommand::Rebuild) } /// Get a shared snapshot of the current index (blocking). /// /// Returns an `Arc` — the clone is zero-cost when no /// mutation is in flight inside the manager. pub fn get_snapshot(&self) -> Result, channel::SendError> { let (tx, rx) = tokio::sync::oneshot::channel(); self.command_tx.send(IndexCommand::GetSnapshot(tx))?; Ok(rx .blocking_recv() .expect("IndexManager dropped before responding")) } /// Get a shared snapshot of the current index (async version). pub async fn get_snapshot_async( &self, ) -> Result, channel::SendError> { let (tx, rx) = tokio::sync::oneshot::channel(); self.command_tx.send(IndexCommand::GetSnapshot(tx))?; Ok(rx.await.expect("IndexManager dropped before responding")) } /// Get the number of indexed files without cloning the entire index. pub fn get_file_count(&self) -> Option { let (tx, rx) = tokio::sync::oneshot::channel(); self.command_tx.send(IndexCommand::GetFileCount(tx)).ok()?; rx.blocking_recv().ok() } /// Get index statistics without cloning the entire index. pub fn get_stats(&self) -> Option { let (tx, rx) = tokio::sync::oneshot::channel(); self.command_tx.send(IndexCommand::GetStats(tx)).ok()?; rx.blocking_recv().ok() } /// Get the query version stamp of the current index without cloning. pub fn get_query_version(&self) -> Option { let (tx, rx) = tokio::sync::oneshot::channel(); self.command_tx .send(IndexCommand::GetQueryVersion(tx)) .ok()?; rx.blocking_recv().ok() } /// Check if the index contains at least one definition for `symbol` (blocking). /// /// Returns `Some(true/false)` on success, or `None` if the manager is /// unavailable. Prefer this over `get_snapshot()` + `has_definition()` /// when you only need a boolean existence check. pub fn has_definition_blocking(&self, symbol: &str) -> Option { let (tx, rx) = tokio::sync::oneshot::channel(); self.command_tx .send(IndexCommand::HasDefinition { symbol: symbol.to_string(), response_tx: tx, }) .ok()?; rx.blocking_recv().ok() } /// Shutdown the IndexManager. pub fn shutdown(&self) -> Result<(), channel::SendError> { self.command_tx.send(IndexCommand::Shutdown) } // Async Query APIs /// Go to definition at the given position (async). /// /// # Arguments /// * `file_path` - Path to the file /// * `row` - 1-indexed line number /// * `col` - 1-indexed column number pub async fn goto_definition( &self, file_path: PathBuf, row: usize, col: usize, ) -> Result, channel::SendError> { let (tx, rx) = tokio::sync::oneshot::channel(); self.command_tx.send(IndexCommand::GotoDefinition { file_path, row, col, response_tx: tx, })?; Ok(rx.await.expect("IndexManager dropped before responding")) } /// Go to references at the given position (async). /// /// # Arguments /// * `file_path` - Path to the file /// * `row` - 1-indexed line number /// * `col` - 1-indexed column number /// * `include_definition` - Whether to include definition locations in results pub async fn goto_references( &self, file_path: PathBuf, row: usize, col: usize, include_definition: bool, ) -> Result, channel::SendError> { let (tx, rx) = tokio::sync::oneshot::channel(); self.command_tx.send(IndexCommand::GotoReferences { file_path, row, col, include_definition, response_tx: tx, })?; Ok(rx.await.expect("IndexManager dropped before responding")) } /// Find definitions by symbol name (async). /// /// # Arguments /// * `symbol` - The symbol name to look up /// * `context_file` - Optional file path for context-aware ranking pub async fn find_definitions( &self, symbol: String, context_file: Option, ) -> Result, channel::SendError> { let (tx, rx) = tokio::sync::oneshot::channel(); self.command_tx.send(IndexCommand::FindDefinitions { symbol, context_file, response_tx: tx, })?; Ok(rx.await.expect("IndexManager dropped before responding")) } /// Find references by symbol name (async). /// /// # Arguments /// * `symbol` - The symbol name to look up /// * `context_file` - Optional file path for context-aware ranking pub async fn find_references( &self, symbol: String, context_file: Option, ) -> Result, channel::SendError> { let (tx, rx) = tokio::sync::oneshot::channel(); self.command_tx.send(IndexCommand::FindReferences { symbol, context_file, response_tx: tx, })?; Ok(rx.await.expect("IndexManager dropped before responding")) } // Blocking Query APIs /// Go to definition at the given position (blocking). pub fn goto_definition_blocking( &self, file_path: PathBuf, row: usize, col: usize, ) -> Result, channel::SendError> { let (tx, rx) = tokio::sync::oneshot::channel(); self.command_tx.send(IndexCommand::GotoDefinition { file_path, row, col, response_tx: tx, })?; Ok(rx .blocking_recv() .expect("IndexManager dropped before responding")) } /// Go to references at the given position (blocking). pub fn goto_references_blocking( &self, file_path: PathBuf, row: usize, col: usize, include_definition: bool, ) -> Result, channel::SendError> { let (tx, rx) = tokio::sync::oneshot::channel(); self.command_tx.send(IndexCommand::GotoReferences { file_path, row, col, include_definition, response_tx: tx, })?; Ok(rx .blocking_recv() .expect("IndexManager dropped before responding")) } /// Find definitions by symbol name (blocking). pub fn find_definitions_blocking( &self, symbol: String, context_file: Option, ) -> Result, channel::SendError> { let (tx, rx) = tokio::sync::oneshot::channel(); self.command_tx.send(IndexCommand::FindDefinitions { symbol, context_file, response_tx: tx, })?; Ok(rx .blocking_recv() .expect("IndexManager dropped before responding")) } /// Find references by symbol name (blocking). pub fn find_references_blocking( &self, symbol: String, context_file: Option, ) -> Result, channel::SendError> { let (tx, rx) = tokio::sync::oneshot::channel(); self.command_tx.send(IndexCommand::FindReferences { symbol, context_file, response_tx: tx, })?; Ok(rx .blocking_recv() .expect("IndexManager dropped before responding")) } } /// Configuration for the IndexManager. #[derive(Debug, Clone)] pub struct IndexManagerConfig { /// Root path to index pub root_path: PathBuf, /// Optional custom cache path pub cache_path: Option, /// Whether to load from cache on startup pub load_from_cache: bool, /// Whether to save to cache on changes pub save_to_cache: bool, } impl IndexManagerConfig { /// Create a new config with just the root path. pub fn new(root_path: PathBuf) -> Self { Self { root_path, cache_path: None, load_from_cache: true, save_to_cache: true, } } pub fn with_cache_path(mut self, path: PathBuf) -> Self { self.cache_path = Some(path); self } /// Disable cache loading on startup. pub fn without_cache_load(mut self) -> Self { self.load_from_cache = false; self } /// Disable cache saving. pub fn without_cache_save(mut self) -> Self { self.save_to_cache = false; self } } /// Test-only RAII guard: sets a shared `AtomicBool` on drop so tests can /// confirm the actor thread actually exited (not just that the Weak stopped upgrading). #[cfg(test)] struct ExitBeacon(Arc); #[cfg(test)] impl Drop for ExitBeacon { fn drop(&mut self) { self.0.store(true, std::sync::atomic::Ordering::SeqCst); } } /// The IndexManager owns and manages the ScopeGraphIndex. /// /// It processes file events through a channel and updates the index incrementally. /// This design avoids Arc by having a single owner of the index. pub struct IndexManager { /// The index being managed. Wrapped in `Arc` so that `GetSnapshot` can /// hand out a shared reference without cloning. Mutations use /// `Arc::make_mut` which is zero-cost when no snapshot is alive and /// performs a single clone (COW) when one is. index: Arc, /// Language registry for parsing registry: LanguageRegistry, /// Configuration config: IndexManagerConfig, /// Command receiver command_rx: Receiver, /// Stats: number of updates processed updates_processed: usize, /// Cached parsers by language ID (avoids recreating on each file) parser_cache: HashMap, /// Cached queries by language ID (avoids recompiling on each file) query_cache: HashMap, } impl IndexManager { /// Spawn an IndexManager in a background thread, returning immediately. /// /// Unlike `new()`, this never blocks the caller. The index is loaded/built /// in a background thread. Queries will wait in the channel until the index /// is ready, then get processed. /// /// This is the recommended API for integrations that need non-blocking startup. /// /// ## Deduplication /// /// This function ensures that at most one `IndexManager` exists per workspace /// per process. If an active manager already exists for the workspace, its /// handle is returned instead of creating a new one. pub fn spawn(config: IndexManagerConfig) -> Arc { let canonical_root = dunce::canonicalize(&config.root_path).unwrap_or_else(|_| config.root_path.clone()); // Test beacon: shared with the actor thread's ExitBeacon guard. #[cfg(test)] let exit_signal = Arc::new(std::sync::atomic::AtomicBool::new(false)); // Use entry API for atomic get-or-create // Loop handles the case where we find a dead weak ref and need to retry let (handle, command_tx, command_rx) = loop { match ACTIVE_MANAGERS.entry(canonical_root.clone()) { dashmap::mapref::entry::Entry::Occupied(entry) => { // Check if the existing weak reference is still valid if let Some(strong) = entry.get().upgrade() { tracing::debug!( root = %canonical_root.display(), "Reusing existing IndexManager" ); return strong; } // Weak reference is dead, remove it and retry entry.remove(); continue; } dashmap::mapref::entry::Entry::Vacant(entry) => { // No existing manager - create a new one let (command_tx, command_rx) = channel::unbounded(); let handle = Arc::new(IndexManagerHandle { command_tx: command_tx.clone(), #[cfg(test)] exit_signal: exit_signal.clone(), }); entry.insert(Arc::downgrade(&handle)); break (handle, command_tx, command_rx); } } }; // Weak ref so the bg thread can't keep the channel alive past teardown. // If every handle drops, the channel disconnects and the actor exits -- // the bg result is discarded. let bg_handle_weak = Arc::downgrade(&handle); // Capture the current tracing dispatcher so the background thread can use it let dispatcher = tracing::dispatcher::get_default(|d| d.clone()); let root_path_for_log = config.root_path.clone(); std::thread::Builder::new() .name("index-manager".to_string()) .spawn(move || { // Set the tracing dispatcher for this thread let _guard = tracing::dispatcher::set_default(&dispatcher); tracing::info!( root = %root_path_for_log.display(), "IndexManager thread started, loading/building index..." ); let start = std::time::Instant::now(); let registry = LanguageRegistry::new(); let current_query_version = registry.compute_query_hash(); // Load or build the index (this is the slow part) let (index, needs_background_validation) = if config.load_from_cache { let cache_path = config .cache_path .clone() .unwrap_or_else(|| crate::manager::get_cache_path(&config.root_path)); match crate::manager::load_index(&cache_path) { Ok(idx) => { // Check if query version matches if idx.needs_query_rebuild(current_query_version) { tracing::info!( cache_path = %cache_path.display(), cached_version = ?idx.query_version, current_version = current_query_version, "Query version mismatch, rebuilding index" ); let idx = Self::build_fresh_index(&config.root_path); let (files, defs, refs) = idx.stats(); tracing::info!( files = files, definitions = defs, references = refs, elapsed_ms = start.elapsed().as_millis() as u64, "Fresh index build complete (query version changed)" ); (idx, false) } else { let (files, defs, refs) = idx.stats(); tracing::info!( cache_path = %cache_path.display(), files = files, definitions = defs, references = refs, elapsed_ms = start.elapsed().as_millis() as u64, "Loaded index from cache (will validate in background)" ); (idx, true) } } Err(e) => { let error = format!("{:?}", e); tracing::info!( root = %config.root_path.display(), error = %error, "No cache found, building fresh index..." ); let idx = Self::build_fresh_index(&config.root_path); let (files, defs, refs) = idx.stats(); tracing::info!( files = files, definitions = defs, references = refs, elapsed_ms = start.elapsed().as_millis() as u64, "Fresh index build complete" ); (idx, false) } } } else { tracing::info!("Cache disabled, building index"); let idx = Self::build_fresh_index(&config.root_path); (idx, false) }; let mut manager = Self { index: Arc::new(index), registry, config: config.clone(), command_rx, updates_processed: 0, parser_cache: HashMap::new(), query_cache: HashMap::new(), }; // Save fresh build to cache immediately (don't wait for shutdown) if !needs_background_validation { manager.save_cache(); } // Bg thread gets a Weak -- can't pin the actor past teardown. if needs_background_validation { let bg_handle = bg_handle_weak; let root_path = config.root_path.clone(); // Collect file paths and metadata for background validation let cached_data: Vec<(String, FileMeta)> = manager .index .file_paths_with_meta() .map(|(path, meta)| (path.to_string(), *meta)) .collect(); // Capture dispatcher for the background thread let bg_dispatcher = tracing::dispatcher::get_default(|d| d.clone()); std::thread::Builder::new() .name("index-bg-refresh".to_string()) .spawn(move || { let _guard = tracing::dispatcher::set_default(&bg_dispatcher); background_index_refresh(root_path, cached_data, bg_handle); }) .expect("Failed to spawn background refresh thread"); } // Drop our sender so the channel can disconnect when all handles drop. drop(command_tx); // Test beacon: fires on run_loop return. #[cfg(test)] let _exit_beacon = ExitBeacon(exit_signal); // Run the event loop - queries queued during load will now be processed manager.run_loop(); }) .expect("Failed to spawn index manager thread"); handle } /// Build a fresh index from scratch. fn build_fresh_index(root_path: &Path) -> ScopeGraphIndex { let start = Instant::now(); match IndexBuilder::new().build(root_path) { Ok(index) => { let (files, defs, refs) = index.stats(); tracing::info!( files = files, definitions = defs, references = refs, elapsed_ms = start.elapsed().as_millis() as u64, "Built index" ); index } Err(e) => { tracing::error!(error = %e, "Failed to build index"); ScopeGraphIndex::new() } } } /// Run the manager, processing commands until shutdown. /// /// This should be called in a dedicated thread. /// Event loop. Drains and coalesces pending file events before processing. fn run_loop(&mut self) { let (files, defs, refs) = self.index.stats(); tracing::info!( files = files, definitions = defs, references = refs, "IndexManager ready, processing queries" ); let mut last_cache_save = Instant::now(); loop { match self.command_rx.recv() { Ok(cmd) => { if !self.process_command_coalesced(cmd, &mut last_cache_save) { break; } } Err(_) => { tracing::debug!("Channel closed, shutting down"); break; } } } // Save cache on shutdown self.save_cache(); tracing::info!( updates_processed = self.updates_processed, "IndexManager shutdown complete" ); } /// Process a command with event coalescing and time-throttled cache saving. /// /// For file events: drains pending events from the channel and coalesces /// by path (last-writer-wins) before processing. A `Removed` after a /// `Created`/`Modified` cancels both. A `Created`/`Modified` after /// `Removed` processes as `Created` (file was replaced). /// /// Returns false if shutdown was requested. fn process_command_coalesced( &mut self, command: IndexCommand, last_cache_save: &mut Instant, ) -> bool { match command { IndexCommand::FileEvent(event) => { let mut coalesced = CoalescedEvents::new(); coalesced.add(event); // Drain any pending file events from the channel while let Ok(cmd) = self.command_rx.try_recv() { match cmd { IndexCommand::FileEvent(e) => coalesced.add(e), IndexCommand::FileEventBatch(batch) => { for e in batch { coalesced.add(e); } } other => { // Non-file command: flush coalesced events, then handle. // Depth-1 recursion: `other` is never a file event. self.apply_coalesced(coalesced, last_cache_save); return self.process_command_coalesced(other, last_cache_save); } } } self.apply_coalesced(coalesced, last_cache_save); true } IndexCommand::FileEventBatch(events) => { let mut coalesced = CoalescedEvents::new(); for e in events { coalesced.add(e); } self.apply_coalesced(coalesced, last_cache_save); true } IndexCommand::Rebuild => { self.rebuild_index(); true } IndexCommand::GetSnapshot(response_tx) => { let _ = response_tx.send(Arc::clone(&self.index)); true } IndexCommand::GotoDefinition { file_path, row, col, response_tx, } => { let result = self.handle_goto_definition(&file_path, row, col); let _ = response_tx.send(result); true } IndexCommand::GotoReferences { file_path, row, col, include_definition, response_tx, } => { let result = self.handle_goto_references(&file_path, row, col, include_definition); let _ = response_tx.send(result); true } IndexCommand::FindDefinitions { symbol, context_file, response_tx, } => { let result = self.handle_find_definitions(&symbol, context_file.as_deref()); let _ = response_tx.send(result); true } IndexCommand::FindReferences { symbol, context_file, response_tx, } => { let result = self.handle_find_references(&symbol, context_file.as_deref()); let _ = response_tx.send(result); true } IndexCommand::BackgroundRefresh { stale_files, deleted_files, } => { self.process_background_refresh(stale_files, deleted_files); true } IndexCommand::GetFileCount(response_tx) => { let _ = response_tx.send(self.index.file_count()); true } IndexCommand::GetStats(response_tx) => { let _ = response_tx.send(self.stats()); true } IndexCommand::GetQueryVersion(response_tx) => { let _ = response_tx.send(self.index.query_version.clone()); true } IndexCommand::HasDefinition { symbol, response_tx, } => { let _ = response_tx.send(self.index.has_definition(&symbol)); true } IndexCommand::Shutdown => { tracing::debug!("Shutdown requested"); false } } } /// Handle goto_definition query. fn handle_goto_definition( &mut self, file_path: &Path, row: usize, col: usize, ) -> Result { // Get symbol at position let symbol = self.get_symbol_at_position(file_path, row, col)?; // Look up definitions let defs = self.index .find_definitions_smart(&symbol, Some(file_path), Some(&self.registry)); let locations = defs .into_iter() .map(|(path, line)| SymbolLocation::new(path, line)) .collect(); Ok(QueryResult { symbol, locations }) } /// Handle goto_references query. fn handle_goto_references( &mut self, file_path: &Path, row: usize, col: usize, include_definition: bool, ) -> Result { // Get symbol at position let symbol = self.get_symbol_at_position(file_path, row, col)?; // Look up references let refs = self .index .find_references_smart(&symbol, Some(file_path), Some(&self.registry)); let mut locations: Vec = refs .into_iter() .map(|(sym, path, line)| SymbolLocation::with_symbol(path, line, sym)) .collect(); // Optionally include definitions if include_definition { let defs = self.index .find_definitions_smart(&symbol, Some(file_path), Some(&self.registry)); for (path, line) in defs { let loc = SymbolLocation::new(path.clone(), line); if !locations .iter() .any(|l| l.path == loc.path && l.line == loc.line) { locations.insert(0, loc); } } } Ok(QueryResult { symbol, locations }) } /// Handle find_definitions query. fn handle_find_definitions( &self, symbol: &str, context_file: Option<&Path>, ) -> Vec { self.index .find_definitions_smart(symbol, context_file, Some(&self.registry)) .into_iter() .map(|(path, line)| SymbolLocation::new(path, line)) .collect() } /// Handle find_references query. fn handle_find_references( &self, symbol: &str, context_file: Option<&Path>, ) -> Vec { self.index .find_references_smart(symbol, context_file, Some(&self.registry)) .into_iter() .map(|(sym, path, line)| SymbolLocation::with_symbol(path, line, sym)) .collect() } /// Get the symbol at a file position. fn get_symbol_at_position( &mut self, file_path: &Path, row: usize, col: usize, ) -> Result { if row == 0 || col == 0 { return Err(QueryError::NoSymbolAtPosition { row, col }); } let content = std::fs::read(file_path) .map_err(|_| QueryError::FileNotFound(file_path.to_path_buf()))?; let lang_config = self.registry.for_file_path(file_path).ok_or_else(|| { QueryError::UnsupportedLanguage( file_path .extension() .and_then(|e| e.to_str()) .unwrap_or("unknown") .to_string(), ) })?; let lang_id = lang_config.primary_language_id().to_string(); let lang = lang_config.language(); let parser = self.parser_cache.entry(lang_id).or_insert_with(|| { let mut p = tree_sitter::Parser::new(); let _ = p.set_language(&lang); p }); let tree = parser .parse(&content, None) .ok_or_else(|| QueryError::ParseError("Failed to parse file".to_string()))?; let point = tree_sitter::Point::new(row.saturating_sub(1), col.saturating_sub(1)); let node = find_smallest_named_node_at_point(tree.root_node(), point); match node { Some(n) => { let text = std::str::from_utf8(&content[n.byte_range()]) .map_err(|_| QueryError::ParseError("Invalid UTF-8".to_string()))?; Ok(text.to_string()) } None => Err(QueryError::NoSymbolAtPosition { row, col }), } } fn should_index(&self, path: &Path, root: &Path) -> bool { self.registry.is_supported(path) && !is_under_hidden_dir(&to_relative_path(root, path)) } /// Process background refresh results. fn process_background_refresh(&mut self, stale_files: Vec, deleted_files: Vec) { let start = Instant::now(); let stale_count = stale_files.len(); let deleted_count = deleted_files.len(); // Remove deleted files for path in deleted_files { Arc::make_mut(&mut self.index).remove_file(Path::new(&path)); } // Reindex stale files for path in stale_files { let path_ref = Path::new(&path); if self.registry.is_supported(path_ref) { self.reindex_file(path_ref); } } tracing::info!( stale = stale_count, deleted = deleted_count, elapsed_ms = start.elapsed().as_millis() as u64, "Background refresh complete" ); // Save cache after background refresh self.save_cache(); } /// Minimum interval between cache saves (avoid blocking during event floods). const CACHE_SAVE_INTERVAL_SECS: u64 = 60; /// Apply a set of coalesced file events and optionally save cache. fn apply_coalesced(&mut self, coalesced: CoalescedEvents, last_cache_save: &mut Instant) { let root = self.config.root_path.clone(); for (path, kind) in coalesced.events { match kind { FileEventKind::Created | FileEventKind::Modified => { if self.should_index(&path, &root) { self.reindex_file(&path); self.updates_processed += 1; } } FileEventKind::Removed => { // Gated by should_index: files under hidden dirs / unsupported // extensions were never indexed, so there's nothing to remove. if self.should_index(&path, &root) { self.remove_file(&path); self.updates_processed += 1; } } FileEventKind::Renamed => { // Renames are passed through without coalescing since they // need both from/to paths which are handled at the event level. if self.should_index(&path, &root) { self.reindex_file(&path); self.updates_processed += 1; } } } } // Time-throttled cache save: at most once per CACHE_SAVE_INTERVAL_SECS if self.config.save_to_cache && self.updates_processed > 0 && last_cache_save.elapsed().as_secs() >= Self::CACHE_SAVE_INTERVAL_SECS { self.save_cache(); *last_cache_save = Instant::now(); } } /// Reindex a single file: read, parse, and intern symbols directly. /// /// Unlike the parallel builder path, this interns symbol names directly /// from `&content[byte_range]` into the index's `StringInterner` — zero /// intermediate `Arc` allocations. /// /// Old entries are removed before re-extraction. If the file can't be /// read/parsed (transient error, binary, oversized), it stays absent /// from the index until the next successful reindex. fn reindex_file(&mut self, path: &Path) { let rel_path = to_relative_path(&self.config.root_path, path); let rel_str = rel_path.to_string_lossy(); Arc::make_mut(&mut self.index).remove_file(Path::new(rel_str.as_ref())); let Some(lang_config) = self.registry.for_file_path(path) else { return; }; let lang_id_str = lang_config.primary_language_id(); let metadata = match std::fs::metadata(path) { Ok(m) => m, Err(_) => return, }; if metadata.len() == 0 || metadata.len() > MAX_INDEXABLE_FILE_SIZE { return; } // Check binary using a small prefix read to avoid loading large files // into memory just to discover they contain null bytes. if is_binary_file(path) { return; } let content = match std::fs::read(path) { Ok(c) => c, Err(_) => return, }; // Populate parser/query caches on miss if !self.parser_cache.contains_key(lang_id_str) { let mut p = tree_sitter::Parser::new(); let _ = p.set_language(&lang_config.language()); self.parser_cache.insert(lang_id_str.to_string(), p); } if !self.query_cache.contains_key(lang_id_str) { let q = lang_config.compile_query().unwrap_or_else(|_| { tree_sitter::Query::new(&lang_config.language(), "").expect("empty query") }); self.query_cache.insert(lang_id_str.to_string(), q); } let Some(parser) = self.parser_cache.get_mut(lang_id_str) else { return; }; let Some(tree) = parser.parse(&content, None) else { return; }; let Some(query) = self.query_cache.get(lang_id_str) else { return; }; // Intern path once, then extract and intern symbols directly from // &content[byte_range] — no Arc intermediaries. let idx = Arc::make_mut(&mut self.index); let path_id = idx.intern(&rel_str); intern_symbols_directly(query, tree.root_node(), &content, path_id, idx); // Use already-obtained metadata (avoids re-stat and relative path CWD issues) Arc::make_mut(&mut self.index).set_file_meta(&rel_str, FileMeta::from_metadata(&metadata)); } /// Remove a file from the index. fn remove_file(&mut self, path: &Path) { let rel_path = to_relative_path(&self.config.root_path, path); Arc::make_mut(&mut self.index).remove_file(&rel_path); } /// Rebuild the entire index. fn rebuild_index(&mut self) { tracing::info!("Rebuilding entire index..."); self.index = Arc::new(Self::build_fresh_index(&self.config.root_path)); self.save_cache(); } /// Save the index to cache. fn save_cache(&self) { if !self.config.save_to_cache { return; } let cache_path = self .config .cache_path .clone() .unwrap_or_else(|| crate::manager::get_cache_path(&self.config.root_path)); let start = Instant::now(); if let Err(e) = crate::manager::save_index(&cache_path, &self.index) { tracing::error!(error = %e, "Failed to save cache"); } else { tracing::debug!( elapsed_ms = start.elapsed().as_millis() as u64, "Cache saved" ); } } /// Get the current index (consumes the manager). pub fn into_index(self) -> Arc { self.index } /// Get stats about the index. pub fn stats(&self) -> IndexStats { let (files, defs, refs) = self.index.stats(); IndexStats::new(files, defs, refs) } } /// Background index refresh: validates cached files and discovers new ones. /// /// This runs in a background thread and sends a BackgroundRefresh command /// when complete. Uses parallel processing for fast stat operations. /// /// ## Deduplication /// /// This function acquires an exclusive lock before starting. If another /// process is already performing background refresh on this workspace, /// this call returns early without doing any work. fn background_index_refresh( root_path: PathBuf, cached_data: Vec<(String, FileMeta)>, handle: Weak, ) { use crate::manager::lock::{IndexOperation, LockResult, try_lock}; use rayon::prelude::*; use std::collections::HashSet; // Test seam: delay file keeps this bg thread alive to prove the actor // still exits promptly on handle-drop. #[cfg(test)] if let Ok(s) = std::fs::read_to_string(root_path.join(".bg_refresh_test_delay_ms")) && let Ok(ms) = s.trim().parse::() { std::thread::sleep(std::time::Duration::from_millis(ms)); } // Try to acquire exclusive lock for background refresh let _lock_guard = match try_lock(&root_path, IndexOperation::BackgroundRefresh) { LockResult::Acquired(guard) => guard, LockResult::Busy { operation, holder_pid, } => { tracing::info!( root = %root_path.display(), blocking_operation = %operation, blocking_pid = ?holder_pid, "Skipping background refresh - another operation in progress" ); return; } }; let start = Instant::now(); tracing::info!( cached_files = cached_data.len(), "Starting background index validation" ); // Phase 1: Parallel stat check on cached files to find stale/deleted let (stale_from_cache, deleted): (Vec<_>, Vec<_>) = cached_data .par_iter() .filter_map(|(path, cached_meta)| { let path_ref = Path::new(path); if cached_meta.is_stale(path_ref) { // Check if file exists or is deleted if path_ref.exists() { Some((Some(path.clone()), None)) } else { Some((None, Some(path.clone()))) } } else { None } }) .fold( || (Vec::new(), Vec::new()), |(mut stale, mut deleted), item| { if let Some(p) = item.0 { stale.push(p); } if let Some(p) = item.1 { deleted.push(p); } (stale, deleted) }, ) .reduce( || (Vec::new(), Vec::new()), |(mut stale_a, mut deleted_a), (stale_b, deleted_b)| { stale_a.extend(stale_b); deleted_a.extend(deleted_b); (stale_a, deleted_a) }, ); let stat_elapsed = start.elapsed(); tracing::debug!( stale = stale_from_cache.len(), deleted = deleted.len(), elapsed_ms = stat_elapsed.as_millis() as u64, "Stat check complete" ); // Phase 2: Walk filesystem to find new files (not in cache) let cached_set: HashSet<&str> = cached_data.iter().map(|(p, _)| p.as_str()).collect(); let registry = crate::languages::LanguageRegistry::new(); let new_files: Vec = ignore::WalkBuilder::new(&root_path) // Skip hidden files/dirs .hidden(true) // Respect .gitignore .git_ignore(true) .git_global(true) .git_exclude(true) .build() .filter_map(|e| e.ok()) .filter(|e| e.file_type().map(|ft| ft.is_file()).unwrap_or(false)) .filter(|e| registry.is_supported(e.path())) .filter(|e| !cached_set.contains(e.path().to_string_lossy().as_ref())) .map(|e| e.path().to_string_lossy().into_owned()) .collect(); let walk_elapsed = start.elapsed() - stat_elapsed; tracing::debug!( new_files = new_files.len(), elapsed_ms = walk_elapsed.as_millis() as u64, "Filesystem walk complete" ); // Combine stale and new files let mut stale_files = stale_from_cache; stale_files.extend(new_files); let total_elapsed = start.elapsed(); tracing::info!( stale = stale_files.len(), deleted = deleted.len(), total_elapsed_ms = total_elapsed.as_millis() as u64, "Background validation complete, sending refresh command" ); // Send result only if the manager is still leased (Weak upgrades). // Otherwise discard -- the actor already exited. if (!stale_files.is_empty() || !deleted.is_empty()) && let Some(handle) = handle.upgrade() { let _ = handle.command_tx.send(IndexCommand::BackgroundRefresh { stale_files, deleted_files: deleted, }); } } /// Extract symbols from a parsed tree and intern them directly into the index. /// /// This is the zero-alloc alternative to `extract_symbols_inline` for the /// incremental reindex path. Instead of creating `Arc` for each symbol /// and collecting into Vecs, it interns symbol names directly from /// `&src[byte_range]` into the index's `StringInterner` and adds /// definition/reference entries inline. /// /// Invalid UTF-8 byte ranges are skipped (indicates binary content in that /// region of the file — no lossy replacement needed). fn intern_symbols_directly( query: &tree_sitter::Query, root_node: tree_sitter::Node<'_>, src: &[u8], path_id: crate::interner::StringId, index: &mut ScopeGraphIndex, ) { use tree_sitter::StreamingIterator; let capture_names = query.capture_names(); let mut is_def = vec![false; capture_names.len()]; let mut is_ref = vec![false; capture_names.len()]; let mut alias_original_idx: Option = None; let mut alias_name_idx: Option = None; for (i, name) in capture_names.iter().enumerate() { if name.starts_with("name.definition.") { is_def[i] = true; } else if name.starts_with("name.reference.") { is_ref[i] = true; } else if *name == "alias.original" { alias_original_idx = Some(i); } else if *name == "alias.name" { alias_name_idx = Some(i); } } let mut cursor = tree_sitter::QueryCursor::new(); let mut matches = cursor.matches(query, root_node, src); while let Some(m) = matches.next() { let mut alias_original: Option<&[u8]> = None; let mut alias_name: Option<&[u8]> = None; for capture in m.captures { let idx = capture.index as usize; let node = capture.node; let byte_range = node.byte_range(); let line = node.start_position().row + 1; let bytes = &src[byte_range]; // Skip non-UTF-8 ranges (binary artifact) instead of lossy replacement let Ok(text) = std::str::from_utf8(bytes) else { continue; }; if is_def.get(idx).copied().unwrap_or(false) { index.add_definition_with_path_id(text, path_id, line); } else if is_ref.get(idx).copied().unwrap_or(false) { index.add_reference_with_path_id(text, path_id, line); } else if Some(idx) == alias_original_idx { alias_original = Some(bytes); } else if Some(idx) == alias_name_idx { alias_name = Some(bytes); } } if let (Some(original), Some(alias)) = (alias_original, alias_name) && let (Ok(orig_str), Ok(alias_str)) = (std::str::from_utf8(original), std::str::from_utf8(alias)) { index.add_alias(alias_str, orig_str); } } } /// Event coalescing state: deduplicates file events by path. /// /// Semantics: /// - `Created` + `Modified` → `Modified` (already exists, just reindex) /// - `Created/Modified` + `Removed` → cancelled (nothing to do) /// - `Removed` + `Created/Modified` → `Created` (file was replaced) /// - Multiple `Modified` → single `Modified` struct CoalescedEvents { events: HashMap, } impl CoalescedEvents { fn new() -> Self { Self { events: HashMap::new(), } } fn add(&mut self, event: FileEvent) { // Renames are special: they carry two paths. Process the "to" path // as `Created` (it needs indexing) and the "from" as `Removed`. if event.kind == FileEventKind::Renamed && event.paths.len() >= 2 { self.insert(event.paths[0].clone(), FileEventKind::Removed); self.insert(event.paths[1].clone(), FileEventKind::Created); return; } for path in event.paths { self.insert(path, event.kind); } } fn insert(&mut self, path: PathBuf, kind: FileEventKind) { use std::collections::hash_map::Entry; match self.events.entry(path) { Entry::Vacant(e) => { e.insert(kind); } Entry::Occupied(mut e) => { let prev = *e.get(); match (prev, kind) { // `Created`/`Modified` then `Removed` → cancel both (FileEventKind::Created | FileEventKind::Modified, FileEventKind::Removed) => { e.remove(); } // `Removed` then `Created`/`Modified` → file replaced, treat as `Created` (FileEventKind::Removed, FileEventKind::Created | FileEventKind::Modified) => { e.insert(FileEventKind::Created); } // Same or compatible kinds → last writer wins _ => { e.insert(kind); } } } } } } /// Check if content appears to be binary by scanning for null bytes. /// Uses the same heuristic as git (check first 8000 bytes). pub fn is_binary_content(content: &[u8]) -> bool { let check_len = content.len().min(8000); content[..check_len].contains(&0) } /// Check if a file appears to be binary by reading only the first 8KB. /// /// Unlike `is_binary_content` (which takes an already-loaded buffer), this /// reads a small prefix from disk — avoiding loading the entire file into /// memory just to discover it contains null bytes. fn is_binary_file(path: &Path) -> bool { use std::io::Read; let Ok(mut f) = std::fs::File::open(path) else { return false; }; let mut buf = [0u8; 8000]; let Ok(n) = f.read(&mut buf) else { return false; }; buf[..n].contains(&0) } /// Check if a path is under a hidden directory (component starting with `.`). /// /// Returns `true` for paths like `.claude/worktrees/x/src/main.rs` or /// `.kigi/worktrees/repo/lib.rs`, which should not be indexed since they /// are typically tool-managed worktrees or caches. fn is_under_hidden_dir(path: &Path) -> bool { path.components().any(|c| { c.as_os_str() .to_str() .is_some_and(|s| s.starts_with('.') && s.len() > 1) }) } /// Find the smallest named node that contains the given point. fn find_smallest_named_node_at_point( node: tree_sitter::Node<'_>, point: tree_sitter::Point, ) -> Option> { // Check if point is within this node if point < node.start_position() || point > node.end_position() { return None; } // Try to find a smaller child node that contains the point let mut cursor = node.walk(); for child in node.children(&mut cursor) { if let Some(found) = find_smallest_named_node_at_point(child, point) { // Prefer named nodes that look like identifiers if is_identifier_like(&found) { return Some(found); } // Keep searching for a better match if is_identifier_like(&child) { return Some(child); } return Some(found); } } // No smaller child contains the point, return this node if it's identifier-like if is_identifier_like(&node) { Some(node) } else { None } } /// Check if a node looks like an identifier. fn is_identifier_like(node: &tree_sitter::Node<'_>) -> bool { let kind = node.kind(); kind == "identifier" || kind == "type_identifier" || kind == "property_identifier" || kind == "field_identifier" || kind == "shorthand_property_identifier" || kind == "shorthand_property_identifier_pattern" // Python || kind == "attribute" // Go || kind == "package_identifier" } #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::tempdir; #[test] fn test_index_manager_basic() { let dir = tempdir().unwrap(); let file_path = dir.path().join("test.rs"); fs::write(&file_path, "fn hello() {}\nfn world() {}").unwrap(); let config = IndexManagerConfig::new(dir.path().to_path_buf()) .without_cache_load() .without_cache_save(); let handle = IndexManager::spawn(config); assert!(handle.has_definition_blocking("hello").unwrap()); assert!(handle.has_definition_blocking("world").unwrap()); // Add a new file let new_file = dir.path().join("new.rs"); fs::write(&new_file, "fn new_func() {}").unwrap(); handle .send_event(FileEvent::created(new_file.clone())) .unwrap(); // Events are processed in order so the next query sees the updated index assert!(handle.has_definition_blocking("new_func").unwrap()); // Shutdown handle.shutdown().unwrap(); } #[test] fn test_query_hash_is_consistent() { let registry1 = LanguageRegistry::new(); let registry2 = LanguageRegistry::new(); // Same registry should produce the same hash let hash1 = registry1.compute_query_hash(); let hash2 = registry2.compute_query_hash(); assert_eq!( hash1, hash2, "Query hash should be consistent across instances" ); // Hash should be non-zero (extremely unlikely to be zero with real queries) assert_ne!(hash1, 0, "Query hash should be non-zero"); } #[test] fn test_query_version_set_and_check() { use crate::QueryVersion; let mut index = ScopeGraphIndex::new(); // Initially Legacy assert_eq!(index.query_version, QueryVersion::Legacy); // After setting, should be Version index.set_query_version(12345); assert_eq!(index.query_version, QueryVersion::Version(12345)); // needs_query_rebuild should return false for same version assert!(!index.needs_query_rebuild(12345)); // needs_query_rebuild should return true for different version assert!(index.needs_query_rebuild(67890)); } #[test] fn test_query_version_legacy_triggers_rebuild() { use crate::QueryVersion; // Index without query version (like old cached indexes) let index = ScopeGraphIndex::new(); assert_eq!(index.query_version, QueryVersion::Legacy); // Should force rebuild since we don't know what queries were used assert!( index.needs_query_rebuild(12345), "Legacy indexes should trigger rebuild" ); } #[test] fn test_index_includes_query_version() { use crate::QueryVersion; let dir = tempdir().unwrap(); let file_path = dir.path().join("test.rs"); fs::write(&file_path, "fn hello() {}").unwrap(); let config = IndexManagerConfig::new(dir.path().to_path_buf()) .without_cache_load() .without_cache_save(); let handle = IndexManager::spawn(config); let qv = handle.get_query_version().unwrap(); assert!( matches!(qv, QueryVersion::Version(_)), "Fresh index should have query version set" ); let registry = LanguageRegistry::new(); assert_eq!( qv, QueryVersion::Version(registry.compute_query_hash()), "Index query version should match current registry hash" ); handle.shutdown().unwrap(); } #[test] fn test_is_binary_content_detects_null_bytes() { assert!(is_binary_content(b"hello\x00world")); assert!(is_binary_content(&[0u8; 100])); } #[test] fn test_is_binary_content_passes_text() { assert!(!is_binary_content(b"fn main() {}")); assert!(!is_binary_content(b"")); } #[test] fn test_is_under_hidden_dir_filters_dotdirs() { assert!(is_under_hidden_dir(Path::new( ".claude/worktrees/abc/src/main.rs" ))); assert!(is_under_hidden_dir(Path::new( ".kigi/worktrees/repo/lib.rs" ))); assert!(is_under_hidden_dir(Path::new("src/.hidden/file.rs"))); } #[test] fn test_is_under_hidden_dir_passes_normal() { assert!(!is_under_hidden_dir(Path::new("src/main.rs"))); assert!(!is_under_hidden_dir(Path::new("lib/utils/mod.rs"))); } #[test] fn test_extract_skips_binary_file() { let dir = tempdir().unwrap(); let file_path = dir.path().join("binary.py"); // Write binary content (urandom-like with null bytes) let mut content = vec![0u8; 1024]; content[0] = b'x'; content[100] = 0; fs::write(&file_path, &content).unwrap(); let config = IndexManagerConfig::new(dir.path().to_path_buf()) .without_cache_load() .without_cache_save(); let handle = IndexManager::spawn(config); // Send event for the binary file handle.send_event(FileEvent::created(file_path)).unwrap(); assert_eq!(handle.get_file_count().unwrap_or(0), 0); handle.shutdown().unwrap(); } #[test] fn test_extract_skips_oversized_file() { let dir = tempdir().unwrap(); let file_path = dir.path().join("huge.rs"); // Write a file larger than MAX_INDEXABLE_FILE_SIZE // ~6MB let content = "fn a() {}\n".repeat(600_000); fs::write(&file_path, &content).unwrap(); let config = IndexManagerConfig::new(dir.path().to_path_buf()) .without_cache_load() .without_cache_save(); let handle = IndexManager::spawn(config); handle.send_event(FileEvent::created(file_path)).unwrap(); assert_eq!(handle.get_file_count().unwrap_or(0), 0); handle.shutdown().unwrap(); } #[test] fn test_process_event_skips_hidden_dir() { let dir = tempdir().unwrap(); // Create a file under a hidden directory let hidden_dir = dir.path().join(".claude").join("worktrees").join("abc"); fs::create_dir_all(&hidden_dir).unwrap(); let file_path = hidden_dir.join("main.rs"); fs::write(&file_path, "fn main() {}").unwrap(); // Also create a normal file let normal_path = dir.path().join("lib.rs"); fs::write(&normal_path, "fn lib_func() {}").unwrap(); let config = IndexManagerConfig::new(dir.path().to_path_buf()) .without_cache_load() .without_cache_save(); let handle = IndexManager::spawn(config); handle.send_event(FileEvent::created(file_path)).unwrap(); handle.send_event(FileEvent::created(normal_path)).unwrap(); // Only the normal file should be indexed assert!(handle.has_definition_blocking("lib_func").unwrap()); assert!(!handle.has_definition_blocking("main").unwrap()); handle.shutdown().unwrap(); } #[test] fn test_builder_skips_binary_and_oversized_files() { use crate::manager::IndexBuilder; let dir = tempdir().unwrap(); // Normal file — should be indexed fs::write(dir.path().join("good.rs"), "fn good() {}").unwrap(); // Binary file with supported extension — should be skipped let mut binary = vec![0u8; 1024]; binary[0] = b'f'; binary[10] = 0; fs::write(dir.path().join("binary.rs"), &binary).unwrap(); // Oversized file — should be skipped // ~6MB let big = "fn big() {}\n".repeat(500_000); fs::write(dir.path().join("huge.rs"), &big).unwrap(); let index = IndexBuilder::new().build(dir.path()).unwrap(); assert!(index.has_definition("good")); assert!(!index.has_definition("big")); } #[test] fn test_get_file_count_lightweight() { let dir = tempdir().unwrap(); fs::write(dir.path().join("a.rs"), "fn alpha() {}").unwrap(); fs::write(dir.path().join("b.rs"), "fn beta() {}").unwrap(); let config = IndexManagerConfig::new(dir.path().to_path_buf()) .without_cache_load() .without_cache_save(); let handle = IndexManager::spawn(config); let count = handle.get_file_count().unwrap(); assert_eq!(count, 2); handle.shutdown().unwrap(); } #[test] fn test_get_stats_lightweight() { let dir = tempdir().unwrap(); fs::write(dir.path().join("test.rs"), "fn hello() {}\nfn world() {}").unwrap(); let config = IndexManagerConfig::new(dir.path().to_path_buf()) .without_cache_load() .without_cache_save(); let handle = IndexManager::spawn(config); let stats = handle.get_stats().unwrap(); assert_eq!(stats.files, 1); // hello + world assert!(stats.definitions >= 2); handle.shutdown().unwrap(); } // CoalescedEvents tests #[test] fn test_coalesce_create_then_remove_cancels() { let mut c = CoalescedEvents::new(); c.add(FileEvent::created("/a.rs".into())); c.add(FileEvent::removed("/a.rs".into())); assert!(c.events.is_empty()); } #[test] fn test_coalesce_modify_then_remove_cancels() { let mut c = CoalescedEvents::new(); c.add(FileEvent::modified("/a.rs".into())); c.add(FileEvent::removed("/a.rs".into())); assert!(c.events.is_empty()); } #[test] fn test_coalesce_remove_then_create_is_created() { let mut c = CoalescedEvents::new(); c.add(FileEvent::removed("/a.rs".into())); c.add(FileEvent::created("/a.rs".into())); assert_eq!(c.events.len(), 1); assert_eq!(c.events[&PathBuf::from("/a.rs")], FileEventKind::Created); } #[test] fn test_coalesce_remove_then_modify_is_created() { let mut c = CoalescedEvents::new(); c.add(FileEvent::removed("/a.rs".into())); c.add(FileEvent::modified("/a.rs".into())); assert_eq!(c.events.len(), 1); assert_eq!(c.events[&PathBuf::from("/a.rs")], FileEventKind::Created); } #[test] fn test_coalesce_multiple_modify_is_single() { let mut c = CoalescedEvents::new(); c.add(FileEvent::modified("/a.rs".into())); c.add(FileEvent::modified("/a.rs".into())); c.add(FileEvent::modified("/a.rs".into())); assert_eq!(c.events.len(), 1); assert_eq!(c.events[&PathBuf::from("/a.rs")], FileEventKind::Modified); } #[test] fn test_coalesce_rename_decomposes() { let mut c = CoalescedEvents::new(); c.add(FileEvent::renamed("/old.rs".into(), "/new.rs".into())); assert_eq!(c.events.len(), 2); assert_eq!(c.events[&PathBuf::from("/old.rs")], FileEventKind::Removed); assert_eq!(c.events[&PathBuf::from("/new.rs")], FileEventKind::Created); } #[test] fn test_coalesce_different_paths_independent() { let mut c = CoalescedEvents::new(); c.add(FileEvent::created("/a.rs".into())); c.add(FileEvent::modified("/b.rs".into())); assert_eq!(c.events.len(), 2); } #[test] fn test_coalesce_rename_then_remove_target_cancels_target() { let mut c = CoalescedEvents::new(); c.add(FileEvent::renamed("/a.rs".into(), "/b.rs".into())); c.add(FileEvent::removed("/b.rs".into())); // /a.rs should still be `Removed`, /b.rs `Created`+`Removed` = cancelled assert_eq!(c.events.len(), 1); assert_eq!(c.events[&PathBuf::from("/a.rs")], FileEventKind::Removed); } #[test] fn test_coalesce_rename_then_modify_target() { let mut c = CoalescedEvents::new(); c.add(FileEvent::renamed("/a.rs".into(), "/b.rs".into())); c.add(FileEvent::modified("/b.rs".into())); // /a.rs `Removed`, /b.rs `Created`+`Modified` → `Modified` (last writer wins) assert_eq!(c.events.len(), 2); assert_eq!(c.events[&PathBuf::from("/a.rs")], FileEventKind::Removed); assert_eq!(c.events[&PathBuf::from("/b.rs")], FileEventKind::Modified); } // Verify has_definition_blocking agrees with get_snapshot().has_definition() // for both present and absent symbols. #[test] fn test_has_definition_blocking_agrees_with_snapshot() { let dir = tempdir().unwrap(); fs::write(dir.path().join("a.rs"), "fn present() {}").unwrap(); let config = IndexManagerConfig::new(dir.path().to_path_buf()) .without_cache_load() .without_cache_save(); let handle = IndexManager::spawn(config); let snapshot = handle.get_snapshot().unwrap(); assert!(snapshot.has_definition("present")); assert_eq!(handle.has_definition_blocking("present"), Some(true)); assert!(!snapshot.has_definition("absent")); assert_eq!(handle.has_definition_blocking("absent"), Some(false)); handle.shutdown().unwrap(); } // COW isolation: an Arc snapshot taken before a mutation must // continue to reflect the original index contents (COW isolation), while // a snapshot taken after the mutation must reflect the updated index. // Also verifies that the two snapshots no longer share the same backing // allocation once a mutation has caused Arc::make_mut to detach. #[test] fn test_snapshot_isolation_across_mutation() { let dir = tempdir().unwrap(); let file_path = dir.path().join("target.rs"); fs::write(&file_path, "fn before() {}").unwrap(); let config = IndexManagerConfig::new(dir.path().to_path_buf()) .without_cache_load() .without_cache_save(); let handle = IndexManager::spawn(config); // Snapshot A: sees the original index. let snapshot_before = handle.get_snapshot().unwrap(); assert!(snapshot_before.has_definition("before")); assert!(!snapshot_before.has_definition("after")); // Mutate: replace the file's symbol. While snapshot_before is alive // the manager's Arc refcount is >1, so Arc::make_mut clones before // mutating — that is the COW path we are testing. fs::write(&file_path, "fn after() {}").unwrap(); handle .send_event(FileEvent::modified(file_path.clone())) .unwrap(); // Snapshot B: sees the mutated index. let snapshot_after = handle.get_snapshot().unwrap(); assert!(!snapshot_after.has_definition("before")); assert!(snapshot_after.has_definition("after")); // Snapshot A is unchanged despite the mutation. assert!(snapshot_before.has_definition("before")); assert!(!snapshot_before.has_definition("after")); // The two snapshots must not share the same backing allocation: // Arc::make_mut on the manager's index detached it from snapshot_before. assert!( !std::sync::Arc::ptr_eq(&snapshot_before, &snapshot_after), "snapshots should point to distinct backing allocations after a mutation" ); handle.shutdown().unwrap(); } /// Actor thread exits when the last handle drops. #[test] fn test_run_loop_thread_exits_when_last_handle_dropped() { use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; let dir = tempdir().unwrap(); fs::write(dir.path().join("a.rs"), "fn alpha() {}").unwrap(); let config = IndexManagerConfig::new(dir.path().to_path_buf()) .without_cache_load() .without_cache_save(); let handle = IndexManager::spawn(config); // Actor must be alive before we test the drop. assert!( handle.has_definition_blocking("alpha").unwrap(), "index should contain the seeded definition" ); assert!( !handle.has_run_loop_exited(), "run_loop must still be running while a handle is alive" ); let exit_signal = std::sync::Arc::clone(&handle.exit_signal); drop(handle); let deadline = Instant::now() + Duration::from_secs(5); while !exit_signal.load(Ordering::SeqCst) { assert!( Instant::now() < deadline, "run_loop thread did not exit within 5s after the last handle dropped — \ the actor is leaking (actor must drop its own sender before run_loop)" ); std::thread::sleep(Duration::from_millis(5)); } } /// Actor exits on handle-drop even while bg refresh is still running /// (bg holds Weak, not a sender). #[test] fn test_actor_exits_while_background_refresh_still_running() { use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; let dir = tempdir().unwrap(); fs::write(dir.path().join("a.rs"), "fn alpha() {}").unwrap(); // Build + cache, then fully reap so pass 2 hits the cache-load path. { let h1 = IndexManager::spawn(IndexManagerConfig::new(dir.path().to_path_buf())); assert!(h1.has_definition_blocking("alpha").unwrap()); let beacon1 = std::sync::Arc::clone(&h1.exit_signal); drop(h1); let deadline = Instant::now() + Duration::from_secs(5); while !beacon1.load(Ordering::SeqCst) { assert!(Instant::now() < deadline, "pass-1 actor did not exit"); std::thread::sleep(Duration::from_millis(5)); } } // Make this root's background refresh sleep 3s so it is provably still // running when we drop the handle below. fs::write(dir.path().join(".bg_refresh_test_delay_ms"), "3000").unwrap(); // Pass 2: cache-load triggers a bg refresh (sleeping 3s via the marker). let h2 = IndexManager::spawn(IndexManagerConfig::new(dir.path().to_path_buf())); assert!( h2.has_definition_blocking("alpha").unwrap(), "cache reload should still answer queries" ); assert!(!h2.has_run_loop_exited()); let beacon2 = std::sync::Arc::clone(&h2.exit_signal); let dropped_at = Instant::now(); drop(h2); // Must exit well under 3s — proves the bg holds a Weak, not a sender. let deadline = Instant::now() + Duration::from_millis(1500); while !beacon2.load(Ordering::SeqCst) { assert!( Instant::now() < deadline, "actor thread did not exit within 1.5s of handle-drop while a 3s background \ refresh was running — the bg thread is pinning the command channel (regression \ of the #8 Weak-sender fix)" ); std::thread::sleep(Duration::from_millis(5)); } assert!( dropped_at.elapsed() < Duration::from_secs(3), "actor exited only after the bg delay elapsed — bg is still pinning the channel" ); } }