use std::path::PathBuf;
use crate::finder::FileEntry;
const MAX_RESULTS: usize = 1000;
const PREVIEW_CHARS: usize = 300;
struct Doc {
rel: String,
abs: PathBuf,
lines: Vec<String>,
}
pub struct Hit {
pub abs: PathBuf,
pub rel: String,
pub line: usize,
pub preview: String,
}
pub struct ProjectSearch {
pub active: bool,
pub query: String,
pub selected: usize,
sources: Vec<FileEntry>,
docs: Vec<Doc>,
results: Vec<Hit>,
truncated: bool,
}
impl ProjectSearch {
pub fn new(files: &[FileEntry]) -> Self {
Self {
active: false,
query: String::new(),
selected: 0,
sources: files.to_vec(),
docs: Vec::new(),
results: Vec::new(),
truncated: false,
}
}
pub fn open(&mut self) {
self.active = true;
self.query.clear();
self.selected = 0;
self.results.clear();
self.truncated = false;
self.docs = self
.sources
.iter()
.filter_map(|e| {
std::fs::read_to_string(&e.abs).ok().map(|content| Doc {
rel: e.rel.clone(),
abs: e.abs.clone(),
lines: content.lines().map(|l| l.to_string()).collect(),
})
})
.collect();
}
pub fn close(&mut self) {
self.active = false;
self.docs = Vec::new(); self.results = Vec::new();
}
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_target(&self) -> Option<(PathBuf, usize)> {
self.results.get(self.selected).map(|h| (h.abs.clone(), h.line))
}
pub fn result_count(&self) -> usize {
self.results.len()
}
pub fn truncated(&self) -> bool {
self.truncated
}
pub fn visible(&self, limit: usize) -> Vec<(&Hit, bool)> {
self.results
.iter()
.take(limit)
.enumerate()
.map(|(i, h)| (h, i == self.selected))
.collect()
}
fn recompute(&mut self) {
self.results.clear();
self.truncated = false;
if self.query.is_empty() {
return;
}
let needle = self.query.to_lowercase();
'outer: for doc in &self.docs {
for (i, line) in doc.lines.iter().enumerate() {
if line.to_lowercase().contains(&needle) {
self.results.push(Hit {
abs: doc.abs.clone(),
rel: doc.rel.clone(),
line: i,
preview: line.trim_start().chars().take(PREVIEW_CHARS).collect(),
});
if self.results.len() >= MAX_RESULTS {
self.truncated = true;
break 'outer;
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn finds_substring_case_insensitive_across_files() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let files = crate::finder::collect_files(&root);
let mut s = ProjectSearch::new(&files);
s.open();
for c in "projectsearch".chars() {
s.push_char(c);
}
assert!(s.result_count() > 0, "expected hits for 'projectsearch'");
let (path, _line) = s.selected_target().expect("a target");
assert_eq!(path.extension().unwrap(), "rs");
}
#[test]
fn empty_query_has_no_results() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let files = crate::finder::collect_files(&root);
let mut s = ProjectSearch::new(&files);
s.open();
assert_eq!(s.result_count(), 0);
}
}