use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct IgnoreOpts {
pub vcs: bool,
pub hidden: bool,
}
impl IgnoreOpts {
pub const DEFAULT: Self = Self {
vcs: true,
hidden: true,
};
pub const SHOW_HIDDEN: Self = Self {
vcs: true,
hidden: false,
};
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FuzzyKind {
Files {
ignore: IgnoreOpts,
},
Lines,
Locations,
Buffers,
}
#[derive(Debug, Clone)]
pub struct MatchItem {
pub idx: usize,
pub score: i32,
pub positions: Vec<usize>,
}
#[derive(Debug)]
pub struct Finder {
pub kind: FuzzyKind,
pub query: String,
pub items: Vec<String>,
pub matches: Vec<MatchItem>,
pub selected: usize,
}
impl Finder {
pub fn files(root: &Path, ignore: IgnoreOpts) -> Self {
let mut items = if ignore.vcs
&& let Some(paths) = crate::vcs::tracked_files(root)
{
paths
.into_iter()
.filter(|p| !ignore.hidden || !is_hidden_path(p))
.take(5000)
.collect()
} else {
let mut v = Vec::new();
collect_files(root, root, &mut v, 0, ignore);
v
};
items.sort();
let mut f = Self {
kind: FuzzyKind::Files { ignore },
query: String::new(),
items,
matches: Vec::new(),
selected: 0,
};
f.refilter();
f
}
pub fn lines(buffer_lines: &[String]) -> Self {
let items: Vec<String> = buffer_lines.to_vec();
let mut f = Self {
kind: FuzzyKind::Lines,
query: String::new(),
items,
matches: Vec::new(),
selected: 0,
};
f.refilter();
f
}
pub fn buffers(items: Vec<String>) -> Self {
let mut f = Self {
kind: FuzzyKind::Buffers,
query: String::new(),
items,
matches: Vec::new(),
selected: 0,
};
f.refilter();
f
}
pub fn locations(items: Vec<String>) -> Self {
let mut f = Self {
kind: FuzzyKind::Locations,
query: String::new(),
items,
matches: Vec::new(),
selected: 0,
};
f.refilter();
f
}
pub fn push(&mut self, c: char) {
self.query.push(c);
self.refilter();
}
pub fn pop(&mut self) {
self.query.pop();
self.refilter();
}
pub fn next(&mut self) {
if !self.matches.is_empty() {
self.selected = (self.selected + 1).min(self.matches.len() - 1);
}
}
pub fn prev(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
pub fn selection(&self) -> Option<&MatchItem> {
self.matches.get(self.selected)
}
fn refilter(&mut self) {
self.matches.clear();
if self.query.is_empty() {
for (i, _) in self.items.iter().enumerate().take(500) {
self.matches.push(MatchItem {
idx: i,
score: 0,
positions: Vec::new(),
});
}
} else {
for (i, item) in self.items.iter().enumerate() {
if let Some((score, positions)) = fuzzy_match(item, &self.query) {
self.matches.push(MatchItem {
idx: i,
score,
positions,
});
}
}
self.matches.sort_by_key(|m| -m.score);
self.matches.truncate(500);
}
self.selected = 0;
}
}
pub fn fuzzy_match(haystack: &str, needle: &str) -> Option<(i32, Vec<usize>)> {
if needle.is_empty() {
return Some((0, Vec::new()));
}
let hay: Vec<char> = haystack.chars().collect();
let ndl: Vec<char> = needle.chars().collect();
if ndl.len() > hay.len() {
return None;
}
let mut best: Option<(i32, usize)> = None;
for start in 0..=hay.len() - ndl.len() {
let mut exact_case = 0i32;
let mut ok = true;
for (j, &nc) in ndl.iter().enumerate() {
let hc = hay[start + j];
if !hc.eq_ignore_ascii_case(&nc) {
ok = false;
break;
}
if hc == nc {
exact_case += 1;
}
}
if !ok {
continue;
}
let at_start = start == 0;
let at_word_boundary = at_start
|| matches!(hay[start - 1], '/' | '_' | '-' | '.' | ' ');
let mut score: i32 = 100;
score -= start as i32; if at_start {
score += 30;
} else if at_word_boundary {
score += 20;
}
score += exact_case * 2;
if best.is_none_or(|(b, _)| score > b) {
best = Some((score, start));
}
}
let (mut score, start) = best?;
score -= (hay.len() as i32) / 4;
let positions = (start..start + ndl.len()).collect();
Some((score, positions))
}
fn is_hidden_path(rel: &str) -> bool {
rel.split('/').any(|seg| seg.starts_with('.'))
}
fn collect_files(
root: &Path,
dir: &Path,
out: &mut Vec<String>,
depth: usize,
ignore: IgnoreOpts,
) {
if depth > 12 || out.len() >= 5000 {
return;
}
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
let name = match path.file_name().and_then(|s| s.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if ignore.hidden && name.starts_with('.') {
continue;
}
if ignore.vcs && matches!(name.as_str(), "target" | "node_modules" | "dist" | "build") {
continue;
}
if path.is_dir() {
collect_files(root, &path, out, depth + 1, ignore);
continue;
}
if !path.is_file() {
continue;
}
let rel = path.strip_prefix(root).ok().and_then(|p| p.to_str());
if let Some(s) = rel {
out.push(s.to_string());
}
}
}