1use crate::binary::is_binary;
17use crate::config::{split_csv, Config};
18use crate::error::{FsearchError, FsearchResult};
19use glob::Pattern;
20use rayon::prelude::*;
21use std::collections::HashSet;
22use std::fs;
23use std::io::{BufRead, BufReader};
24use std::path::{Path, PathBuf};
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::sync::{Arc, Mutex};
27use walkdir::WalkDir;
28
29pub type LineMatch = (usize, String);
33
34#[derive(Debug, Clone)]
36pub enum SearchMatch {
37 Path(PathBuf),
39 Content {
41 path: PathBuf,
42 lines: Vec<LineMatch>,
43 },
44}
45
46impl SearchMatch {
47 pub fn path(&self) -> &Path {
49 match self {
50 Self::Path(p) => p,
51 Self::Content { path, .. } => path,
52 }
53 }
54
55 pub fn is_content(&self) -> bool {
57 matches!(self, Self::Content { .. })
58 }
59}
60
61#[derive(Debug, Clone)]
68pub struct SearchOptions {
69 pub base_dirs: Vec<PathBuf>,
73
74 pub pattern: String,
76
77 pub max_depth: u32,
79
80 pub include_dirs: bool,
82
83 pub case_insensitive: bool,
85
86 pub search_in_files: bool,
88
89 pub include_patterns: Vec<String>,
91
92 pub exclude_dirs: Vec<String>,
94
95 pub max_line_length: usize,
97
98 pub binary_check_bytes: usize,
100
101 pub max_results: usize,
103}
104
105impl SearchOptions {
106 pub fn from_config(cfg: &Config, base_dirs: Vec<PathBuf>, pattern: String) -> Self {
108 Self {
109 base_dirs,
110 pattern,
111 max_depth: cfg.default_depth,
112 include_dirs: cfg.include_dirs,
113 case_insensitive: cfg.case_insensitive,
114 search_in_files: false,
115 include_patterns: split_csv(&cfg.default_include),
116 exclude_dirs: cfg.excluded_dirs(),
117 max_line_length: cfg.max_line_length,
118 binary_check_bytes: cfg.binary_check_bytes,
119 max_results: cfg.max_results,
120 }
121 }
122
123 pub fn builder(pattern: impl Into<String>) -> SearchOptionsBuilder {
125 SearchOptionsBuilder::new(pattern.into())
126 }
127}
128
129pub struct SearchOptionsBuilder(SearchOptions);
131
132impl SearchOptionsBuilder {
133 fn new(pattern: String) -> Self {
134 Self(SearchOptions {
135 base_dirs: vec![PathBuf::from(".")],
136 pattern,
137 max_depth: 1,
138 include_dirs: true,
139 case_insensitive: true,
140 search_in_files: false,
141 include_patterns: vec![],
142 exclude_dirs: vec![
143 ".git".into(),
144 "node_modules".into(),
145 "target".into(),
146 ".svn".into(),
147 "__pycache__".into(),
148 ".hg".into(),
149 ".cache".into(),
150 ],
151 max_line_length: 10_000,
152 binary_check_bytes: 1024,
153 max_results: 0,
154 })
155 }
156
157 pub fn base_dir(mut self, p: impl Into<PathBuf>) -> Self {
159 self.0.base_dirs = vec![p.into()];
160 self
161 }
162
163 pub fn base_dirs(mut self, dirs: Vec<impl Into<PathBuf>>) -> Self {
165 self.0.base_dirs = dirs.into_iter().map(Into::into).collect();
166 self
167 }
168
169 pub fn add_dir(mut self, p: impl Into<PathBuf>) -> Self {
171 self.0.base_dirs.push(p.into());
172 self
173 }
174
175 pub fn max_depth(mut self, d: u32) -> Self {
176 self.0.max_depth = d;
177 self
178 }
179 pub fn include_dirs(mut self, v: bool) -> Self {
180 self.0.include_dirs = v;
181 self
182 }
183 pub fn case_insensitive(mut self, v: bool) -> Self {
184 self.0.case_insensitive = v;
185 self
186 }
187 pub fn search_in_files(mut self, v: bool) -> Self {
188 self.0.search_in_files = v;
189 self
190 }
191 pub fn include_patterns(mut self, p: Vec<String>) -> Self {
192 self.0.include_patterns = p;
193 self
194 }
195 pub fn exclude_dirs(mut self, d: Vec<String>) -> Self {
196 self.0.exclude_dirs = d;
197 self
198 }
199 pub fn max_results(mut self, n: usize) -> Self {
200 self.0.max_results = n;
201 self
202 }
203 pub fn build(self) -> SearchOptions {
204 self.0
205 }
206}
207
208pub fn parse_patterns(raw: &str, case_insensitive: bool) -> Vec<String> {
212 raw.split(',')
213 .map(|s| s.trim().to_string())
214 .filter(|s| !s.is_empty())
215 .map(|s| {
216 if case_insensitive {
217 s.to_lowercase()
218 } else {
219 s
220 }
221 })
222 .collect()
223}
224
225fn matches_include(name: &str, patterns: &[String], ci: bool) -> bool {
226 if patterns.is_empty() {
227 return true;
228 }
229 let cmp = if ci {
230 name.to_lowercase()
231 } else {
232 name.to_string()
233 };
234 patterns.iter().any(|p| {
235 Pattern::new(p)
236 .map(|pat| pat.matches(&cmp))
237 .unwrap_or(false)
238 })
239}
240
241fn is_excluded_dir(name: &str, excludes: &[String]) -> bool {
242 excludes
243 .iter()
244 .any(|ex| Pattern::new(ex).map(|p| p.matches(name)).unwrap_or(false) || ex == name)
245}
246
247fn name_matches(entry_name: &str, pattern: &str, ci: bool) -> bool {
248 let (name, pat) = if ci {
249 (entry_name.to_lowercase(), pattern.to_lowercase())
250 } else {
251 (entry_name.to_string(), pattern.to_string())
252 };
253 if pat.contains('*') || pat.contains('?') {
254 Pattern::new(&pat)
255 .map(|p| p.matches(&name))
256 .unwrap_or(false)
257 } else {
258 name.contains(&pat)
259 }
260}
261
262fn search_in_file(
265 path: &Path,
266 pattern: &str,
267 ci: bool,
268 max_line: usize,
269 check_bytes: usize,
270) -> Vec<LineMatch> {
271 if is_binary(path, check_bytes) {
272 return vec![];
273 }
274 let file = match fs::File::open(path) {
275 Ok(f) => f,
276 Err(_) => return vec![],
277 };
278 let pat = if ci {
279 pattern.to_lowercase()
280 } else {
281 pattern.to_string()
282 };
283 BufReader::new(file)
284 .lines()
285 .enumerate()
286 .filter_map(|(i, lr)| {
287 let line = lr.ok()?;
288 if line.len() > max_line {
289 return None;
290 }
291 let cmp = if ci {
292 line.to_lowercase()
293 } else {
294 line.clone()
295 };
296 if cmp.contains(&pat) {
297 Some((i + 1, line))
298 } else {
299 None
300 }
301 })
302 .collect()
303}
304
305fn validate_dirs(dirs: &[PathBuf]) -> FsearchResult<()> {
308 if dirs.is_empty() {
309 return Err(FsearchError::DirectoryNotFound(
310 "(no directories specified)".into(),
311 ));
312 }
313 for d in dirs {
314 if !d.exists() {
315 return Err(FsearchError::DirectoryNotFound(d.display().to_string()));
316 }
317 if !d.is_dir() {
318 return Err(FsearchError::NotADirectory(d.display().to_string()));
319 }
320 }
321 Ok(())
322}
323
324pub fn fast_find(
331 opts: &SearchOptions,
332 interrupted: Arc<AtomicBool>,
333) -> FsearchResult<Vec<SearchMatch>> {
334 validate_dirs(&opts.base_dirs)?;
335
336 let seen: Arc<Mutex<HashSet<PathBuf>>> = Arc::new(Mutex::new(HashSet::new()));
339
340 let mut all_results: Vec<SearchMatch> = opts
341 .base_dirs
342 .par_iter()
343 .flat_map(|base| {
344 if interrupted.load(Ordering::Relaxed) {
345 return vec![];
346 }
347 let entries: Vec<_> = WalkDir::new(base)
348 .max_depth(opts.max_depth as usize + 1)
349 .follow_links(false)
350 .into_iter()
351 .filter_entry(|e| {
352 if e.file_type().is_dir() && e.depth() > 0 {
353 let name = e.file_name().to_string_lossy().to_string();
354 if is_excluded_dir(&name, &opts.exclude_dirs) {
355 return false;
356 }
357 }
358 true
359 })
360 .filter_map(|e| e.ok())
361 .filter(|e| e.depth() > 0)
362 .collect();
363
364 entries
365 .into_par_iter()
366 .filter_map(|entry| {
367 if interrupted.load(Ordering::Relaxed) {
368 return None;
369 }
370
371 let is_dir = entry.file_type().is_dir();
372 let name = entry.file_name().to_string_lossy().to_string();
373 let path = entry.path().to_path_buf();
374
375 {
377 let mut guard = seen.lock().unwrap();
378 if !guard.insert(path.clone()) {
379 return None;
380 }
381 }
382
383 if !is_dir
384 && !matches_include(&name, &opts.include_patterns, opts.case_insensitive)
385 {
386 return None;
387 }
388
389 if opts.search_in_files {
390 if is_dir {
391 return None;
392 }
393 let lines = search_in_file(
394 &path,
395 &opts.pattern,
396 opts.case_insensitive,
397 opts.max_line_length,
398 opts.binary_check_bytes,
399 );
400 if lines.is_empty() {
401 None
402 } else {
403 Some(SearchMatch::Content { path, lines })
404 }
405 } else {
406 if is_dir && !opts.include_dirs {
407 return None;
408 }
409 if name_matches(&name, &opts.pattern, opts.case_insensitive) {
410 Some(SearchMatch::Path(path))
411 } else {
412 None
413 }
414 }
415 })
416 .collect::<Vec<_>>()
417 })
418 .collect();
419
420 all_results.sort_unstable_by(|a, b| a.path().cmp(b.path()));
422
423 Ok(cap(all_results, opts.max_results))
424}
425
426pub fn recursive_find(
433 opts: &SearchOptions,
434 interrupted: Arc<AtomicBool>,
435) -> FsearchResult<Vec<SearchMatch>> {
436 validate_dirs(&opts.base_dirs)?;
437
438 let mut seen: HashSet<PathBuf> = HashSet::new();
439 let mut matches: Vec<SearchMatch> = Vec::new();
440
441 for base in &opts.base_dirs {
442 if interrupted.load(Ordering::Relaxed) {
443 break;
444 }
445 walk_dir(base, opts, 0, &mut matches, &mut seen, &interrupted);
446 }
447
448 matches.sort_unstable_by(|a, b| a.path().cmp(b.path()));
449 Ok(cap(matches, opts.max_results))
450}
451
452fn walk_dir(
453 dir: &Path,
454 opts: &SearchOptions,
455 depth: u32,
456 matches: &mut Vec<SearchMatch>,
457 seen: &mut HashSet<PathBuf>,
458 interrupted: &AtomicBool,
459) {
460 if depth > opts.max_depth || interrupted.load(Ordering::Relaxed) {
461 return;
462 }
463 let entries = match fs::read_dir(dir) {
464 Ok(e) => e,
465 Err(_) => return,
466 };
467
468 for entry in entries.flatten() {
469 if interrupted.load(Ordering::Relaxed) {
470 break;
471 }
472
473 let path = entry.path();
474 let file_type = match entry.file_type() {
475 Ok(ft) => ft,
476 Err(_) => continue,
477 };
478 let name = entry.file_name().to_string_lossy().to_string();
479
480 if !seen.insert(path.clone()) {
482 continue;
483 }
484
485 if file_type.is_dir() {
486 if is_excluded_dir(&name, &opts.exclude_dirs) {
487 continue;
488 }
489 if !opts.search_in_files
490 && opts.include_dirs
491 && name_matches(&name, &opts.pattern, opts.case_insensitive)
492 {
493 matches.push(SearchMatch::Path(path.clone()));
494 }
495 walk_dir(&path, opts, depth + 1, matches, seen, interrupted);
496 } else if file_type.is_file() {
497 if !matches_include(&name, &opts.include_patterns, opts.case_insensitive) {
498 continue;
499 }
500 if opts.search_in_files {
501 let lines = search_in_file(
502 &path,
503 &opts.pattern,
504 opts.case_insensitive,
505 opts.max_line_length,
506 opts.binary_check_bytes,
507 );
508 if !lines.is_empty() {
509 matches.push(SearchMatch::Content { path, lines });
510 }
511 } else if name_matches(&name, &opts.pattern, opts.case_insensitive) {
512 matches.push(SearchMatch::Path(path));
513 }
514 }
515 }
516}
517
518fn cap(mut v: Vec<SearchMatch>, limit: usize) -> Vec<SearchMatch> {
521 if limit > 0 && v.len() > limit {
522 v.truncate(limit);
523 }
524 v
525}