use crossbeam_channel::Receiver; use crossbeam_channel::Sender; use crossbeam_channel::after; use crossbeam_channel::never; use crossbeam_channel::select; use crossbeam_channel::unbounded; use ignore::WalkBuilder; use ignore::overrides::OverrideBuilder; use nucleo::Config; use nucleo::Injector; use nucleo::Matcher; use nucleo::Nucleo; use nucleo::Utf32String; use nucleo::pattern::CaseMatching; use nucleo::pattern::Normalization; use serde::Serialize; use std::collections::HashSet; use std::num::NonZero; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::Condvar; use std::sync::Mutex; use std::sync::RwLock; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::thread; use std::time::Duration; use tokio::process::Command; #[cfg(test)] use nucleo::Utf32Str; #[cfg(test)] use nucleo::pattern::AtomKind; #[cfg(test)] use nucleo::pattern::Pattern; mod cli; mod snapshot; pub use cli::Cli; /// A single match result returned from the search. /// /// * `score` – Relevance score used to select and order this result. /// * `path` – Path to the matched entry (file or directory), relative to the /// search directory. /// * `match_type` – Whether this match is a file or directory. /// * `indices` – Optional list of character indices that matched the query. /// These are only filled when the caller of [`run`] sets /// `options.compute_indices` to `true`. The indices vector follows the /// guidance from `nucleo::pattern::Pattern::indices`: they are /// unique and sorted in ascending order so that callers can use /// them directly for highlighting. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct FileMatch { pub score: u32, pub path: PathBuf, pub match_type: MatchType, pub root: PathBuf, #[serde(skip_serializing_if = "Option::is_none")] pub indices: Option>, // Sorted & deduplicated when present } #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum MatchType { File, Directory, } /// Carries the selected root and entry type without duplicating ASCII matcher paths. enum IndexedEntry { Ascii { root_idx: usize, match_type: MatchType, }, Unicode(Box), } // Unicode matcher columns retain only one codepoint per grapheme. struct UnicodeEntry { root_idx: usize, relative_path: Box, match_type: MatchType, } impl FileMatch { pub fn full_path(&self) -> PathBuf { self.root.join(&self.path) } } /// Returns the final path component for a matched path, falling back to the full path. pub fn file_name_from_path(path: &str) -> String { Path::new(path) .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| path.to_string()) } #[derive(Debug)] pub struct FileSearchResults { pub matches: Vec, pub total_match_count: usize, } #[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)] pub struct FileSearchSnapshot { pub query: String, pub matches: Vec, pub total_match_count: usize, pub scanned_file_count: usize, pub walk_complete: bool, } #[derive(Debug, Clone)] pub struct FileSearchOptions { pub limit: NonZero, pub exclude: Vec, pub threads: NonZero, pub compute_indices: bool, /// Toggle ignore-file processing in the walker. /// /// When enabled, `.gitignore` files are scoped by /// `WalkBuilder::require_git(true)`, so they are honored only when the /// traversed path is inside a git repository. When disabled, the walker /// turns off `.gitignore`, git-global/exclude rules, `.ignore`, and /// parent-directory ignore scanning. pub respect_gitignore: bool, } impl Default for FileSearchOptions { fn default() -> Self { Self { #[expect(clippy::unwrap_used)] limit: NonZero::new(20).unwrap(), exclude: Vec::new(), #[expect(clippy::unwrap_used)] threads: NonZero::new(2).unwrap(), compute_indices: false, respect_gitignore: true, } } } pub trait SessionReporter: Send + Sync + 'static { /// Called when the debounced top-N changes. fn on_update(&self, snapshot: &FileSearchSnapshot); /// Called when the session becomes idle or is cancelled. Guaranteed to be called at least once per update_query. fn on_complete(&self); } pub struct FileSearchSession { inner: Arc, } impl FileSearchSession { /// Update the query. This should be cheap relative to re-walking. pub fn update_query(&self, pattern_text: &str) { let _ = self .inner .work_tx .send(WorkSignal::QueryUpdated(pattern_text.to_string())); } } impl Drop for FileSearchSession { fn drop(&mut self) { self.inner.shutdown.store(true, Ordering::Relaxed); let _ = self.inner.work_tx.send(WorkSignal::Shutdown); } } pub fn create_session( search_directories: Vec, options: FileSearchOptions, reporter: Arc, cancel_flag: Option>, ) -> anyhow::Result { let FileSearchOptions { limit, exclude, threads, compute_indices, respect_gitignore, } = options; let Some(primary_search_directory) = search_directories.first() else { anyhow::bail!("at least one search directory is required"); }; let override_matcher = build_override_matcher(primary_search_directory, &exclude)?; let (work_tx, work_rx) = unbounded(); let notify_tx = work_tx.clone(); let notify = Arc::new(move || { let _ = notify_tx.send(WorkSignal::NucleoNotify); }); let nucleo = Nucleo::new( Config::DEFAULT.match_paths(), notify, Some(threads.get()), 1, ); let injector = nucleo.injector(); let cancelled = cancel_flag.unwrap_or_else(|| Arc::new(AtomicBool::new(false))); let inner = Arc::new(SessionInner { search_directories, limit: limit.get(), threads: threads.get(), compute_indices, respect_gitignore, cancelled, shutdown: Arc::new(AtomicBool::new(false)), reporter, work_tx, }); let matcher_inner = inner.clone(); thread::spawn(move || matcher_worker(matcher_inner, work_rx, nucleo)); let walker_inner = inner.clone(); thread::spawn(move || walker_worker(walker_inner, override_matcher, injector)); Ok(FileSearchSession { inner }) } pub trait Reporter { fn report_match(&self, file_match: &FileMatch); fn warn_matches_truncated(&self, total_match_count: usize, shown_match_count: usize); fn warn_no_search_pattern(&self, search_directory: &Path); } pub async fn run_main( Cli { pattern, limit, cwd, compute_indices, json: _, exclude, threads, }: Cli, reporter: T, ) -> anyhow::Result<()> { let search_directory = match cwd { Some(dir) => dir, None => std::env::current_dir()?, }; let pattern_text = match pattern { Some(pattern) => pattern, None => { reporter.warn_no_search_pattern(&search_directory); #[cfg(unix)] Command::new("ls") .arg("-al") .current_dir(search_directory) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) .status() .await?; #[cfg(windows)] { Command::new("cmd") .arg("/c") .arg(search_directory) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) .status() .await?; } return Ok(()); } }; let FileSearchResults { total_match_count, matches, } = run( &pattern_text, vec![search_directory.to_path_buf()], FileSearchOptions { limit, exclude, threads, compute_indices, respect_gitignore: true, }, /*cancel_flag*/ None, )?; let match_count = matches.len(); let matches_truncated = total_match_count > match_count; for file_match in matches { reporter.report_match(&file_match); } if matches_truncated { reporter.warn_matches_truncated(total_match_count, match_count); } Ok(()) } /// The worker threads will periodically check `cancel_flag` to see if they /// should stop processing files. pub fn run( pattern_text: &str, roots: Vec, options: FileSearchOptions, cancel_flag: Option>, ) -> anyhow::Result { let reporter = Arc::new(RunReporter::default()); let session = create_session(roots, options, reporter.clone(), cancel_flag)?; session.update_query(pattern_text); let snapshot = reporter.wait_for_complete(); Ok(FileSearchResults { matches: snapshot.matches, total_match_count: snapshot.total_match_count, }) } /// Sort matches in-place by descending score, then ascending path. #[cfg(test)] fn sort_matches(matches: &mut [(u32, String)]) { matches.sort_by(cmp_by_score_desc_then_path_asc::<(u32, String), _, _>( |t| t.0, |t| t.1.as_str(), )); } /// Returns a comparator closure suitable for `slice.sort_by(...)` that orders /// items by descending score and then ascending path using the provided accessors. pub fn cmp_by_score_desc_then_path_asc( score_of: FScore, path_of: FPath, ) -> impl FnMut(&T, &T) -> std::cmp::Ordering where FScore: Fn(&T) -> u32, FPath: Fn(&T) -> &str, { use std::cmp::Ordering; move |a, b| match score_of(b).cmp(&score_of(a)) { Ordering::Equal => path_of(a).cmp(path_of(b)), other => other, } } #[cfg(test)] fn create_pattern(pattern: &str) -> Pattern { Pattern::new( pattern, CaseMatching::Ignore, Normalization::Smart, AtomKind::Fuzzy, ) } struct SessionInner { search_directories: Vec, limit: usize, threads: usize, compute_indices: bool, respect_gitignore: bool, cancelled: Arc, shutdown: Arc, reporter: Arc, work_tx: Sender, } enum WorkSignal { QueryUpdated(String), NucleoNotify, WalkComplete, Shutdown, } fn build_override_matcher( search_directory: &Path, exclude: &[String], ) -> anyhow::Result> { if exclude.is_empty() { return Ok(None); } let mut override_builder = OverrideBuilder::new(search_directory); for exclude in exclude { let exclude_pattern = format!("!{exclude}"); override_builder.add(&exclude_pattern)?; } let matcher = override_builder.build()?; Ok(Some(matcher)) } fn get_file_path<'a>(path: &'a Path, search_directories: &[PathBuf]) -> Option<(usize, &'a str)> { let mut best_match: Option<(usize, &Path)> = None; for (idx, root) in search_directories.iter().enumerate() { if let Ok(rel_path) = path.strip_prefix(root) { let root_depth = root.components().count(); match best_match { Some((best_idx, _)) if search_directories[best_idx].components().count() >= root_depth => {} _ => { best_match = Some((idx, rel_path)); } } } } let (root_idx, rel_path) = best_match?; rel_path.to_str().map(|p| (root_idx, p)) } /// Walks the search directories and feeds discovered paths into `nucleo` /// via the injector. /// /// The walker uses `require_git(true)` to match git's own ignore semantics: /// git never reads `.gitignore` files from directories above the repository /// root. Without this flag, the `ignore` crate reads `.gitignore` files from /// *all* ancestor directories—a deliberate divergence from git intended for /// non-git use cases—allowing a broad parent ignore (e.g. `~/.gitignore` /// containing `*`) to silently suppress every file in the walk. /// /// When `respect_gitignore` is `false`, all git-related ignore processing is /// disabled regardless of this flag. fn walker_worker( inner: Arc, override_matcher: Option, injector: Injector, ) { let roots = &inner.search_directories; let Some((first_root, other_roots)) = roots.split_first() else { let _ = inner.work_tx.send(WorkSignal::WalkComplete); return; }; let mut walk_builder = WalkBuilder::new(first_root); let mut walk_roots = HashSet::from([first_root.as_path()]); for root in other_roots { if walk_roots.insert(root.as_path()) { walk_builder.add(root); } } // Keep both walks for nested roots: ignore rules can expose different entries. // Only those subtrees need to remember paths already sent to the matcher. let seen_paths: Vec<_> = roots .iter() .map(|root| { root.ancestors() .skip(1) .any(|parent| walk_roots.contains(parent)) .then(|| Mutex::new(HashSet::new())) }) .collect(); walk_builder .threads(inner.threads) // Allow hidden entries. .hidden(false) // Follow symlinks to search their contents. .follow_links(true) // Keep ignore behavior aligned with git repositories: only apply // gitignore rules when a git context exists. .require_git(true); if !inner.respect_gitignore { walk_builder .git_ignore(false) .git_global(false) .git_exclude(false) .ignore(false) .parents(false); } if let Some(override_matcher) = override_matcher { walk_builder.overrides(override_matcher); } let walker = walk_builder.build_parallel(); walker.run(|| { const CHECK_INTERVAL: usize = 1024; let mut n = 0; let seen_paths = &seen_paths; let injector = injector.clone(); let cancelled = inner.cancelled.clone(); let shutdown = inner.shutdown.clone(); Box::new(move |entry| { let entry = match entry { Ok(entry) => entry, Err(_) => return ignore::WalkState::Continue, }; let path = entry.path(); if path.to_str().is_none() { return ignore::WalkState::Continue; } if let Some((root_idx, relative_path)) = get_file_path(path, roots) && seen_paths[root_idx].as_ref().is_none_or(|seen| { seen.lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .insert(PathBuf::from(relative_path)) }) { let match_type = match entry.file_type() { Some(file_type) if file_type.is_dir() => MatchType::Directory, _ => MatchType::File, }; let column = Utf32String::from(relative_path); let indexed_entry = match &column { Utf32String::Ascii(_) => IndexedEntry::Ascii { root_idx, match_type, }, Utf32String::Unicode(_) => IndexedEntry::Unicode(Box::new(UnicodeEntry { root_idx, relative_path: Box::from(relative_path), match_type, })), }; injector.push(indexed_entry, |_, cols| { cols[0] = column; }); } n += 1; if n >= CHECK_INTERVAL { if cancelled.load(Ordering::Relaxed) || shutdown.load(Ordering::Relaxed) { return ignore::WalkState::Quit; } n = 0; } ignore::WalkState::Continue }) }); let _ = inner.work_tx.send(WorkSignal::WalkComplete); } fn matcher_worker( inner: Arc, work_rx: Receiver, mut nucleo: Nucleo, ) -> anyhow::Result<()> { const TICK_TIMEOUT_MS: u64 = 10; let config = Config::DEFAULT.match_paths(); let mut matcher = Matcher::new(config); let cancel_requested = || inner.cancelled.load(Ordering::Relaxed); let shutdown_requested = || inner.shutdown.load(Ordering::Relaxed); let mut last_query = String::new(); let mut next_notify = never(); let mut will_notify = false; let mut walk_complete = false; let mut ranking = None; loop { select! { recv(work_rx) -> signal => { let Ok(signal) = signal else { break; }; match signal { WorkSignal::QueryUpdated(query) => { ranking = None; let append = query.starts_with(&last_query); nucleo.pattern.reparse( 0, &query, CaseMatching::Ignore, Normalization::Smart, append, ); last_query = query; will_notify = true; next_notify = after(Duration::from_millis(0)); } WorkSignal::NucleoNotify => { if !will_notify { will_notify = true; next_notify = after(Duration::from_millis(TICK_TIMEOUT_MS)); } } WorkSignal::WalkComplete => { walk_complete = true; if !will_notify { will_notify = true; next_notify = after(Duration::from_millis(0)); } } WorkSignal::Shutdown => { break; } } } recv(next_notify) -> _ => { will_notify = false; if ranking.is_none() { let status = nucleo.tick(TICK_TIMEOUT_MS); if status.changed { ranking = snapshot::Ranking::start(&nucleo, &last_query, walk_complete); } if ranking.is_none() && !status.running && walk_complete { inner.reporter.on_complete(); } } if let Some(active) = &mut ranking { if let Some(snapshot) = active.advance( &inner, &nucleo, &last_query, &mut matcher, Duration::from_millis(TICK_TIMEOUT_MS), ) { inner.reporter.on_update(&snapshot); ranking = None; } // Resume this snapshot or collect work that arrived while ranking it. will_notify = true; next_notify = after(Duration::ZERO); } } default(Duration::from_millis(100)) => { // Occasionally check the cancel flag. } } if cancel_requested() || shutdown_requested() { break; } } // If we cancelled or otherwise exited the loop, make sure the reporter is notified. inner.reporter.on_complete(); Ok(()) } #[derive(Default)] struct RunReporter { snapshot: RwLock, completed: (Condvar, Mutex), } impl SessionReporter for RunReporter { fn on_update(&self, snapshot: &FileSearchSnapshot) { #[allow(clippy::unwrap_used)] let mut guard = self.snapshot.write().unwrap(); *guard = snapshot.clone(); } fn on_complete(&self) { let (cv, mutex) = &self.completed; #[allow(clippy::unwrap_used)] let mut completed = mutex.lock().unwrap(); *completed = true; cv.notify_all(); } } impl RunReporter { fn wait_for_complete(&self) -> FileSearchSnapshot { let (cv, mutex) = &self.completed; #[allow(clippy::unwrap_used)] let mut completed = mutex.lock().unwrap(); while !*completed { #[allow(clippy::unwrap_used)] { completed = cv.wait(completed).unwrap(); } } #[allow(clippy::unwrap_used)] self.snapshot.read().unwrap().clone() } } #[cfg(test)] #[path = "lib_tests.rs"] mod tests;