use std::fs;
use std::path::Path;
use std::time::{Duration, Instant};
use super::workspace::Workspace;
pub(super) const MAX_MATCHES: usize = 200;
const SKIPPED: [&str; 3] = [".git", "target", "node_modules"];
const BINARY_PROBE: usize = 8192;
const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024;
const MAX_LINE_CHARS: usize = 300;
pub(super) const LIMIT: Duration = Duration::from_secs(30);
struct Found<'a> {
lines: Vec<String>,
cut: bool,
stopped: Option<Stopped>,
until: Instant,
cancelled: &'a dyn Fn() -> bool,
}
#[derive(Clone, Copy)]
enum Stopped {
Time,
Person,
}
impl Found<'_> {
fn over(&mut self) -> bool {
if self.cut || self.stopped.is_some() {
return true;
}
if (self.cancelled)() {
self.stopped = Some(Stopped::Person);
} else if Instant::now() >= self.until {
self.stopped = Some(Stopped::Time);
}
self.stopped.is_some()
}
}
pub(super) fn search(
workspace: &Workspace,
pattern: &str,
path: &str,
glob: Option<&str>,
ignore_case: bool,
cancelled: &dyn Fn() -> bool,
limit: Duration,
) -> Result<String, String> {
if pattern.is_empty() {
return Err("`search` needs a non-empty `pattern`.".to_owned());
}
let start = workspace.resolve(path)?;
let shown = workspace.relative(&start);
if !start.exists() {
return Err(format!("`{shown}` does not exist."));
}
let folded = ignore_case.then(|| pattern.to_lowercase());
let mut found = Found { lines: Vec::new(), cut: false, stopped: None, until: Instant::now() + limit, cancelled };
visit(workspace, &start, pattern, folded.as_deref(), glob, &mut found);
let stopped = found.stopped.map(|why| match why {
Stopped::Time => format!(
"(Stopped after {} s with part of `{shown}` searched; narrow the search with `path` or a `glob` such as `*.rs`.)",
limit.as_secs()
),
Stopped::Person => "(Stopped by the person before the search was done.)".to_owned(),
});
if found.lines.is_empty() {
return Ok(match stopped {
Some(stopped) => format!("No matches for `{pattern}` in the part searched. {stopped}"),
None => nothing_found(pattern, &shown, glob),
});
}
let mut out = found.lines.join("\n");
if let Some(stopped) = stopped {
out.push('\n');
out.push_str(&stopped);
}
if found.cut {
out.push_str(&format!(
"\n(Stopped at {MAX_MATCHES} matches; narrow the search with `path`, a `glob` such as `*.rs` or a longer pattern.)"
));
}
Ok(out)
}
fn nothing_found(pattern: &str, shown: &str, glob: Option<&str>) -> String {
let mut out = format!("No matches for `{pattern}` in `{shown}`.");
out.push_str(" Try `ignore_case` for the same text in another case");
if glob.is_none() {
out.push_str("; or a `glob` such as `*.rs` to look at fewer files");
}
out.push('.');
out
}
fn visit(
workspace: &Workspace,
path: &Path,
pattern: &str,
folded: Option<&str>,
glob: Option<&str>,
found: &mut Found<'_>,
) {
if found.over() {
return;
}
let Ok(meta) = fs::symlink_metadata(path) else {
return;
};
if meta.is_file() {
if meta.len() <= MAX_FILE_BYTES {
search_file(workspace, path, pattern, folded, glob, found);
}
return;
}
if !meta.is_dir() {
return;
}
let Ok(entries) = fs::read_dir(path) else {
return;
};
let mut children: Vec<_> = entries.filter_map(Result::ok).collect();
children.sort_by_key(std::fs::DirEntry::file_name);
for child in children {
let is_dir = child.file_type().is_ok_and(|kind| kind.is_dir());
if is_dir && SKIPPED.iter().any(|name| child.file_name() == *name) {
continue;
}
visit(workspace, &child.path(), pattern, folded, glob, found);
if found.over() {
return;
}
}
}
fn search_file(
workspace: &Workspace,
path: &Path,
pattern: &str,
folded: Option<&str>,
glob: Option<&str>,
found: &mut Found<'_>,
) {
if let Some(glob) = glob
&& !glob_matches(glob, &workspace.relative(path))
{
return;
}
let Ok(bytes) = fs::read(path) else {
return;
};
if bytes[..bytes.len().min(BINARY_PROBE)].contains(&0) {
return;
}
let text = String::from_utf8_lossy(&bytes);
let shown = workspace.relative(path);
for (index, line) in text.lines().enumerate() {
let line_holds = match folded {
Some(folded) => line.to_lowercase().contains(folded),
None => line.contains(pattern),
};
if !line_holds {
continue;
}
if found.lines.len() == MAX_MATCHES {
found.cut = true;
return;
}
let number = index + 1;
let line = shorten(line);
found.lines.push(format!("{shown}:{number}: {line}"));
}
}
pub(super) fn glob_matches(pattern: &str, path: &str) -> bool {
let pattern: Vec<char> = pattern.chars().collect();
let text: Vec<char> = path.chars().collect();
let (mut at, mut over) = (0, 0);
let mut star: Option<(usize, usize)> = None;
while over < text.len() {
if at < pattern.len() && (pattern[at] == text[over] || pattern[at] == '?') {
at += 1;
over += 1;
} else if at < pattern.len() && pattern[at] == '*' {
star = Some((at, over));
at += 1;
} else if let Some((was, from)) = star {
at = was + 1;
over = from + 1;
star = Some((was, over));
} else {
return false;
}
}
pattern[at..].iter().all(|letter| *letter == '*')
}
fn shorten(line: &str) -> String {
match line.char_indices().nth(MAX_LINE_CHARS) {
Some((end, _)) => format!("{}…", &line[..end]),
None => line.to_owned(),
}
}