use std::path::{Path, PathBuf};
use anyhow::{Context, bail};
use crate::ops;
#[derive(Debug, Clone)]
pub struct SearchMatch {
pub line_number: usize,
pub line: String,
}
pub fn search(
path: &Path,
pattern: &str,
regex: bool,
case_insensitive: bool,
) -> anyhow::Result<Vec<SearchMatch>> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
if pattern.is_empty() {
bail!("search pattern must not be empty");
}
let compiled_re = if regex || case_insensitive {
Some(ops::replace::compile_replace_regex(
pattern,
regex,
case_insensitive,
false,
false,
)?)
} else {
None
};
let mut matches = Vec::new();
for (i, line) in content.lines().enumerate() {
let matched = match &compiled_re {
Some(Some(re)) => re.is_match(line),
_ => line.contains(pattern),
};
if matched {
matches.push(SearchMatch {
line_number: i + 1,
line: line.to_string(),
});
}
}
Ok(matches)
}
pub fn search_file(
path: &Path,
pattern: &str,
opts: &SearchOptions,
) -> anyhow::Result<Vec<SearchResult>> {
search_directory(path, pattern, opts)
}
#[derive(Debug, Clone, Default)]
pub struct SearchOptions {
pub literal: bool,
pub regex: bool,
pub case_insensitive: bool,
pub context: Option<usize>,
pub before_context: Option<usize>,
pub after_context: Option<usize>,
pub invert_match: bool,
pub multiline: bool,
pub globs: Vec<String>,
pub max_results: usize,
pub exclude_patterns: Vec<String>,
pub custom_ignore_filenames: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SearchResult {
pub path: PathBuf,
pub line_number: usize,
pub line: String,
pub column: usize,
pub context_before: Vec<String>,
pub context_after: Vec<String>,
}
pub fn build_context_lines(
all_lines: &[&str],
match_idx: usize,
before_ctx: usize,
after_ctx: usize,
) -> (Vec<String>, Vec<String>) {
let before = if before_ctx == 0 {
vec![]
} else {
let start = match_idx.saturating_sub(before_ctx);
all_lines[start..match_idx]
.iter()
.map(|s| s.to_string())
.collect()
};
let after = if after_ctx == 0 {
vec![]
} else {
let end = (match_idx + 1 + after_ctx).min(all_lines.len());
all_lines[match_idx + 1..end]
.iter()
.map(|s| s.to_string())
.collect()
};
(before, after)
}
pub fn search_directory(
root: &Path,
pattern: &str,
opts: &SearchOptions,
) -> anyhow::Result<Vec<SearchResult>> {
if pattern.is_empty() {
bail!("search pattern must not be empty");
}
#[cfg(any(feature = "cli", feature = "files"))]
{
use crate::files::{build_glob_matcher, par_process_files};
let glob_matcher = build_glob_matcher(&opts.globs)?;
let glob_roots = vec![root.to_path_buf()];
let file_paths = crate::files::collect_file_paths_with_ignores(
root,
&opts.custom_ignore_filenames,
&opts.exclude_patterns,
false, )?;
let limit = if opts.max_results > 0 {
opts.max_results
} else {
usize::MAX
};
let file_result_groups: Vec<Vec<SearchResult>> =
par_process_files(&file_paths, glob_matcher.as_ref(), &glob_roots, |path| {
let v = search_one_file(path, pattern, opts, root);
if v.is_empty() { None } else { Some(v) }
});
let mut res: Vec<SearchResult> = file_result_groups.into_iter().flatten().collect();
if limit < usize::MAX {
res.truncate(limit);
}
Ok(res)
}
#[cfg(not(any(feature = "cli", feature = "files")))]
{
if opts.multiline {
bail!("multiline search requires the 'cli' or 'files' feature");
}
if opts.invert_match {
bail!("invert_match search requires the 'cli' or 'files' feature");
}
if root.is_file() {
let basic = search(root, pattern, opts.regex, opts.case_insensitive)?;
let display = root.to_path_buf();
let ctx_b = opts.before_context.or(opts.context).unwrap_or(0);
let ctx_a = opts.after_context.or(opts.context).unwrap_or(0);
let content = std::fs::read_to_string(root)
.with_context(|| format!("failed to read {}", root.display()))?;
let all_lines: Vec<&str> = content.lines().collect();
let results: Vec<SearchResult> = basic
.into_iter()
.map(|m| {
let i = m.line_number - 1;
let (context_before, context_after) =
build_context_lines(&all_lines, i, ctx_b, ctx_a);
let column = 1;
SearchResult {
path: display.clone(),
line_number: m.line_number,
line: m.line,
column,
context_before,
context_after,
}
})
.collect();
Ok(results)
} else {
bail!(
"search_directory requires the 'files' feature to be enabled (for pure-library recursive search with ignores/parallelism)"
);
}
}
}
#[cfg(any(feature = "cli", feature = "files"))]
pub fn search_one_file(
path: &Path,
pattern: &str,
opts: &SearchOptions,
root: &Path,
) -> Vec<SearchResult> {
let content = match crate::files::read_text_file(path) {
Some(c) => c,
None => return vec![],
};
let display = crate::files::relative_display(path, root);
let pat = if opts.literal || (opts.multiline && !opts.regex) {
regex::escape(pattern)
} else {
pattern.to_string()
};
let re = if opts.regex || opts.case_insensitive || opts.multiline {
match regex::RegexBuilder::new(&pat)
.case_insensitive(opts.case_insensitive)
.multi_line(true)
.dot_matches_new_line(opts.multiline)
.build()
{
Ok(r) => Some(r),
Err(_) => return vec![],
}
} else {
None
};
let ctx_before = opts.before_context.or(opts.context).unwrap_or(0);
let ctx_after = opts.after_context.or(opts.context).unwrap_or(0);
if opts.multiline {
let re = re.as_ref().expect("multiline always builds regex");
let mut results = Vec::new();
let all_lines: Vec<&str> = content.lines().collect();
for m in re.find_iter(&content) {
let start_byte = m.start();
let line_num = content[..start_byte].matches('\n').count();
let line_text = all_lines.get(line_num).unwrap_or(&"").to_string();
let (context_before, context_after) =
build_context_lines(&all_lines, line_num, ctx_before, ctx_after);
results.push(SearchResult {
path: display.to_path_buf(),
line_number: line_num + 1,
line: line_text,
column: 1,
context_before,
context_after,
});
}
return results;
}
let mut results = Vec::new();
let all_lines: Vec<&str> = content.lines().collect();
for (i, line) in all_lines.iter().enumerate() {
let found = if let Some(re) = &re {
re.is_match(line)
} else {
line.contains(pattern)
};
let is_match = if opts.invert_match { !found } else { found };
if is_match {
let (context_before, context_after) =
build_context_lines(&all_lines, i, ctx_before, ctx_after);
let column = if !opts.invert_match {
if let Some(re) = &re {
re.find(line).map_or(1, |m| m.start() + 1)
} else {
line.find(pattern).map_or(1, |p| p + 1)
}
} else {
1
};
results.push(SearchResult {
path: display.to_path_buf(),
line_number: i + 1,
line: line.to_string(),
column,
context_before,
context_after,
});
}
}
results
}
pub fn format_search_results(results: &[SearchResult], as_json: bool) -> String {
use std::fmt::Write;
let mut out = String::new();
if as_json {
let payload: Vec<_> = results
.iter()
.map(|r| {
serde_json::json!({
"path": r.path,
"line": r.line_number,
"text": r.line,
"column": r.column,
"context_before": r.context_before,
"context_after": r.context_after,
})
})
.collect();
if let Ok(s) = serde_json::to_string_pretty(&payload) {
out = s;
out.push('\n');
}
} else {
for r in results {
for ctx in &r.context_before {
let _ = writeln!(out, " {}", ctx);
}
let _ = writeln!(out, "{}:{}: {}", r.path.display(), r.line_number, r.line);
for ctx in &r.context_after {
let _ = writeln!(out, " {}", ctx);
}
}
}
out
}