use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher, Utf32Str};
#[derive(Debug, Clone)]
pub struct FinderMatch {
pub path: PathBuf,
pub display_name: String,
pub score: u32,
}
type WalkedPath = (PathBuf, String);
fn walk_iter(root: &Path) -> impl Iterator<Item = WalkedPath> + '_ {
walkdir::WalkDir::new(root)
.max_depth(8)
.into_iter()
.filter_entry(|e| {
e.depth() == 0 || !e.file_name().to_str().is_some_and(|s| s.starts_with('.'))
})
.flatten()
.filter_map(move |entry| {
let path = entry.path();
let rel = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
if rel.is_empty() {
return None;
}
Some((path.to_path_buf(), rel.to_string()))
})
}
pub struct FinderWalk {
paths: Arc<Mutex<Vec<WalkedPath>>>,
done: Arc<AtomicBool>,
stop: Arc<AtomicBool>,
handle: Option<JoinHandle<()>>,
}
impl FinderWalk {
pub fn spawn(root: PathBuf) -> Self {
let paths = Arc::new(Mutex::new(Vec::new()));
let done = Arc::new(AtomicBool::new(false));
let stop = Arc::new(AtomicBool::new(false));
let (p, d, s) = (paths.clone(), done.clone(), stop.clone());
let handle = std::thread::spawn(move || {
let mut batch = Vec::with_capacity(64);
for item in walk_iter(&root) {
if s.load(Ordering::Relaxed) {
return; }
batch.push(item);
if batch.len() >= 64 {
p.lock().unwrap().append(&mut batch);
}
}
p.lock().unwrap().append(&mut batch);
d.store(true, Ordering::Relaxed);
});
FinderWalk {
paths,
done,
stop,
handle: Some(handle),
}
}
pub fn count(&self) -> usize {
self.paths.lock().unwrap().len()
}
pub fn is_done(&self) -> bool {
self.done.load(Ordering::Relaxed)
}
pub fn with_paths<T>(&self, f: impl FnOnce(&[WalkedPath]) -> T) -> T {
let guard = self.paths.lock().unwrap();
f(&guard)
}
}
impl Drop for FinderWalk {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
drop(self.handle.take());
}
}
pub struct Finder {
matcher: Matcher,
}
impl Default for Finder {
fn default() -> Self {
Self::new()
}
}
impl Finder {
pub fn new() -> Self {
Finder {
matcher: Matcher::new(Config::DEFAULT),
}
}
pub fn find_in(
&mut self,
items: &[WalkedPath],
query: &str,
max_results: usize,
) -> Vec<FinderMatch> {
if query.is_empty() {
return Vec::new();
}
let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);
let mut buf = Vec::new();
let mut results: Vec<FinderMatch> = items
.iter()
.filter_map(|(path, rel)| {
let haystack = Utf32Str::new(rel, &mut buf);
pattern
.score(haystack, &mut self.matcher)
.map(|score| FinderMatch {
path: path.clone(),
display_name: rel.clone(),
score,
})
})
.collect();
results.sort_by_key(|m| std::cmp::Reverse(m.score));
results.truncate(max_results);
results
}
pub fn find(&mut self, root: &Path, query: &str, max_results: usize) -> Vec<FinderMatch> {
if query.is_empty() {
return Vec::new();
}
let items: Vec<WalkedPath> = walk_iter(root).collect();
self.find_in(&items, query, max_results)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_finder_basic() {
let tmp = std::env::temp_dir().join(format!("ncoxide_finder_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(tmp.join("src")).unwrap();
fs::write(tmp.join("src/main.rs"), "").unwrap();
fs::write(tmp.join("src/lib.rs"), "").unwrap();
fs::write(tmp.join("README.md"), "").unwrap();
let mut finder = Finder::new();
let results = finder.find(&tmp, "main", 10);
assert!(!results.is_empty());
assert!(results[0].display_name.contains("main"));
let results = finder.find(&tmp, "rs", 10);
assert!(results.len() >= 2);
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn test_finder_works_inside_hidden_root() {
let tmp =
std::env::temp_dir().join(format!(".ncoxide_finder_hidden_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(tmp.join("sub")).unwrap();
fs::write(tmp.join("main.rs"), "").unwrap();
fs::write(tmp.join("sub/other.rs"), "").unwrap();
fs::write(tmp.join(".secret.rs"), "").unwrap();
let mut finder = Finder::new();
let results = finder.find(&tmp, "rs", 10);
let names: Vec<&str> = results.iter().map(|m| m.display_name.as_str()).collect();
assert!(names.contains(&"main.rs"), "results: {names:?}");
assert!(names.contains(&"sub/other.rs"), "results: {names:?}");
assert!(!names.contains(&".secret.rs"), "results: {names:?}");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn test_finder_empty_query() {
let mut finder = Finder::new();
let results = finder.find(Path::new("/tmp"), "", 10);
assert!(results.is_empty());
}
}