use std::path::PathBuf;
use nucleo::{Config, Matcher, Utf32Str};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SearchMode {
#[default]
Fuzzy,
Glob,
Regex,
}
impl SearchMode {
pub fn next(self) -> Self {
match self {
Self::Fuzzy => Self::Glob,
Self::Glob => Self::Regex,
Self::Regex => Self::Fuzzy,
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Fuzzy => "Fuzzy",
Self::Glob => "Glob",
Self::Regex => "Regex",
}
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SearchResult {
pub path: PathBuf,
pub display: String,
pub score: u32,
pub is_dir: bool,
pub matched_indices: Vec<usize>,
}
pub struct SearchState {
pub active: bool,
pub mode: SearchMode,
pub query: String,
pub cursor: usize,
pub results: Vec<SearchResult>,
pub selected: usize,
pub offset: usize,
matcher: Matcher,
all_paths: Vec<(PathBuf, String, bool)>, }
impl Default for SearchState {
fn default() -> Self {
Self::new()
}
}
impl SearchState {
pub fn new() -> Self {
Self {
active: false,
mode: SearchMode::default(),
query: String::new(),
cursor: 0,
results: Vec::new(),
selected: 0,
offset: 0,
matcher: Matcher::new(Config::DEFAULT),
all_paths: Vec::new(),
}
}
pub fn activate(&mut self) {
self.active = true;
self.query.clear();
self.cursor = 0;
self.results.clear();
self.selected = 0;
self.offset = 0;
}
pub fn deactivate(&mut self) {
self.active = false;
self.query.clear();
self.cursor = 0;
self.results.clear();
self.selected = 0;
self.offset = 0;
}
pub fn update_paths(&mut self, paths: Vec<(PathBuf, String, bool)>) {
self.all_paths = paths;
if self.active && !self.query.is_empty() {
self.execute_search();
}
}
#[allow(dead_code)]
pub fn set_query(&mut self, query: String) {
self.query = query;
self.cursor = self.query.len();
self.execute_search();
}
pub fn insert_char(&mut self, c: char) {
self.query.insert(self.cursor, c);
self.cursor += 1;
self.execute_search();
}
pub fn delete_char_before(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
self.query.remove(self.cursor);
self.execute_search();
}
}
pub fn delete_char_at(&mut self) {
if self.cursor < self.query.len() {
self.query.remove(self.cursor);
self.execute_search();
}
}
pub fn move_cursor_left(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
pub fn move_cursor_right(&mut self) {
self.cursor = (self.cursor + 1).min(self.query.len());
}
pub fn move_cursor_start(&mut self) {
self.cursor = 0;
}
pub fn move_cursor_end(&mut self) {
self.cursor = self.query.len();
}
pub fn clear_query(&mut self) {
self.query.clear();
self.cursor = 0;
self.results.clear();
self.selected = 0;
self.offset = 0;
}
pub fn cycle_mode(&mut self) {
self.mode = self.mode.next();
self.execute_search();
}
pub fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
if self.selected < self.offset {
self.offset = self.selected;
}
}
}
pub fn move_down(&mut self) {
if self.selected + 1 < self.results.len() {
self.selected += 1;
}
}
pub fn selected_result(&self) -> Option<&SearchResult> {
self.results.get(self.selected)
}
fn execute_search(&mut self) {
self.results.clear();
self.selected = 0;
self.offset = 0;
if self.query.is_empty() {
return;
}
match self.mode {
SearchMode::Fuzzy => self.execute_fuzzy_search(),
SearchMode::Glob => self.execute_glob_search(),
SearchMode::Regex => self.execute_regex_search(),
}
self.results.sort_by(|a, b| b.score.cmp(&a.score));
self.results.truncate(1000);
}
fn execute_fuzzy_search(&mut self) {
let mut query_buf = Vec::new();
for (path, display, is_dir) in &self.all_paths {
query_buf.clear();
let haystack = Utf32Str::new(display, &mut query_buf);
let mut pattern_buf = Vec::new();
let pattern = Utf32Str::new(&self.query, &mut pattern_buf);
let mut indices = Vec::new();
if let Some(score) = self.matcher.fuzzy_indices(haystack, pattern, &mut indices) {
self.results.push(SearchResult {
path: path.clone(),
display: display.clone(),
score: score as u32,
is_dir: *is_dir,
matched_indices: indices.iter().map(|&i| i as usize).collect(),
});
}
}
}
fn execute_glob_search(&mut self) {
let pattern = self.query.to_lowercase();
for (path, display, is_dir) in &self.all_paths {
let display_lower = display.to_lowercase();
if Self::matches_glob(&pattern, &display_lower) {
self.results.push(SearchResult {
path: path.clone(),
display: display.clone(),
score: 100, is_dir: *is_dir,
matched_indices: Vec::new(),
});
}
}
}
fn execute_regex_search(&mut self) {
let Ok(regex) = regex::Regex::new(&self.query) else {
return;
};
for (path, display, is_dir) in &self.all_paths {
if let Some(m) = regex.find(display) {
let matched_indices: Vec<usize> = (m.start()..m.end()).collect();
self.results.push(SearchResult {
path: path.clone(),
display: display.clone(),
score: 100, is_dir: *is_dir,
matched_indices,
});
}
}
}
fn matches_glob(pattern: &str, text: &str) -> bool {
let pattern_chars: Vec<char> = pattern.chars().collect();
let text_chars: Vec<char> = text.chars().collect();
Self::glob_match_recursive(&pattern_chars, &text_chars, 0, 0)
}
fn glob_match_recursive(pattern: &[char], text: &[char], pi: usize, ti: usize) -> bool {
if pi == pattern.len() {
return ti == text.len();
}
if pattern[pi] == '*' {
for i in ti..=text.len() {
if Self::glob_match_recursive(pattern, text, pi + 1, i) {
return true;
}
}
return false;
}
if ti == text.len() {
return false;
}
if pattern[pi] == '?' || pattern[pi] == text[ti] {
return Self::glob_match_recursive(pattern, text, pi + 1, ti + 1);
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_search_mode_cycle() {
let mode = SearchMode::Fuzzy;
assert_eq!(mode.next(), SearchMode::Glob);
assert_eq!(mode.next().next(), SearchMode::Regex);
assert_eq!(mode.next().next().next(), SearchMode::Fuzzy);
}
#[test]
fn test_glob_matching() {
assert!(SearchState::matches_glob("*.rs", "main.rs"));
assert!(SearchState::matches_glob("*.rs", "lib.rs"));
assert!(!SearchState::matches_glob("*.rs", "main.py"));
assert!(SearchState::matches_glob("test?", "test1"));
assert!(SearchState::matches_glob("test?", "testa"));
assert!(!SearchState::matches_glob("test?", "test"));
assert!(SearchState::matches_glob("*test*", "mytest.rs"));
}
#[test]
fn test_search_state_cursor() {
let mut state = SearchState::new();
state.activate();
state.insert_char('t');
state.insert_char('e');
state.insert_char('s');
state.insert_char('t');
assert_eq!(state.query, "test");
assert_eq!(state.cursor, 4);
state.move_cursor_left();
assert_eq!(state.cursor, 3);
state.move_cursor_start();
assert_eq!(state.cursor, 0);
state.move_cursor_end();
assert_eq!(state.cursor, 4);
state.delete_char_before();
assert_eq!(state.query, "tes");
assert_eq!(state.cursor, 3);
}
}