1use std::path::PathBuf;
2
3use rayon::prelude::*;
4
5use crate::error::{Error, Result};
6use crate::pattern::{CompiledPattern, PatternOptions};
7use crate::plan::{check_match_counts, read_text_or_skip_binary};
8use crate::walker::{WalkOptions, walk_paths};
9
10#[derive(Debug, Clone)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize))]
12pub struct SearchMatch {
13 pub line: usize,
14 pub column: usize,
15 pub snippet: String,
16 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
17 pub capture: Option<String>,
18}
19
20#[derive(Debug, Clone)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize))]
22pub struct SearchFile {
23 pub path: PathBuf,
24 pub matches: Vec<SearchMatch>,
25}
26
27#[derive(Debug, Clone)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize))]
29pub struct SearchPlan {
30 pub files: Vec<SearchFile>,
31 pub total_matches: usize,
32 pub files_scanned: usize,
33}
34
35#[derive(Debug, Clone)]
37pub struct SearchOptions {
38 pub pattern_options: PatternOptions,
39 pub walk_options: WalkOptions,
40 pub at_least: Option<usize>,
41 pub at_most: Option<usize>,
42 pub max_bytes: u64,
43 pub max_files: usize,
44}
45
46impl Default for SearchOptions {
47 fn default() -> Self {
48 Self {
49 pattern_options: PatternOptions::default(),
50 walk_options: WalkOptions::default(),
51 at_least: Some(1),
52 at_most: None,
53 max_bytes: 10 * 1024 * 1024,
54 max_files: 1000,
55 }
56 }
57}
58
59pub(crate) fn line_col(source: &str, byte_offset: usize) -> (usize, usize) {
61 debug_assert!(source.is_char_boundary(byte_offset), "byte_offset must be on a char boundary");
62 let prefix = &source[..byte_offset];
63 let line = prefix.bytes().filter(|&b| b == b'\n').count() + 1;
64 let col = match prefix.rfind('\n') {
65 Some(nl) => byte_offset - nl,
66 None => byte_offset + 1,
67 };
68 (line, col)
69}
70
71pub(crate) fn truncate_snippet(s: &str) -> String {
72 let first_line = s.lines().next().unwrap_or("").trim();
73 first_line.chars().take(200).collect()
74}
75
76pub fn plan_search<P: AsRef<std::path::Path>>(
77 pattern: &str,
78 roots: &[P],
79 opts: &SearchOptions,
80) -> Result<SearchPlan> {
81 let compiled = CompiledPattern::compile(pattern, "", &opts.pattern_options)?;
82 let files = scan(roots, opts)?;
83 let files_scanned = files.len();
84
85 let results: Vec<Result<Option<SearchFile>>> =
86 files.par_iter().map(|path| search_one(&compiled, path, opts)).collect();
87
88 let found = collect(results)?;
89 let total_matches: usize = found.iter().map(|f| f.matches.len()).sum();
90 check_match_counts(total_matches, opts.at_least, opts.at_most)?;
91
92 Ok(SearchPlan { files: found, total_matches, files_scanned })
93}
94
95pub(crate) fn scan<P: AsRef<std::path::Path>>(
96 roots: &[P],
97 opts: &SearchOptions,
98) -> Result<Vec<PathBuf>> {
99 let files = walk_paths(roots, &opts.walk_options)?;
100 if files.len() > opts.max_files {
101 return Err(Error::TooManyFiles { count: files.len(), limit: opts.max_files });
102 }
103 Ok(files)
104}
105
106pub(crate) fn collect(results: Vec<Result<Option<SearchFile>>>) -> Result<Vec<SearchFile>> {
107 let mut out = Vec::new();
108 for r in results {
109 if let Some(f) = r? {
110 out.push(f);
111 }
112 }
113 Ok(out)
114}
115
116fn search_one(
117 compiled: &CompiledPattern,
118 path: &std::path::Path,
119 opts: &SearchOptions,
120) -> Result<Option<SearchFile>> {
121 let (source, _) = match read_text_or_skip_binary(path, opts.max_bytes)? {
122 Some(pair) => pair,
123 None => return Ok(None),
124 };
125
126 let matches: Vec<SearchMatch> = compiled
127 .regex()
128 .find_iter(&source)
129 .map(|m| {
130 let (line, column) = line_col(&source, m.start());
131 SearchMatch { line, column, snippet: truncate_snippet(m.as_str()), capture: None }
132 })
133 .collect();
134
135 if matches.is_empty() {
136 return Ok(None);
137 }
138 Ok(Some(SearchFile { path: path.to_path_buf(), matches }))
139}
140
141#[cfg(test)]
142#[path = "search_tests.rs"]
143mod tests;