use std::path::{Path, PathBuf}; use crate::FsAccessPolicy; use grep_regex::RegexMatcherBuilder; use grep_searcher::sinks::UTF8 as UTF8Sink; use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkMatch}; use ignore::WalkBuilder; use ignore::overrides::OverrideBuilder; use ignore::types::TypesBuilder; use crate::{FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink}; struct ContentLine { path: PathBuf, line_number: Option, text: String, is_match: bool, } struct GrepReport { mode: GrepOutputMode, show_line_numbers: bool, files: Vec, counts: Vec<(PathBuf, usize)>, lines: Vec, truncated: bool, } impl GrepReport { fn into_result(self, root: &Path) -> GrepResult { let (match_count, matched_files) = match self.mode { GrepOutputMode::FilesWithMatches => (self.files.len(), self.files.len()), GrepOutputMode::Count => ( self.counts.iter().map(|(_, count)| *count).sum(), self.counts.len(), ), GrepOutputMode::Content => ( self.lines.iter().filter(|line| line.is_match).count(), self.lines .iter() .map(|line| line.path.as_path()) .collect::>() .len(), ), }; let mut output = String::new(); match self.mode { GrepOutputMode::FilesWithMatches => { for path in &self.files { output.push_str(&logical_display(root, path)); output.push('\n'); } } GrepOutputMode::Count => { for (path, count) in &self.counts { output.push_str(&format!("{}:{count}\n", logical_display(root, path))); } } GrepOutputMode::Content => { for line in &self.lines { let separator = if line.is_match { ':' } else { '-' }; let path = logical_display(root, &line.path); if self.show_line_numbers && let Some(number) = line.line_number { output.push_str(&format!( "{path}{separator}{number}{separator}{}\n", line.text )); } else { output.push_str(&format!("{path}{separator}{}\n", line.text)); } } } } GrepResult { output, match_count, matched_files, truncated: self.truncated, } } } fn logical_display(root: &Path, path: &Path) -> String { path.strip_prefix(root) .unwrap_or(path) .to_string_lossy() .replace('\\', "/") } const DEFAULT_HEAD_LIMIT: usize = 250; struct GrepParams { pattern: String, path: Option, glob: Option, file_type: Option, case_insensitive: bool, before: Option, after: Option, context: Option, line_numbers: Option, multiline: bool, output_mode: Option, head_limit: Option, offset: Option, } pub fn run_grep( root: &Path, base: PathBuf, request: GrepRequest, access: &dyn FsAccessPolicy, ) -> Result { let p = GrepParams { pattern: request.pattern, path: Some(base.clone()), glob: request.glob, file_type: request.file_type, case_insensitive: request.case_insensitive, before: Some(request.before_context), after: Some(request.after_context), context: None, line_numbers: Some(true), multiline: request.multiline, output_mode: Some(request.output_mode), head_limit: Some(request.limit), offset: Some(request.offset), }; let matcher = RegexMatcherBuilder::new() .case_insensitive(p.case_insensitive) .multi_line(p.multiline) .dot_matches_new_line(p.multiline) .build(&p.pattern) .map_err(|e| FsError::InvalidRegex(e.to_string()))?; let (before, after) = match (p.before, p.after, p.context) { (_, _, Some(c)) => (c, c), (b, a, None) => (b.unwrap_or(0), a.unwrap_or(0)), }; let mut sb = SearcherBuilder::new(); sb.binary_detection(BinaryDetection::quit(b'\x00')) .line_number(p.line_numbers.unwrap_or(true)) .multi_line(p.multiline) .before_context(before) .after_context(after); let mut searcher = sb.build(); let base = p.path.unwrap_or(base); if !base.is_absolute() { return Err(FsError::RelativePath(base)); } let symlink = direct_symlink(&base); if !access.is_readable(&base) { return Err(if let Some(info) = symlink.as_ref() { let link_parent_readable = info .link_path .parent() .map(|parent| access.is_readable(parent)) .unwrap_or(false); if info.target_exists && link_parent_readable { FsError::SymlinkOutOfScope { path: base.clone(), target: info.resolved_path.clone(), required_permission: "read", } } else { FsError::OutOfScope(base.clone()) } } else { FsError::OutOfScope(base.clone()) }); } if let Some(info) = symlink.as_ref() { if !info.target_exists { return Err(FsError::BrokenSymlink { path: base.clone(), link: info.link_path.clone(), target: info.target_path.clone(), }); } } let base_meta = std::fs::metadata(&base).map_err(|e| match e.kind() { std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()), _ => FsError::io(&base, e), })?; if !base_meta.is_dir() { return Err(FsError::InvalidArgument(format!( "grep search path is not a directory: {}", base.display() ))); } if let Some(info) = symlink.as_ref() { return Err(FsError::SymlinkDirectoryNotTraversed { tool: "Grep", path: base.clone(), target: info.resolved_path.clone(), }); } let mut wb = WalkBuilder::new(&base); wb.hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .ignore(true) .parents(true) .follow_links(false); if let Some(t) = p.file_type.as_deref() { let mut tb = TypesBuilder::new(); tb.add_defaults(); tb.select(t); let types = tb .build() .map_err(|e| FsError::InvalidArgument(format!("invalid type {t}: {e}")))?; wb.types(types); } if let Some(g) = p.glob.as_deref() { let mut ob = OverrideBuilder::new(&base); ob.add(g).map_err(|e| FsError::InvalidGlob(e.to_string()))?; let ov = ob .build() .map_err(|e| FsError::InvalidGlob(e.to_string()))?; wb.overrides(ov); } let mode = p.output_mode.unwrap_or_default(); let head_limit = p.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT); let offset = p.offset.unwrap_or(0); let show_line_numbers = p.line_numbers.unwrap_or(true); let mut report = GrepReport { mode, show_line_numbers, files: Vec::new(), counts: Vec::new(), lines: Vec::new(), truncated: false, }; // Per-mode walker state. let mut matching_files_seen: usize = 0; let mut matches_seen: usize = 0; 'walker: for entry in wb.build().flatten() { if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) { continue; } let path = entry.path(); if !access.is_readable(path) { continue; } match mode { GrepOutputMode::FilesWithMatches => { let hit = scan_any_match(&mut searcher, &matcher, path)?; if !hit { continue; } if matching_files_seen >= offset { report.files.push(path.to_path_buf()); if report.files.len() >= head_limit { report.truncated = true; break 'walker; } } matching_files_seen += 1; } GrepOutputMode::Count => { let count = scan_count(&mut searcher, &matcher, path)?; if count == 0 { continue; } if matching_files_seen >= offset { report.counts.push((path.to_path_buf(), count)); if report.counts.len() >= head_limit { report.truncated = true; break 'walker; } } matching_files_seen += 1; } GrepOutputMode::Content => { let before_count = matches_seen; let mut sink = ContentSink { path: path.to_path_buf(), lines: &mut report.lines, matches_seen: &mut matches_seen, offset, head_limit, }; searcher .search_path(&matcher, path, &mut sink) .map_err(|e| FsError::io(path, e))?; // If we hit head_limit during this file, stop walking. if matches_seen >= offset.saturating_add(head_limit) && matches_seen > before_count { report.truncated = true; break 'walker; } } } } Ok(report.into_result(root)) } fn scan_any_match( searcher: &mut Searcher, matcher: &grep_regex::RegexMatcher, path: &Path, ) -> Result { let mut hit = false; let sink = UTF8Sink(|_, _| { hit = true; Ok(false) // stop searching this file immediately }); searcher .search_path(matcher, path, sink) .map_err(|e| FsError::io(path, e))?; Ok(hit) } fn scan_count( searcher: &mut Searcher, matcher: &grep_regex::RegexMatcher, path: &Path, ) -> Result { let mut count = 0usize; let sink = UTF8Sink(|_, _| { count += 1; Ok(true) }); searcher .search_path(matcher, path, sink) .map_err(|e| FsError::io(path, e))?; Ok(count) } struct ContentSink<'a> { path: PathBuf, lines: &'a mut Vec, matches_seen: &'a mut usize, offset: usize, head_limit: usize, } impl Sink for ContentSink<'_> { type Error = std::io::Error; fn matched(&mut self, _searcher: &Searcher, mat: &SinkMatch<'_>) -> Result { let idx = *self.matches_seen; *self.matches_seen += 1; // Skip matches before offset. if idx < self.offset { return Ok(true); } // Stop searching this file once we've filled the head_limit. if idx >= self.offset.saturating_add(self.head_limit) { return Ok(false); } let text = String::from_utf8_lossy(mat.bytes()) .trim_end_matches('\n') .trim_end_matches('\r') .to_string(); self.lines.push(ContentLine { path: self.path.clone(), line_number: mat.line_number(), text, is_match: true, }); Ok(true) } fn context( &mut self, _searcher: &Searcher, ctx: &SinkContext<'_>, ) -> Result { let seen = *self.matches_seen; if seen < self.offset { return Ok(true); } if seen >= self.offset.saturating_add(self.head_limit) { return Ok(false); } let text = String::from_utf8_lossy(ctx.bytes()) .trim_end_matches('\n') .trim_end_matches('\r') .to_string(); self.lines.push(ContentLine { path: self.path.clone(), line_number: ctx.line_number(), text, is_match: false, }); Ok(true) } }