use crate::format::Format;
use crate::query::Query;
use grep_regex::RegexMatcher;
use grep_searcher::sinks::Lossy;
use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder};
use ignore::{WalkBuilder, WalkState};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::Arc;
use std::time::SystemTime;
const CAP: usize = 5000;
const SNIPPET_CAP: usize = 200;
pub struct Hit {
pub path: PathBuf,
pub rel: String,
pub kind: Format,
pub size: u64,
pub modified: Option<SystemTime>,
pub snippet: Option<(u64, String)>,
}
pub enum Msg {
Hit(Hit),
Done {
capped: bool,
},
}
pub struct Search {
rx: Receiver<Msg>,
cancel: Arc<AtomicBool>,
}
impl Search {
pub fn drain(&self) -> Vec<Msg> {
self.rx.try_iter().collect()
}
pub fn cancel(&self) {
self.cancel.store(true, Ordering::Relaxed);
}
}
impl Drop for Search {
fn drop(&mut self) {
self.cancel();
}
}
pub fn start(root: PathBuf, query: Query, show_hidden: bool) -> Search {
let (tx, rx) = mpsc::channel();
let cancel = Arc::new(AtomicBool::new(false));
let cancel_walk = Arc::clone(&cancel);
std::thread::spawn(move || {
run_walk(root, query, show_hidden, tx, cancel_walk);
});
Search { rx, cancel }
}
fn run_walk(
root: PathBuf,
query: Query,
show_hidden: bool,
tx: Sender<Msg>,
cancel: Arc<AtomicBool>,
) {
let matcher = match query.content() {
Some(pat) => match grep_regex::RegexMatcherBuilder::new()
.case_smart(true)
.fixed_strings(true)
.build(pat)
{
Ok(m) => Some(Arc::new(m)),
Err(_) => {
let _ = tx.send(Msg::Done { capped: false });
return;
}
},
None => None,
};
let count = Arc::new(AtomicUsize::new(0));
let capped = Arc::new(AtomicBool::new(false));
let walker = WalkBuilder::new(&root)
.hidden(!show_hidden) .git_ignore(true)
.build_parallel();
let root = &root;
let query = &query;
let matcher = matcher.as_deref();
walker.run(|| {
let tx = tx.clone();
let cancel = Arc::clone(&cancel);
let count = Arc::clone(&count);
let capped = Arc::clone(&capped);
let mut searcher = SearcherBuilder::new()
.line_number(true)
.binary_detection(BinaryDetection::quit(0))
.build();
Box::new(move |result| {
visit(
result,
root,
query,
matcher,
&mut searcher,
&tx,
&cancel,
&count,
&capped,
)
})
});
let _ = tx.send(Msg::Done {
capped: capped.load(Ordering::Relaxed),
});
}
#[allow(clippy::too_many_arguments)]
fn visit(
result: Result<ignore::DirEntry, ignore::Error>,
root: &Path,
query: &Query,
matcher: Option<&RegexMatcher>,
searcher: &mut Searcher,
tx: &Sender<Msg>,
cancel: &AtomicBool,
count: &AtomicUsize,
capped: &AtomicBool,
) -> WalkState {
if cancel.load(Ordering::Relaxed) {
return WalkState::Quit;
}
let entry = match result {
Ok(e) => e,
Err(_) => return WalkState::Continue,
};
if entry.depth() == 0 {
return WalkState::Continue;
}
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
let meta = entry.metadata().ok();
let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
let modified = meta.and_then(|m| m.modified().ok());
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
let kind = crate::format::classify(&ext, is_dir, None);
if !query.matches(&name, kind, size, modified) {
return WalkState::Continue;
}
let snippet = match matcher {
Some(m) => {
if is_dir {
return WalkState::Continue;
}
match grep_first_match(searcher, m, path) {
Some(hit) => Some(hit),
None => return WalkState::Continue,
}
}
None => None,
};
if count.fetch_add(1, Ordering::Relaxed) >= CAP {
capped.store(true, Ordering::Relaxed);
return WalkState::Quit;
}
let rel = path
.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.into_owned();
let hit = Hit {
path: path.to_path_buf(),
rel,
kind,
size,
modified,
snippet,
};
if tx.send(Msg::Hit(hit)).is_err() {
cancel.store(true, Ordering::Relaxed);
return WalkState::Quit;
}
WalkState::Continue
}
fn grep_first_match(
searcher: &mut Searcher,
matcher: &RegexMatcher,
path: &Path,
) -> Option<(u64, String)> {
let mut found: Option<(u64, String)> = None;
let sink = Lossy(|lnum: u64, line: &str| {
found = Some((lnum, cap_snippet(line)));
Ok(false) });
let _ = searcher.search_path(matcher, path, sink);
found
}
fn cap_snippet(line: &str) -> String {
let trimmed = line.trim();
if trimmed.chars().count() > SNIPPET_CAP {
let mut s: String = trimmed.chars().take(SNIPPET_CAP).collect();
s.push('…');
s
} else {
trimmed.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::query;
use std::fs;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{Duration, Instant};
struct Fixture {
root: PathBuf,
}
impl Fixture {
fn new() -> Fixture {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir().join(format!(
"sucher-search-test-{}-{}",
std::process::id(),
n
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
Fixture { root }
}
fn file(&self, rel: &str, body: &str) {
let path = self.root.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, body).unwrap();
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn standard_tree() -> Fixture {
let fx = Fixture::new();
fx.file("a.rs", "fn main() { let TODO = 1; }");
fx.file("notes.md", "a NEEDLE here");
fx.file("sub/b.rs", "other");
fx.file("sub/deep/c.txt", "needle lower");
fx.file(".hidden.txt", "secret needle");
fx
}
fn collect(fx: &Fixture, raw: &str, show_hidden: bool) -> (Vec<Hit>, bool) {
let search = start(fx.root.clone(), query::parse(raw), show_hidden);
let mut hits = Vec::new();
let deadline = Instant::now() + Duration::from_secs(10);
for _ in 0..100_000 {
for msg in search.drain() {
match msg {
Msg::Hit(h) => hits.push(h),
Msg::Done { capped } => return (hits, capped),
}
}
assert!(
Instant::now() < deadline,
"search did not finish within timeout for query {raw:?}"
);
std::thread::sleep(Duration::from_millis(1));
}
panic!("search did not finish within iteration bound for query {raw:?}");
}
fn rels(hits: &[Hit]) -> Vec<String> {
let mut v: Vec<String> = hits.iter().map(|h| h.rel.clone()).collect();
v.sort();
v
}
#[test]
fn descends_recursively_for_a_name_match() {
let fx = standard_tree();
let (hits, _) = collect(&fx, "b", false);
let rels = rels(&hits);
assert!(
rels.contains(&"sub/b.rs".to_string()),
"expected sub/b.rs, got {rels:?}"
);
}
#[test]
fn ext_predicate_applies_across_depths() {
let fx = standard_tree();
let (hits, _) = collect(&fx, "ext:rs", false);
assert_eq!(
rels(&hits),
vec!["a.rs".to_string(), "sub/b.rs".to_string()]
);
}
#[test]
fn content_match_is_smart_case_and_recursive() {
let fx = standard_tree();
let (hits, _) = collect(&fx, "content:needle", false);
assert_eq!(
rels(&hits),
vec!["notes.md".to_string(), "sub/deep/c.txt".to_string()]
);
let notes = hits.iter().find(|h| h.rel == "notes.md").unwrap();
assert_eq!(notes.snippet, Some((1, "a NEEDLE here".to_string())));
}
#[test]
fn content_match_smart_case_goes_sensitive_on_uppercase() {
let fx = standard_tree();
let (hits, _) = collect(&fx, "content:NEEDLE", false);
assert_eq!(rels(&hits), vec!["notes.md".to_string()]);
}
#[test]
fn hidden_files_respect_the_toggle() {
let fx = standard_tree();
let (hidden_off, _) = collect(&fx, "content:secret", false);
assert!(
hidden_off.is_empty(),
"hidden file should be skipped, got {:?}",
rels(&hidden_off)
);
let (hidden_on, _) = collect(&fx, "content:secret", true);
assert_eq!(rels(&hidden_on), vec![".hidden.txt".to_string()]);
}
#[test]
fn content_queries_never_return_a_directory() {
let fx = Fixture::new();
fx.file("needle_dir/inside.txt", "needle");
fx.file("needle_top.txt", "needle");
let (hits, _) = collect(&fx, "content:needle", false);
for h in &hits {
assert!(
h.kind != Format::Directory,
"content query returned a directory: {}",
h.rel
);
}
assert_eq!(
rels(&hits),
vec![
"needle_dir/inside.txt".to_string(),
"needle_top.txt".to_string()
]
);
}
#[test]
fn cap_snippet_trims_and_truncates() {
assert_eq!(cap_snippet(" hello world \n"), "hello world");
assert_eq!(cap_snippet("short"), "short");
let long = "x".repeat(SNIPPET_CAP + 50);
let capped = cap_snippet(&long);
assert_eq!(capped.chars().count(), SNIPPET_CAP + 1); assert!(capped.ends_with('…'));
}
}