diff --git a/crates/fs-operation/src/lib.rs b/crates/fs-operation/src/lib.rs index f63a1ce3..53999081 100644 --- a/crates/fs-operation/src/lib.rs +++ b/crates/fs-operation/src/lib.rs @@ -279,4 +279,58 @@ mod tests { assert_eq!(grep.matched_files, 2); assert!(!grep.output.contains("c.txt")); } + + #[test] + fn grep_content_groups_lines_by_file_and_marks_matches() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("first.txt"), + "before\nneedle one\nafter\nomitted one\nomitted two\nbefore distant\nneedle distant\nafter distant\n", + ) + .unwrap(); + std::fs::write(temp.path().join("second.txt"), "needle two\n").unwrap(); + let root = temp.path().canonicalize().unwrap(); + let readable = RootAccess(root.clone()); + + let grep = run_grep( + &root, + root.clone(), + GrepRequest { + pattern: "needle".to_string(), + path: FsPath::root(), + glob: Some("*.txt".to_string()), + output_mode: GrepOutputMode::Content, + case_insensitive: false, + before_context: 1, + after_context: 1, + multiline: false, + file_type: None, + limit: 20, + offset: 0, + }, + &readable, + ) + .unwrap(); + + assert_eq!(grep.match_count, 3); + assert_eq!(grep.matched_files, 2); + assert_eq!( + grep.output, + concat!( + "first.txt\n", + " 1 │ before\n", + " > 2 │ needle one\n", + " 3 │ after\n", + " …\n", + " 6 │ before distant\n", + " > 7 │ needle distant\n", + " 8 │ after distant\n", + "\n", + "second.txt\n", + " > 1 │ needle two\n", + ) + ); + assert_eq!(grep.output.matches("first.txt").count(), 1); + assert_eq!(grep.output.matches("second.txt").count(), 1); + } } diff --git a/crates/fs-operation/src/search.rs b/crates/fs-operation/src/search.rs index 74a66d27..54420f6f 100644 --- a/crates/fs-operation/src/search.rs +++ b/crates/fs-operation/src/search.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; +use std::fmt::Write as _; use std::path::{Path, PathBuf}; use crate::FsAccessPolicy; @@ -57,20 +59,11 @@ impl GrepReport { } } 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)); - } - } + output.push_str(&render_content_lines( + root, + &self.lines, + self.show_line_numbers, + )); } } GrepResult { @@ -82,6 +75,48 @@ impl GrepReport { } } +fn render_content_lines(root: &Path, lines: &[ContentLine], show_line_numbers: bool) -> String { + let mut grouped = BTreeMap::<&Path, Vec<&ContentLine>>::new(); + for line in lines { + grouped.entry(&line.path).or_default().push(line); + } + + let mut output = String::new(); + for (file_index, (path, file_lines)) in grouped.into_iter().enumerate() { + if file_index > 0 { + output.push('\n'); + } + let _ = writeln!(output, "{}", logical_display(root, path)); + + let number_width = file_lines + .iter() + .filter_map(|line| line.line_number) + .map(|number| number.to_string().len()) + .max() + .unwrap_or(1); + let mut previous_line_end = None; + for line in file_lines { + if let (Some(previous_end), Some(number)) = (previous_line_end, line.line_number) + && number > previous_end + { + let _ = writeln!(output, " …"); + } + + let marker = if line.is_match { '>' } else { ' ' }; + if show_line_numbers && let Some(number) = line.line_number { + let _ = writeln!(output, " {marker} {number:>number_width$} │ {}", line.text); + } else { + let _ = writeln!(output, " {marker} │ {}", line.text); + } + previous_line_end = line + .line_number + .map(|number| number + line.text.split('\n').count() as u64); + } + } + + output +} + fn logical_display(root: &Path, path: &Path) -> String { path.strip_prefix(root) .unwrap_or(path) diff --git a/crates/tools/src/grep.rs b/crates/tools/src/grep.rs index 598015b6..27c26ad0 100644 --- a/crates/tools/src/grep.rs +++ b/crates/tools/src/grep.rs @@ -129,7 +129,7 @@ pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition { Arc::new(move || { let schema = schemars::schema_for!(GrepParams); let meta = ToolMeta::new("Grep") - .description("Search Workdir file contents with a regex. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.") + .description("Search Workdir file contents with a regex. Content results group lines by file; `>` marks matching lines and unmarked lines are context. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.") .input_schema(serde_json::to_value(schema).expect("Grep schema serialization")); let tool: Arc = Arc::new(GrepTool { session: session.clone(),