use std::path::Path;
use nucleo::pattern::{AtomKind, CaseMatching, Normalization, Pattern};
use nucleo::{Matcher, Utf32Str};
const MAX_FILES: usize = 5000;
pub const MAX_RESULTS: usize = 20;
const MAX_EMPTY_QUERY: usize = 12;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileSearchResult {
pub path: String,
pub score: u32,
}
#[derive(Clone, Debug)]
pub struct FileSearchState {
pub query: String,
pub at_offset: usize,
pub hidden_mode: bool,
pub results: Vec<FileSearchResult>,
pub selected: usize,
pub index: Vec<String>,
pub line_mode: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AtToken {
pub at_offset: usize,
pub path_query: String,
pub line_range: Option<(usize, usize)>,
pub hidden_request: bool,
}
pub fn parse_at_cursor(buffer: &str, cursor: usize) -> Option<AtToken> {
let cursor = cursor.min(buffer.len());
let before = &buffer[..cursor];
let at_rel = before.rfind('@')?;
let at_offset = at_rel;
if at_offset > 0 {
let prev = before[..at_offset].chars().last().unwrap_or(' ');
if prev.is_alphanumeric() || prev == '_' {
return None;
}
}
let after_at = &buffer[at_offset + 1..cursor];
if after_at.chars().any(|c| c.is_whitespace()) {
return None;
}
let (hidden_request, rest) = if let Some(stripped) = after_at.strip_prefix('!') {
(true, stripped)
} else {
(false, after_at)
};
let (path_query, line_range) = split_path_and_range(rest);
Some(AtToken {
at_offset,
path_query,
line_range,
hidden_request,
})
}
fn split_path_and_range(rest: &str) -> (String, Option<(usize, usize)>) {
if let Some(colon) = rest.rfind(':')
&& rest[colon + 1..]
.chars()
.all(|c| c.is_ascii_digit() || c == '-')
{
let path_part = &rest[..colon];
let range_part = &rest[colon + 1..];
if let Some(range) = parse_line_range(range_part) {
return (path_part.to_string(), Some(range));
}
}
(rest.to_string(), None)
}
fn parse_line_range(s: &str) -> Option<(usize, usize)> {
if s.is_empty() {
return None;
}
if let Some(dash) = s.find('-') {
let start: usize = s[..dash].parse().ok()?;
let end: usize = s[dash + 1..].parse().ok()?;
(start > 0 && end >= start).then_some((start, end))
} else {
let n: usize = s.parse().ok()?;
(n > 0).then_some((n, n))
}
}
pub fn build_index(cwd: &Path, hidden: bool) -> Vec<String> {
let mut files = Vec::new();
let mut builder = ignore::WalkBuilder::new(cwd);
builder
.hidden(!hidden) .git_ignore(true)
.git_global(true)
.git_exclude(true)
.ignore(true)
.parents(true)
.threads(2);
let walker = builder.build();
for entry in walker.flatten() {
if files.len() >= MAX_FILES {
break;
}
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
if let Ok(rel) = entry.path().strip_prefix(cwd) {
if rel.starts_with(".git") {
continue;
}
if let Some(s) = rel.to_str() {
files.push(s.to_string());
}
}
}
files
}
pub fn search(index: &[String], query: &str, max: usize) -> Vec<FileSearchResult> {
if query.is_empty() {
let mut roots: Vec<&String> = index
.iter()
.filter(|p| !p.contains(std::path::MAIN_SEPARATOR))
.collect();
roots.sort();
return roots
.into_iter()
.take(max.min(MAX_EMPTY_QUERY))
.map(|p| FileSearchResult {
path: p.clone(),
score: 0,
})
.collect();
}
let pattern = Pattern::new(
query,
CaseMatching::Smart,
Normalization::Smart,
AtomKind::Fuzzy,
);
let mut matcher = Matcher::new(nucleo::Config::DEFAULT);
let mut scored: Vec<FileSearchResult> = index
.iter()
.filter_map(|path| {
let haystack = Utf32Str::Ascii(path.as_bytes());
let score = pattern.score(haystack, &mut matcher)?;
Some(FileSearchResult {
path: path.clone(),
score,
})
})
.collect();
scored.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.path.cmp(&b.path)));
scored.truncate(max);
scored
}
pub fn insertion_text(path: &str, line_range: Option<(usize, usize)>, line_mode: bool) -> String {
if line_mode {
match line_range {
Some((start, end)) if start == end => format!("@{path}:{start} "),
Some((start, end)) => format!("@{path}:{start}-{end} "),
None => format!("@{path}:"),
}
} else {
format!("@{path} ")
}
}
pub fn open(cwd: &Path, at_offset: usize, hidden_mode: bool) -> FileSearchState {
let index = build_index(cwd, hidden_mode);
let results = search(&index, "", MAX_RESULTS);
FileSearchState {
query: String::new(),
at_offset,
hidden_mode,
results,
selected: 0,
index,
line_mode: false,
}
}
impl FileSearchState {
pub fn refresh(&mut self, query: &str) {
self.query = query.to_string();
self.results = search(&self.index, query, MAX_RESULTS);
self.selected = 0;
}
pub fn up(&mut self) {
if !self.results.is_empty() {
self.selected = if self.selected == 0 {
self.results.len() - 1
} else {
self.selected - 1
};
}
}
pub fn down(&mut self) {
if !self.results.is_empty() {
self.selected = if self.selected + 1 >= self.results.len() {
0
} else {
self.selected + 1
};
}
}
pub fn selected_result(&self) -> Option<&FileSearchResult> {
self.results.get(self.selected)
}
pub fn toggle_hidden(&mut self, cwd: &Path) {
self.hidden_mode = !self.hidden_mode;
self.index = build_index(cwd, self.hidden_mode);
self.refresh(&self.query.clone());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn at_at_buffer_start_triggers() {
let tok = parse_at_cursor("@foo", 4).unwrap();
assert_eq!(tok.at_offset, 0);
assert_eq!(tok.path_query, "foo");
assert_eq!(tok.line_range, None);
assert!(!tok.hidden_request);
}
#[test]
fn at_after_space_triggers() {
let tok = parse_at_cursor("hello @foo", 10).unwrap();
assert_eq!(tok.at_offset, 6);
assert_eq!(tok.path_query, "foo");
}
#[test]
fn email_is_not_a_trigger() {
assert!(parse_at_cursor("foo@bar.com", 11).is_none());
assert!(parse_at_cursor("user_name@host", 14).is_none());
}
#[test]
fn at_after_underscore_blocked() {
assert!(parse_at_cursor("_@foo", 5).is_none());
}
#[test]
fn at_after_punctuation_triggers() {
let tok = parse_at_cursor("(@foo", 5).unwrap();
assert_eq!(tok.path_query, "foo");
}
#[test]
fn whitespace_closes_token() {
assert!(parse_at_cursor("@foo bar", 8).is_none());
}
#[test]
fn hidden_toggle_detected() {
let tok = parse_at_cursor("@!foo", 5).unwrap();
assert!(tok.hidden_request);
assert_eq!(tok.path_query, "foo");
}
#[test]
fn hidden_toggle_alone() {
let tok = parse_at_cursor("@!", 2).unwrap();
assert!(tok.hidden_request);
assert!(tok.path_query.is_empty());
}
#[test]
fn line_range_single() {
let tok = parse_at_cursor("@foo:42", 7).unwrap();
assert_eq!(tok.path_query, "foo");
assert_eq!(tok.line_range, Some((42, 42)));
}
#[test]
fn line_range_multi() {
let tok = parse_at_cursor("@foo:10-25", 10).unwrap();
assert_eq!(tok.path_query, "foo");
assert_eq!(tok.line_range, Some((10, 25)));
}
#[test]
fn line_range_invalid_zero() {
let tok = parse_at_cursor("@foo:0", 6).unwrap();
assert_eq!(tok.path_query, "foo:0");
assert_eq!(tok.line_range, None);
}
#[test]
fn line_range_inverted_rejected() {
let tok = parse_at_cursor("@foo:25-10", 10).unwrap();
assert_eq!(tok.path_query, "foo:25-10");
assert_eq!(tok.line_range, None);
}
#[test]
fn no_at_symbol_no_trigger() {
assert!(parse_at_cursor("hello world", 11).is_none());
}
#[test]
fn bare_at_symbol_triggers_empty_query() {
let tok = parse_at_cursor("@", 1).unwrap();
assert!(tok.path_query.is_empty());
assert!(!tok.hidden_request);
}
#[test]
fn insertion_normal() {
assert_eq!(insertion_text("src/foo.rs", None, false), "@src/foo.rs ");
}
#[test]
fn insertion_line_mode_no_range() {
assert_eq!(insertion_text("foo.rs", None, true), "@foo.rs:");
}
#[test]
fn insertion_line_mode_single() {
assert_eq!(
insertion_text("foo.rs", Some((10, 10)), true),
"@foo.rs:10 "
);
}
#[test]
fn insertion_line_mode_range() {
assert_eq!(
insertion_text("foo.rs", Some((10, 25)), true),
"@foo.rs:10-25 "
);
}
#[test]
fn empty_query_returns_root_files() {
let index = vec![
"src/main.rs".into(),
"README.md".into(),
"Cargo.toml".into(),
"src/lib.rs".into(),
];
let results = search(&index, "", 20);
let paths: Vec<&str> = results.iter().map(|r| r.path.as_str()).collect();
assert_eq!(paths, vec!["Cargo.toml", "README.md"]);
}
#[test]
fn fuzzy_query_ranks_by_score() {
let index = vec![
"src/main.rs".into(),
"src/maine.rs".into(),
"README.md".into(),
];
let results = search(&index, "main", 20);
assert!(!results.is_empty());
assert_eq!(results[0].path, "src/main.rs");
}
#[test]
fn search_truncates_to_max() {
let index: Vec<String> = (0..100).map(|i| format!("file_{i}.rs")).collect();
let results = search(&index, "file", 5);
assert_eq!(results.len(), 5);
}
#[test]
fn no_matches_returns_empty() {
let index = vec!["foo.rs".into()];
let results = search(&index, "zzzzzzzzz", 20);
assert!(results.is_empty());
}
#[test]
fn nav_down_wraps() {
let mut state = FileSearchState {
query: String::new(),
at_offset: 0,
hidden_mode: false,
results: vec![
FileSearchResult {
path: "a".into(),
score: 0,
},
FileSearchResult {
path: "b".into(),
score: 0,
},
FileSearchResult {
path: "c".into(),
score: 0,
},
],
selected: 0,
index: vec![],
line_mode: false,
};
state.down();
assert_eq!(state.selected, 1);
state.down();
assert_eq!(state.selected, 2);
state.down();
assert_eq!(state.selected, 0); }
#[test]
fn nav_up_wraps() {
let mut state = FileSearchState {
query: String::new(),
at_offset: 0,
hidden_mode: false,
results: vec![
FileSearchResult {
path: "a".into(),
score: 0,
},
FileSearchResult {
path: "b".into(),
score: 0,
},
],
selected: 0,
index: vec![],
line_mode: false,
};
state.up();
assert_eq!(state.selected, 1); }
#[test]
fn refresh_resets_selection() {
let mut state = FileSearchState {
query: String::new(),
at_offset: 0,
hidden_mode: false,
results: vec![],
selected: 5,
index: vec!["foo.rs".into(), "bar.rs".into()],
line_mode: false,
};
state.refresh("foo");
assert_eq!(state.selected, 0);
assert_eq!(state.results.len(), 1);
}
}