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,
WorkspaceSearch,
Buffers,
Diagnostics {
workspace: bool,
},
}
#[derive(Debug, Clone)]
pub struct MatchItem {
pub idx: usize,
pub score: i32,
pub positions: Vec<usize>,
pub line_hits: Vec<usize>,
pub match_col: u32,
}
#[derive(Debug)]
pub struct Finder {
pub kind: FuzzyKind,
pub query: String,
pub cursor: usize,
pub items: Vec<String>,
pub file_lines: Vec<Vec<String>>,
pub matches: Vec<MatchItem>,
pub selected: usize,
}
pub fn workspace_files(root: &Path, ignore: IgnoreOpts) -> Vec<String> {
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))
.filter(|p| !is_symlink(&root.join(p)))
.take(5000)
.collect()
} else {
let mut v = Vec::new();
collect_files(root, root, &mut v, 0, ignore);
v
};
items.sort();
items
}
impl Finder {
pub fn files(root: &Path, ignore: IgnoreOpts) -> Self {
let items = workspace_files(root, ignore);
let mut f = Self {
kind: FuzzyKind::Files { ignore },
query: String::new(),
items,
file_lines: Vec::new(),
matches: Vec::new(),
selected: 0,
cursor: 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,
file_lines: Vec::new(),
matches: Vec::new(),
selected: 0,
cursor: 0,
};
f.refilter();
f
}
pub fn buffers(items: Vec<String>) -> Self {
let mut f = Self {
kind: FuzzyKind::Buffers,
query: String::new(),
items,
file_lines: Vec::new(),
matches: Vec::new(),
selected: 0,
cursor: 0,
};
f.refilter();
f
}
pub fn locations(items: Vec<String>) -> Self {
let mut f = Self {
kind: FuzzyKind::Locations,
query: String::new(),
items,
file_lines: Vec::new(),
matches: Vec::new(),
selected: 0,
cursor: 0,
};
f.refilter();
f
}
pub fn diagnostics(items: Vec<String>, workspace: bool) -> Self {
let mut f = Self {
kind: FuzzyKind::Diagnostics { workspace },
query: String::new(),
items,
file_lines: Vec::new(),
matches: Vec::new(),
selected: 0,
cursor: 0,
};
f.refilter();
f
}
pub fn workspace_search(items: Vec<String>, file_lines: Vec<Vec<String>>) -> Self {
debug_assert_eq!(items.len(), file_lines.len());
let mut f = Self {
kind: FuzzyKind::WorkspaceSearch,
query: String::new(),
items,
file_lines,
matches: Vec::new(),
selected: 0,
cursor: 0,
};
f.refilter();
f
}
fn char_len(&self) -> usize {
self.query.chars().count()
}
fn byte_idx(&self, char_idx: usize) -> usize {
self.query
.char_indices()
.nth(char_idx)
.map(|(i, _)| i)
.unwrap_or(self.query.len())
}
fn insert(&mut self, c: char) {
let byte = self.byte_idx(self.cursor);
self.query.insert(byte, c);
self.cursor += 1;
self.refilter();
}
fn backspace(&mut self) {
if self.cursor == 0 {
return;
}
let end = self.byte_idx(self.cursor);
let start = self.byte_idx(self.cursor - 1);
self.query.replace_range(start..end, "");
self.cursor -= 1;
self.refilter();
}
fn delete(&mut self) {
if self.cursor >= self.char_len() {
return;
}
let start = self.byte_idx(self.cursor);
let end = self.byte_idx(self.cursor + 1);
self.query.replace_range(start..end, "");
self.refilter();
}
pub fn apply_line_key(&mut self, key: crossterm::event::KeyEvent) {
use crossterm::event::{KeyCode, KeyModifiers};
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
KeyCode::Right if self.cursor < self.char_len() => self.cursor += 1,
KeyCode::Home => self.cursor = 0,
KeyCode::End => self.cursor = self.char_len(),
KeyCode::Backspace => self.backspace(),
KeyCode::Delete => self.delete(),
KeyCode::Char('b') if ctrl => self.cursor = self.cursor.saturating_sub(1),
KeyCode::Char('f') if ctrl && self.cursor < self.char_len() => self.cursor += 1,
KeyCode::Char('a') if ctrl => self.cursor = 0,
KeyCode::Char('e') if ctrl => self.cursor = self.char_len(),
KeyCode::Char(c) if !ctrl => self.insert(c),
_ => {}
}
}
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 matches!(self.kind, FuzzyKind::WorkspaceSearch) {
self.refilter_workspace();
self.selected = 0;
return;
}
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(),
line_hits: Vec::new(),
match_col: 0,
});
}
} 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,
line_hits: Vec::new(),
match_col: 0,
});
}
}
self.matches.sort_by_key(|m| -m.score);
self.matches.truncate(500);
}
self.selected = 0;
}
fn refilter_workspace(&mut self) {
if self.query.is_empty() {
return;
}
let case_sensitive = self.query.chars().any(|c| c.is_uppercase());
let needle_ci: Option<String> = (!case_sensitive).then(|| self.query.to_lowercase());
let mut scratch = String::new();
'files: for (i, lines) in self.file_lines.iter().enumerate() {
for (row, line) in lines.iter().enumerate() {
if line.len() > WORKSPACE_SEARCH_MAX_LINE_BYTES {
continue;
}
let col: Option<u32> = match &needle_ci {
None => line
.find(self.query.as_str())
.map(|byte| line[..byte].chars().count() as u32),
Some(n) => {
if line.is_ascii() {
ascii_find_lower(line, n).map(|c| c as u32)
} else {
scratch.clear();
scratch.extend(line.chars().flat_map(|c| c.to_lowercase()));
scratch.contains(n.as_str()).then_some(0)
}
}
};
let Some(col) = col else {
continue;
};
self.matches.push(MatchItem {
idx: i,
score: 0,
positions: Vec::new(),
line_hits: vec![row],
match_col: col,
});
if self.matches.len() >= WORKSPACE_SEARCH_MAX_MATCHES {
break 'files;
}
}
}
}
}
const WORKSPACE_SEARCH_MAX_MATCHES: usize = 2000;
const WORKSPACE_SEARCH_MAX_LINE_BYTES: usize = 500;
fn ascii_find_lower(hay: &str, needle_lower: &str) -> Option<usize> {
let hay = hay.as_bytes();
let ndl = needle_lower.as_bytes();
if ndl.is_empty() {
return Some(0);
}
if hay.len() < ndl.len() {
return None;
}
'outer: for start in 0..=hay.len() - ndl.len() {
for (k, &n) in ndl.iter().enumerate() {
if hay[start + k].to_ascii_lowercase() != n {
continue 'outer;
}
}
return Some(start);
}
None
}
const SCORE_MATCH: i32 = 16;
const SCORE_GAP_START: i32 = -3;
const SCORE_GAP_EXTEND: i32 = -1;
const BONUS_BOUNDARY: i32 = SCORE_MATCH / 2; const BONUS_CAMEL: i32 = BONUS_BOUNDARY - 1; const BONUS_CONSECUTIVE: i32 = -(SCORE_GAP_START + SCORE_GAP_EXTEND); const BONUS_FIRST_CHAR_MULT: i32 = 2;
const SCORE_NEG_INF: i32 = i32::MIN / 4;
#[derive(Copy, Clone, PartialEq)]
enum CharKind {
NonWord,
Lower,
Upper,
Number,
}
fn char_kind(c: char) -> CharKind {
if c.is_ascii_lowercase() {
CharKind::Lower
} else if c.is_ascii_uppercase() {
CharKind::Upper
} else if c.is_ascii_digit() {
CharKind::Number
} else if c.is_alphanumeric() {
CharKind::Lower
} else {
CharKind::NonWord
}
}
fn boundary_bonus(prev: CharKind, curr: CharKind) -> i32 {
use CharKind::*;
match (prev, curr) {
(NonWord, c) if c != NonWord => BONUS_BOUNDARY,
(Lower, Upper) => BONUS_CAMEL,
(Lower | Upper, Number) => BONUS_CAMEL,
_ => 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();
let n = ndl.len();
let m = hay.len();
if n > m {
return None;
}
let mut bonus = vec![0i32; m];
let mut prev_kind = CharKind::NonWord;
for (j, &c) in hay.iter().enumerate() {
let k = char_kind(c);
bonus[j] = boundary_bonus(prev_kind, k);
prev_kind = k;
}
let cell = |i: usize, j: usize| i * m + j;
let mut mscore = vec![SCORE_NEG_INF; n * m];
let mut gscore = vec![SCORE_NEG_INF; n * m];
let mut mparent = vec![usize::MAX; n * m];
let mut gmatch = vec![usize::MAX; n * m];
for i in 0..n {
for j in i..m {
let nc = ndl[i];
let hc = hay[j];
let is_match = nc.eq_ignore_ascii_case(&hc);
if is_match {
let case_bonus = if nc == hc { 1 } else { 0 };
let ms = if i == 0 {
SCORE_MATCH + bonus[j] * BONUS_FIRST_CHAR_MULT + case_bonus
} else if j == 0 {
SCORE_NEG_INF
} else {
let from_m = mscore[cell(i - 1, j - 1)];
let from_g = gscore[cell(i - 1, j - 1)];
let consec_bonus = BONUS_CONSECUTIVE.max(bonus[j]);
let via_m = from_m.saturating_add(SCORE_MATCH + consec_bonus + case_bonus);
let via_g = from_g.saturating_add(SCORE_MATCH + bonus[j] + case_bonus);
if from_m == SCORE_NEG_INF && from_g == SCORE_NEG_INF {
SCORE_NEG_INF
} else if via_m >= via_g {
mparent[cell(i, j)] = j - 1;
via_m
} else {
mparent[cell(i, j)] = gmatch[cell(i - 1, j - 1)];
via_g
}
};
mscore[cell(i, j)] = ms;
}
if j > 0 {
let from_m = mscore[cell(i, j - 1)];
let from_g = gscore[cell(i, j - 1)];
let start = from_m.saturating_add(SCORE_GAP_START);
let extend = from_g.saturating_add(SCORE_GAP_EXTEND);
if from_m == SCORE_NEG_INF && from_g == SCORE_NEG_INF {
} else if extend >= start {
gscore[cell(i, j)] = extend;
gmatch[cell(i, j)] = gmatch[cell(i, j - 1)];
} else {
gscore[cell(i, j)] = start;
gmatch[cell(i, j)] = j - 1;
}
}
}
}
let mut best_score = SCORE_NEG_INF;
let mut best_j = usize::MAX;
for j in (n - 1)..m {
let s = mscore[cell(n - 1, j)];
if s > best_score {
best_score = s;
best_j = j;
}
}
if best_j == usize::MAX || best_score == SCORE_NEG_INF {
return None;
}
let mut positions = Vec::with_capacity(n);
let mut i = n - 1;
let mut j = best_j;
positions.push(j);
while i > 0 {
let pj = mparent[cell(i, j)];
if pj == usize::MAX {
return None;
}
j = pj;
i -= 1;
positions.push(j);
}
positions.reverse();
Some((best_score, positions))
}
fn is_hidden_path(rel: &str) -> bool {
rel.split('/').any(|seg| seg.starts_with('.'))
}
fn is_symlink(path: &Path) -> bool {
fs::symlink_metadata(path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
}
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;
}
let file_type = match entry.file_type() {
Ok(t) => t,
Err(_) => continue,
};
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
collect_files(root, &path, out, depth + 1, ignore);
continue;
}
if !file_type.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());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn matched(haystack: &str, needle: &str) -> Option<String> {
let (_score, positions) = fuzzy_match(haystack, needle)?;
let hay: Vec<char> = haystack.chars().collect();
Some(positions.iter().map(|&i| hay[i]).collect())
}
#[test]
fn skips_dot_separator() {
let (_score, positions) = fuzzy_match("xx.go", "xxgo").expect("should match");
assert_eq!(positions, vec![0, 1, 3, 4]);
}
#[test]
fn skips_underscore_and_slash() {
assert_eq!(matched("foo_bar", "foobar").as_deref(), Some("foobar"));
assert_eq!(matched("src/foo.rs", "foors").as_deref(), Some("foors"));
assert_eq!(matched("foo bar", "foobar").as_deref(), Some("foobar"));
}
#[test]
fn case_insensitive() {
let (_s, pos) = fuzzy_match("README.md", "readme").unwrap();
assert_eq!(pos, vec![0, 1, 2, 3, 4, 5]);
}
#[test]
fn camel_case_boundary_preferred() {
let (camel, _) = fuzzy_match("fooBar", "foob").unwrap();
let (flat, _) = fuzzy_match("flooba", "foob").unwrap();
assert!(camel > flat, "camelCase {camel} should outrank flat {flat}");
}
#[test]
fn word_boundary_outranks_mid_word() {
let (boundary, _) = fuzzy_match("src/foo", "foo").unwrap();
let (mid, _) = fuzzy_match("srcafoo", "foo").unwrap();
assert!(
boundary > mid,
"boundary {boundary} should outrank mid-word {mid}"
);
}
#[test]
fn subsequence_with_long_gap() {
let (_score, positions) = fuzzy_match("alphabet_soup", "asp").unwrap();
assert_eq!(positions.len(), 3);
assert!(positions.windows(2).all(|w| w[0] < w[1]));
}
#[test]
fn rejects_when_letters_missing() {
assert!(fuzzy_match("xx.go", "xxrs").is_none());
assert!(fuzzy_match("abc", "abcd").is_none());
assert!(fuzzy_match("src/foo", "srcz").is_none());
}
}