use std::cmp::Reverse;
use std::path::{Path, PathBuf};
use ignore::WalkBuilder;
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher, Utf32Str};
#[derive(Clone)]
pub struct FileEntry {
pub rel: String,
pub abs: PathBuf,
}
pub fn collect_files(root: &Path) -> Vec<FileEntry> {
let mut entries = Vec::new();
for result in WalkBuilder::new(root).build() {
let Ok(entry) = result else { continue };
if entry.file_type().map(|t| t.is_dir()).unwrap_or(true) {
continue;
}
let abs = entry.path().to_path_buf();
let rel = abs
.strip_prefix(root)
.unwrap_or(&abs)
.to_string_lossy()
.to_string();
entries.push(FileEntry { rel, abs });
}
entries
}
pub struct Finder {
pub active: bool,
pub query: String,
pub selected: usize,
pub(crate) results: Vec<usize>,
entries: Vec<FileEntry>,
matcher: Matcher,
}
impl Finder {
pub fn from_files(entries: Vec<FileEntry>) -> Self {
let mut finder = Self {
active: false,
query: String::new(),
selected: 0,
results: Vec::new(),
entries,
matcher: Matcher::new(Config::DEFAULT.match_paths()),
};
finder.recompute();
finder
}
pub fn open(&mut self) {
self.active = true;
self.query.clear();
self.selected = 0;
self.recompute();
}
pub fn close(&mut self) {
self.active = false;
}
pub fn push_char(&mut self, c: char) {
self.query.push(c);
self.selected = 0;
self.recompute();
}
pub fn pop_char(&mut self) {
self.query.pop();
self.selected = 0;
self.recompute();
}
pub fn move_down(&mut self) {
if self.selected + 1 < self.results.len() {
self.selected += 1;
}
}
pub fn move_up(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
pub fn selected_path(&self) -> Option<PathBuf> {
let idx = *self.results.get(self.selected)?;
Some(self.entries[idx].abs.clone())
}
pub fn visible(&self, limit: usize) -> Vec<(&str, bool)> {
self.results
.iter()
.take(limit)
.enumerate()
.map(|(i, &idx)| (self.entries[idx].rel.as_str(), i == self.selected))
.collect()
}
fn recompute(&mut self) {
if self.query.is_empty() {
self.results = (0..self.entries.len()).collect();
return;
}
let pattern = Pattern::parse(&self.query, CaseMatching::Smart, Normalization::Smart);
let mut buf = Vec::new();
let mut scored: Vec<(usize, u32)> = self
.entries
.iter()
.enumerate()
.filter_map(|(i, e)| {
pattern
.score(Utf32Str::new(&e.rel, &mut buf), &mut self.matcher)
.map(|s| (i, s))
})
.collect();
scored.sort_by_key(|&(_, s)| Reverse(s));
self.results = scored.into_iter().map(|(i, _)| i).collect();
if self.selected >= self.results.len() {
self.selected = self.results.len().saturating_sub(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fuzzy_ranks_matching_paths_first() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut finder = Finder::from_files(collect_files(root.as_path()));
finder.open();
for c in "highlight".chars() {
finder.push_char(c);
}
let top = finder.selected_path().expect("a match");
assert!(
top.file_name().unwrap().to_string_lossy().contains("highlight"),
"top result was {top:?}"
);
}
}