Skip to repository content1426 lines · 51.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:37:40.113Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
grep_tool.rs
1use crate::{AgentTool, ToolCallEventStream, ToolInput};
2use acp_thread::MentionUri;
3use agent_client_protocol::schema::v1 as acp;
4use anyhow::Result;
5use futures::{FutureExt as _, StreamExt};
6use gpui::{App, Entity, SharedString, Task};
7use language::{OffsetRangeExt, ParseStatus, Point};
8use project::{
9 Project, ProjectPath, SearchResults, WorktreeSettings,
10 search::{SearchQuery, SearchResult},
11};
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use settings::Settings;
15use std::{cmp, fmt::Write, sync::Arc};
16use util::RangeExt;
17use util::markdown::{MarkdownCodeBlock, MarkdownInlineCode};
18use util::paths::PathMatcher;
19
20/// Searches the contents of files in the project with a regular expression
21///
22/// - Prefer this tool to path search when searching for symbols in the project, because you won't need to guess what path it's in.
23/// - Supports full regex syntax (eg. "log.*Error", "function\\s+\\w+", etc.)
24/// - Pass an `include_pattern` if you know how to narrow your search on the files system
25/// - Never use this tool to search for paths. Only search file contents with this tool.
26/// - Use this tool when you need to find files containing specific patterns
27/// - Results are paginated with 20 matches per page. Use the optional 'offset' parameter to request subsequent pages.
28/// - DO NOT use HTML entities solely to escape characters in the tool parameters.
29#[derive(Debug, Serialize, Deserialize, JsonSchema)]
30pub struct GrepToolInput {
31 /// A regex pattern to search for in the entire project. Note that the regex will be parsed by the Rust `regex` crate.
32 ///
33 /// Do NOT specify a path here! This will only be matched against the code **content**.
34 pub regex: String,
35 /// A glob pattern for the paths of files to include in the search.
36 /// Supports standard glob patterns like "**/*.rs" or "frontend/src/**/*.ts".
37 /// If omitted, all files in the project will be searched.
38 ///
39 /// The glob pattern is matched against the full path including the project root directory.
40 ///
41 /// <example>
42 /// If the project has the following root directories:
43 ///
44 /// - /a/b/backend
45 /// - /c/d/frontend
46 ///
47 /// Use "backend/**/*.rs" to search only Rust files in the backend root directory.
48 /// Use "frontend/src/**/*.ts" to search TypeScript files only in the frontend root directory (sub-directory "src").
49 /// Use "**/*.rs" to search Rust files across all root directories.
50 /// </example>
51 pub include_pattern: Option<String>,
52 /// Optional starting position for paginated results (0-based).
53 /// When not provided, starts from the beginning.
54 #[serde(default)]
55 pub offset: u32,
56 /// Whether the regex is case-sensitive. Defaults to false (case-insensitive).
57 #[serde(default)]
58 pub case_sensitive: bool,
59}
60
61impl GrepToolInput {
62 /// Which page of search results this is.
63 pub fn page(&self) -> u32 {
64 1 + (self.offset / RESULTS_PER_PAGE)
65 }
66}
67
68const RESULTS_PER_PAGE: u32 = 20;
69
70pub struct GrepTool {
71 project: Entity<Project>,
72}
73
74impl GrepTool {
75 pub fn new(project: Entity<Project>) -> Self {
76 Self { project }
77 }
78}
79
80impl AgentTool for GrepTool {
81 type Input = GrepToolInput;
82 type Output = String;
83
84 const NAME: &'static str = "grep";
85
86 fn kind() -> acp::ToolKind {
87 acp::ToolKind::Search
88 }
89
90 fn initial_title(
91 &self,
92 input: Result<Self::Input, serde_json::Value>,
93 _cx: &mut App,
94 ) -> SharedString {
95 match input {
96 Ok(input) => {
97 let page = input.page();
98 let regex_str = MarkdownInlineCode(&input.regex);
99 let case_info = if input.case_sensitive {
100 " (case-sensitive)"
101 } else {
102 ""
103 };
104
105 if page > 1 {
106 format!("Get page {page} of search results for regex {regex_str}{case_info}")
107 } else {
108 format!("Search files for regex {regex_str}{case_info}")
109 }
110 }
111 Err(_) => "Search with regex".into(),
112 }
113 .into()
114 }
115
116 fn run(
117 self: Arc<Self>,
118 input: ToolInput<Self::Input>,
119 event_stream: ToolCallEventStream,
120 cx: &mut App,
121 ) -> Task<Result<Self::Output, Self::Output>> {
122 const CONTEXT_LINES: u32 = 2;
123 const MAX_ANCESTOR_LINES: u32 = 10;
124
125 let project = self.project.clone();
126 cx.spawn(async move |cx| {
127 let input = input
128 .recv()
129 .await
130 .map_err(|e| e.to_string())?;
131
132 let results = cx.update(|cx| {
133 let path_style = project.read(cx).path_style(cx);
134
135 let include_matcher = PathMatcher::new(
136 input
137 .include_pattern
138 .as_ref()
139 .into_iter()
140 .collect::<Vec<_>>(),
141 path_style,
142 )
143 .map_err(|error| format!("invalid include glob pattern: {error}"))?;
144
145 // Exclude global file_scan_exclusions and private_files settings
146 let exclude_matcher = {
147 let global_settings = WorktreeSettings::get_global(cx);
148 let exclude_patterns = global_settings
149 .file_scan_exclusions
150 .sources()
151 .chain(global_settings.private_files.sources());
152
153 PathMatcher::new(exclude_patterns, path_style)
154 .map_err(|error| format!("invalid exclude pattern: {error}"))?
155 };
156
157 let query = SearchQuery::regex(
158 &input.regex,
159 false,
160 input.case_sensitive,
161 false,
162 false,
163 include_matcher,
164 exclude_matcher,
165 true, // Always match file include pattern against *full project paths* that start with a project root.
166 None,
167 )
168 .map_err(|error| error.to_string())?;
169
170 Ok::<_, String>(
171 project.update(cx, |project, cx| project.search(query, cx)),
172 )
173 })?;
174
175 let project = project.downgrade();
176 // Keep the search alive for the duration of result iteration. Dropping this task is the
177 // cancellation mechanism; we intentionally do not detach it.
178 let SearchResults {rx, ..} = results;
179 futures::pin_mut!(rx);
180
181 let mut output = String::new();
182 let mut content = Vec::new();
183 let mut locations = Vec::new();
184 let mut skips_remaining = input.offset;
185 let mut matches_found = 0;
186 let mut has_more_matches = false;
187
188 'outer: loop {
189 let search_result = futures::select! {
190 result = rx.next().fuse() => result,
191 _ = event_stream.cancelled_by_user().fuse() => {
192 return Err("Search cancelled by user".to_string());
193 }
194 };
195
196 let (buffer, ranges) = match search_result {
197 Some(SearchResult::Buffer { buffer, ranges }) => (buffer, ranges),
198 Some(SearchResult::LimitReached) => {
199 has_more_matches = true;
200 break;
201 }
202 Some(SearchResult::WaitingForScan | SearchResult::Searching) => continue,
203 None => break,
204 };
205 if ranges.is_empty() {
206 continue;
207 }
208
209 let (Some((path, project_path)), mut parse_status) =
210 buffer.read_with(cx, |buffer, cx| {
211 (
212 buffer.file().map(|file| {
213 (
214 file.full_path(cx),
215 ProjectPath {
216 worktree_id: file.worktree_id(cx),
217 path: file.path().clone(),
218 },
219 )
220 }),
221 buffer.parse_status(),
222 )
223 })
224 else {
225 continue;
226 };
227
228 // Check if this file should be excluded based on its worktree settings
229 if cx.update(|cx| {
230 let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
231 worktree_settings.is_path_excluded(&project_path.path)
232 || worktree_settings.is_path_private(&project_path.path)
233 }) {
234 continue;
235 }
236
237 let abs_path = project
238 .read_with(cx, |project, cx| project.absolute_path(&project_path, cx))
239 .ok()
240 .flatten();
241
242 while *parse_status.borrow() != ParseStatus::Idle {
243 parse_status.changed().await.map_err(|e| e.to_string())?;
244 }
245
246 let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
247
248 let mut ranges = ranges
249 .into_iter()
250 .map(|range| {
251 let matched = range.to_point(&snapshot);
252 let matched_end_line_len = snapshot.line_len(matched.end.row);
253 let full_lines = Point::new(matched.start.row, 0)..Point::new(matched.end.row, matched_end_line_len);
254 let symbols = snapshot.symbols_containing(matched.start, None);
255
256 if let Some(ancestor_node) = snapshot.syntax_ancestor(full_lines.clone()) {
257 let full_ancestor_range = ancestor_node.byte_range().to_point(&snapshot);
258 let end_row = full_ancestor_range.end.row.min(full_ancestor_range.start.row + MAX_ANCESTOR_LINES);
259 let end_col = snapshot.line_len(end_row);
260 let capped_ancestor_range = Point::new(full_ancestor_range.start.row, 0)..Point::new(end_row, end_col);
261
262 if capped_ancestor_range.contains_inclusive(&full_lines) {
263 return (capped_ancestor_range, Some(full_ancestor_range), symbols)
264 }
265 }
266
267 let mut matched = matched;
268 matched.start.column = 0;
269 matched.start.row =
270 matched.start.row.saturating_sub(CONTEXT_LINES);
271 matched.end.row = cmp::min(
272 snapshot.max_point().row,
273 matched.end.row + CONTEXT_LINES,
274 );
275 matched.end.column = snapshot.line_len(matched.end.row);
276
277 (matched, None, symbols)
278 })
279 .peekable();
280
281 let mut file_header_written = false;
282
283 while let Some((mut range, ancestor_range, parent_symbols)) = ranges.next(){
284 if skips_remaining > 0 {
285 skips_remaining -= 1;
286 continue;
287 }
288
289 // We'd already found a full page of matches, and we just found one more.
290 if matches_found >= RESULTS_PER_PAGE {
291 has_more_matches = true;
292 break 'outer;
293 }
294
295 while let Some((next_range, _, _)) = ranges.peek() {
296 if range.end.row >= next_range.start.row {
297 range.end = next_range.end;
298 ranges.next();
299 } else {
300 break;
301 }
302 }
303
304 if !file_header_written {
305 writeln!(output, "\n## Matches in {}", path.display())
306 .ok();
307 file_header_written = true;
308 }
309
310 let end_row = range.end.row;
311 output.push_str("\n### ");
312
313 for symbol in parent_symbols {
314 write!(output, "{} › ", symbol.text)
315 .ok();
316 }
317
318 let line_label = if range.start.row == end_row {
319 format!("L{}", range.start.row + 1)
320 } else {
321 format!("L{}-{}", range.start.row + 1, end_row + 1)
322 };
323 writeln!(output, "{line_label}").ok();
324
325 let snippet: String = snapshot.text_for_range(range.clone()).collect();
326 output.push_str("```\n");
327 output.push_str(&snippet);
328 output.push_str("\n```\n");
329
330 if let Some(abs_path) = &abs_path {
331 let uri = MentionUri::Selection {
332 abs_path: Some(abs_path.clone()),
333 line_range: range.start.row..=end_row,
334 column: None,
335 };
336 content.push(acp::ToolCallContent::Content(acp::Content::new(
337 acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
338 format!("{}#{}", path.display(), line_label),
339 uri.to_uri().to_string(),
340 )),
341 )));
342 locations.push(
343 acp::ToolCallLocation::new(abs_path).line(Some(range.start.row)),
344 );
345 }
346 // Use a fence longer than any backtick run in the snippet so
347 // matches containing code fences don't break the rendering.
348 content.push(acp::ToolCallContent::Content(acp::Content::new(
349 acp::ContentBlock::Text(acp::TextContent::new(
350 MarkdownCodeBlock {
351 tag: "",
352 text: &snippet,
353 }
354 .to_string(),
355 )),
356 )));
357
358 if let Some(ancestor_range) = ancestor_range
359 && end_row < ancestor_range.end.row {
360 let remaining_lines = ancestor_range.end.row - end_row;
361 writeln!(output, "\n{} lines remaining in ancestor node. Read the file to see all.", remaining_lines)
362 .ok();
363 }
364
365 matches_found += 1;
366 }
367 }
368
369 if !content.is_empty() {
370 event_stream.update_fields(
371 acp::ToolCallUpdateFields::new()
372 .content(content)
373 .locations(locations),
374 );
375 }
376
377 if matches_found == 0 {
378 Ok("No matches found".into())
379 } else if has_more_matches {
380 Ok(format!(
381 "Showing matches {}-{} (there were more matches found; use offset: {} to see next page):\n{output}",
382 input.offset + 1,
383 input.offset + matches_found,
384 input.offset + RESULTS_PER_PAGE,
385 ))
386 } else {
387 Ok(format!("Found {matches_found} matches:\n{output}"))
388 }
389 })
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use crate::ToolCallEventStream;
396
397 use super::*;
398 use gpui::{TestAppContext, UpdateGlobal};
399 use project::{FakeFs, Project};
400 use serde_json::json;
401 use settings::SettingsStore;
402 use std::path::PathBuf;
403 use unindent::Unindent;
404 use util::path;
405
406 #[gpui::test]
407 async fn test_grep_tool_with_include_pattern(cx: &mut TestAppContext) {
408 init_test(cx);
409 cx.executor().allow_parking();
410
411 let fs = FakeFs::new(cx.executor());
412 fs.insert_tree(
413 path!("/root"),
414 serde_json::json!({
415 "src": {
416 "main.rs": "fn main() {\n println!(\"Hello, world!\");\n}",
417 "utils": {
418 "helper.rs": "fn helper() {\n println!(\"I'm a helper!\");\n}",
419 },
420 },
421 "tests": {
422 "test_main.rs": "fn test_main() {\n assert!(true);\n}",
423 }
424 }),
425 )
426 .await;
427
428 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
429
430 // Test with include pattern for Rust files inside the root of the project
431 let input = GrepToolInput {
432 regex: "println".to_string(),
433 include_pattern: Some("root/**/*.rs".to_string()),
434 offset: 0,
435 case_sensitive: false,
436 };
437
438 let result = run_grep_tool(input, project.clone(), cx).await;
439 assert!(result.contains("main.rs"), "Should find matches in main.rs");
440 assert!(
441 result.contains("helper.rs"),
442 "Should find matches in helper.rs"
443 );
444 assert!(
445 !result.contains("test_main.rs"),
446 "Should not include test_main.rs even though it's a .rs file (because it doesn't have the pattern)"
447 );
448
449 // Test with include pattern for src directory only
450 let input = GrepToolInput {
451 regex: "fn".to_string(),
452 include_pattern: Some("root/**/src/**".to_string()),
453 offset: 0,
454 case_sensitive: false,
455 };
456
457 let result = run_grep_tool(input, project.clone(), cx).await;
458 assert!(
459 result.contains("main.rs"),
460 "Should find matches in src/main.rs"
461 );
462 assert!(
463 result.contains("helper.rs"),
464 "Should find matches in src/utils/helper.rs"
465 );
466 assert!(
467 !result.contains("test_main.rs"),
468 "Should not include test_main.rs as it's not in src directory"
469 );
470
471 // Test with empty include pattern (should default to all files)
472 let input = GrepToolInput {
473 regex: "fn".to_string(),
474 include_pattern: None,
475 offset: 0,
476 case_sensitive: false,
477 };
478
479 let result = run_grep_tool(input, project.clone(), cx).await;
480 assert!(result.contains("main.rs"), "Should find matches in main.rs");
481 assert!(
482 result.contains("helper.rs"),
483 "Should find matches in helper.rs"
484 );
485 assert!(
486 result.contains("test_main.rs"),
487 "Should include test_main.rs"
488 );
489 }
490
491 #[gpui::test]
492 async fn test_grep_tool_with_case_sensitivity(cx: &mut TestAppContext) {
493 init_test(cx);
494 cx.executor().allow_parking();
495
496 let fs = FakeFs::new(cx.executor());
497 fs.insert_tree(
498 path!("/root"),
499 serde_json::json!({
500 "case_test.txt": "This file has UPPERCASE and lowercase text.\nUPPERCASE patterns should match only with case_sensitive: true",
501 }),
502 )
503 .await;
504
505 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
506
507 // Test case-insensitive search (default)
508 let input = GrepToolInput {
509 regex: "uppercase".to_string(),
510 include_pattern: Some("**/*.txt".to_string()),
511 offset: 0,
512 case_sensitive: false,
513 };
514
515 let result = run_grep_tool(input, project.clone(), cx).await;
516 assert!(
517 result.contains("UPPERCASE"),
518 "Case-insensitive search should match uppercase"
519 );
520
521 // Test case-sensitive search
522 let input = GrepToolInput {
523 regex: "uppercase".to_string(),
524 include_pattern: Some("**/*.txt".to_string()),
525 offset: 0,
526 case_sensitive: true,
527 };
528
529 let result = run_grep_tool(input, project.clone(), cx).await;
530 assert!(
531 !result.contains("UPPERCASE"),
532 "Case-sensitive search should not match uppercase"
533 );
534
535 // Test case-sensitive search
536 let input = GrepToolInput {
537 regex: "LOWERCASE".to_string(),
538 include_pattern: Some("**/*.txt".to_string()),
539 offset: 0,
540 case_sensitive: true,
541 };
542
543 let result = run_grep_tool(input, project.clone(), cx).await;
544
545 assert!(
546 !result.contains("lowercase"),
547 "Case-sensitive search should match lowercase"
548 );
549
550 // Test case-sensitive search for lowercase pattern
551 let input = GrepToolInput {
552 regex: "lowercase".to_string(),
553 include_pattern: Some("**/*.txt".to_string()),
554 offset: 0,
555 case_sensitive: true,
556 };
557
558 let result = run_grep_tool(input, project.clone(), cx).await;
559 assert!(
560 result.contains("lowercase"),
561 "Case-sensitive search should match lowercase text"
562 );
563 }
564
565 // The grep tool streams a clickable `file://` ResourceLink and a tool-call
566 // location for every match so each result opens the file at the matched line
567 // in the agent panel. The model-facing text output stays link-free.
568 #[gpui::test]
569 async fn test_grep_results_are_clickable_file_links(cx: &mut TestAppContext) {
570 init_test(cx);
571 cx.executor().allow_parking();
572
573 let fs = FakeFs::new(cx.executor());
574 fs.insert_tree(
575 path!("/root"),
576 json!({
577 "src": {
578 "alpha.txt": "the needle is in alpha",
579 },
580 "beta.txt": "the needle is in beta",
581 }),
582 )
583 .await;
584
585 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
586
587 let tool = Arc::new(GrepTool { project });
588 let (event_stream, mut events) = ToolCallEventStream::test();
589 let input = GrepToolInput {
590 regex: "needle".to_string(),
591 include_pattern: None,
592 offset: 0,
593 case_sensitive: false,
594 };
595 let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
596 let output = task.await.expect("grep tool should succeed");
597 let update = events.expect_update_fields().await;
598
599 // Model-facing output is unchanged: matches are rendered as markdown, but
600 // the clickable `file://` URIs only live in the tool-call UI content.
601 assert!(output.contains("## Matches in"));
602 assert!(
603 !output.contains("file://"),
604 "model-facing output should not embed file:// links, got:\n{output}"
605 );
606
607 // Pull the ResourceLink blocks (the clickable links) out of the content.
608 let content = update.content.expect("expected content blocks");
609 let links = content
610 .iter()
611 .filter_map(|block| match block {
612 acp::ToolCallContent::Content(inner) => match &inner.content {
613 acp::ContentBlock::ResourceLink(link) => Some(link),
614 _ => None,
615 },
616 _ => None,
617 })
618 .collect::<Vec<_>>();
619 assert_eq!(links.len(), 2, "expected one resource link per match");
620
621 let selection_uri = |abs_path: &str| {
622 MentionUri::Selection {
623 abs_path: Some(PathBuf::from(abs_path)),
624 line_range: 0..=0,
625 column: None,
626 }
627 .to_uri()
628 .to_string()
629 };
630
631 let alpha_uri = selection_uri(path!("/root/src/alpha.txt"));
632 assert!(
633 links.iter().any(|link| {
634 link.name.replace('\\', "/") == "root/src/alpha.txt#L1" && link.uri == alpha_uri
635 }),
636 "missing clickable link for alpha.txt, got: {links:?}"
637 );
638
639 let beta_uri = selection_uri(path!("/root/beta.txt"));
640 assert!(
641 links
642 .iter()
643 .any(|link| link.name.replace('\\', "/") == "root/beta.txt#L1"
644 && link.uri == beta_uri),
645 "missing clickable link for beta.txt, got: {links:?}"
646 );
647
648 // Each match also reports a location so the panel can reveal the file at
649 // the matched (0-based) row.
650 let locations = update.locations.expect("expected locations");
651 assert_eq!(locations.len(), 2);
652 assert!(
653 locations.iter().any(|location| {
654 location.path.to_string_lossy().replace('\\', "/")
655 == path!("/root/src/alpha.txt").replace('\\', "/")
656 && location.line == Some(0)
657 }),
658 "missing location for alpha.txt, got: {locations:?}"
659 );
660 assert!(
661 locations.iter().any(|location| {
662 location.path.to_string_lossy().replace('\\', "/")
663 == path!("/root/beta.txt").replace('\\', "/")
664 && location.line == Some(0)
665 }),
666 "missing location for beta.txt, got: {locations:?}"
667 );
668 }
669
670 // Snippets that themselves contain a ``` code fence (e.g. matches inside
671 // markdown) must be wrapped in a longer fence so they don't break out of the
672 // surrounding code block when rendered in the agent panel.
673 #[gpui::test]
674 async fn test_grep_snippet_fence_outlives_inner_backticks(cx: &mut TestAppContext) {
675 init_test(cx);
676 cx.executor().allow_parking();
677
678 let fs = FakeFs::new(cx.executor());
679 fs.insert_tree(
680 path!("/root"),
681 json!({
682 "doc.md": "before\n```\nNEEDLE inside fence\n```\nafter",
683 }),
684 )
685 .await;
686
687 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
688
689 let tool = Arc::new(GrepTool { project });
690 let (event_stream, mut events) = ToolCallEventStream::test();
691 let input = GrepToolInput {
692 regex: "NEEDLE".to_string(),
693 include_pattern: None,
694 offset: 0,
695 case_sensitive: false,
696 };
697 let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
698 task.await.expect("grep tool should succeed");
699 let update = events.expect_update_fields().await;
700
701 // Find the snippet text block emitted alongside the clickable link.
702 let content = update.content.expect("expected content blocks");
703 let snippet = content
704 .iter()
705 .find_map(|block| match block {
706 acp::ToolCallContent::Content(inner) => match &inner.content {
707 acp::ContentBlock::Text(text) => Some(text.text.as_str()),
708 _ => None,
709 },
710 _ => None,
711 })
712 .expect("expected a snippet text block in the tool-call content");
713
714 // The snippet embeds a three-backtick fence, so the wrapping fence must be
715 // at least four backticks long to avoid breaking out of the code block.
716 assert!(
717 snippet.contains("NEEDLE inside fence"),
718 "snippet should contain the matched line, got:\n{snippet}"
719 );
720 assert!(
721 snippet.starts_with("````\n"),
722 "snippet should be wrapped in a fence longer than the inner ```, got:\n{snippet}"
723 );
724 }
725
726 /// Helper function to set up a syntax test environment
727 async fn setup_syntax_test(cx: &mut TestAppContext) -> Entity<Project> {
728 use unindent::Unindent;
729 init_test(cx);
730 cx.executor().allow_parking();
731
732 let fs = FakeFs::new(cx.executor());
733
734 // Create test file with syntax structures
735 fs.insert_tree(
736 path!("/root"),
737 serde_json::json!({
738 "test_syntax.rs": r#"
739 fn top_level_function() {
740 println!("This is at the top level");
741 }
742
743 mod feature_module {
744 pub mod nested_module {
745 pub fn nested_function(
746 first_arg: String,
747 second_arg: i32,
748 ) {
749 println!("Function in nested module");
750 println!("{first_arg}");
751 println!("{second_arg}");
752 }
753 }
754 }
755
756 struct MyStruct {
757 field1: String,
758 field2: i32,
759 }
760
761 impl MyStruct {
762 fn method_with_block() {
763 let condition = true;
764 if condition {
765 println!("Inside if block");
766 }
767 }
768
769 fn long_function() {
770 println!("Line 1");
771 println!("Line 2");
772 println!("Line 3");
773 println!("Line 4");
774 println!("Line 5");
775 println!("Line 6");
776 println!("Line 7");
777 println!("Line 8");
778 println!("Line 9");
779 println!("Line 10");
780 println!("Line 11");
781 println!("Line 12");
782 }
783 }
784
785 trait Processor {
786 fn process(&self, input: &str) -> String;
787 }
788
789 impl Processor for MyStruct {
790 fn process(&self, input: &str) -> String {
791 format!("Processed: {}", input)
792 }
793 }
794 "#.unindent().trim(),
795 }),
796 )
797 .await;
798
799 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
800
801 project.update(cx, |project, _cx| {
802 project.languages().add(language::rust_lang())
803 });
804
805 project
806 }
807
808 #[gpui::test]
809 async fn test_grep_top_level_function(cx: &mut TestAppContext) {
810 let project = setup_syntax_test(cx).await;
811
812 // Test: Line at the top level of the file
813 let input = GrepToolInput {
814 regex: "This is at the top level".to_string(),
815 include_pattern: Some("**/*.rs".to_string()),
816 offset: 0,
817 case_sensitive: false,
818 };
819
820 let result = run_grep_tool(input, project.clone(), cx).await;
821 let expected = r#"
822 Found 1 matches:
823
824 ## Matches in root/test_syntax.rs
825
826 ### fn top_level_function › L1-3
827 ```
828 fn top_level_function() {
829 println!("This is at the top level");
830 }
831 ```
832 "#
833 .unindent();
834 assert_eq!(result, expected);
835 }
836
837 #[gpui::test]
838 async fn test_grep_function_body(cx: &mut TestAppContext) {
839 let project = setup_syntax_test(cx).await;
840
841 // Test: Line inside a function body
842 let input = GrepToolInput {
843 regex: "Function in nested module".to_string(),
844 include_pattern: Some("**/*.rs".to_string()),
845 offset: 0,
846 case_sensitive: false,
847 };
848
849 let result = run_grep_tool(input, project.clone(), cx).await;
850 let expected = r#"
851 Found 1 matches:
852
853 ## Matches in root/test_syntax.rs
854
855 ### mod feature_module › pub mod nested_module › pub fn nested_function › L10-14
856 ```
857 ) {
858 println!("Function in nested module");
859 println!("{first_arg}");
860 println!("{second_arg}");
861 }
862 ```
863 "#
864 .unindent();
865 assert_eq!(result, expected);
866 }
867
868 #[gpui::test]
869 async fn test_grep_function_args_and_body(cx: &mut TestAppContext) {
870 let project = setup_syntax_test(cx).await;
871
872 // Test: Line with a function argument
873 let input = GrepToolInput {
874 regex: "second_arg".to_string(),
875 include_pattern: Some("**/*.rs".to_string()),
876 offset: 0,
877 case_sensitive: false,
878 };
879
880 let result = run_grep_tool(input, project.clone(), cx).await;
881 let expected = r#"
882 Found 1 matches:
883
884 ## Matches in root/test_syntax.rs
885
886 ### mod feature_module › pub mod nested_module › pub fn nested_function › L7-14
887 ```
888 pub fn nested_function(
889 first_arg: String,
890 second_arg: i32,
891 ) {
892 println!("Function in nested module");
893 println!("{first_arg}");
894 println!("{second_arg}");
895 }
896 ```
897 "#
898 .unindent();
899 assert_eq!(result, expected);
900 }
901
902 #[gpui::test]
903 async fn test_grep_if_block(cx: &mut TestAppContext) {
904 use unindent::Unindent;
905 let project = setup_syntax_test(cx).await;
906
907 // Test: Line inside an if block
908 let input = GrepToolInput {
909 regex: "Inside if block".to_string(),
910 include_pattern: Some("**/*.rs".to_string()),
911 offset: 0,
912 case_sensitive: false,
913 };
914
915 let result = run_grep_tool(input, project.clone(), cx).await;
916 let expected = r#"
917 Found 1 matches:
918
919 ## Matches in root/test_syntax.rs
920
921 ### impl MyStruct › fn method_with_block › L26-28
922 ```
923 if condition {
924 println!("Inside if block");
925 }
926 ```
927 "#
928 .unindent();
929 assert_eq!(result, expected);
930 }
931
932 #[gpui::test]
933 async fn test_grep_long_function_top(cx: &mut TestAppContext) {
934 use unindent::Unindent;
935 let project = setup_syntax_test(cx).await;
936
937 // Test: Line in the middle of a long function - should show message about remaining lines
938 let input = GrepToolInput {
939 regex: "Line 5".to_string(),
940 include_pattern: Some("**/*.rs".to_string()),
941 offset: 0,
942 case_sensitive: false,
943 };
944
945 let result = run_grep_tool(input, project.clone(), cx).await;
946 let expected = r#"
947 Found 1 matches:
948
949 ## Matches in root/test_syntax.rs
950
951 ### impl MyStruct › fn long_function › L31-41
952 ```
953 fn long_function() {
954 println!("Line 1");
955 println!("Line 2");
956 println!("Line 3");
957 println!("Line 4");
958 println!("Line 5");
959 println!("Line 6");
960 println!("Line 7");
961 println!("Line 8");
962 println!("Line 9");
963 println!("Line 10");
964 ```
965
966 3 lines remaining in ancestor node. Read the file to see all.
967 "#
968 .unindent();
969 assert_eq!(result, expected);
970 }
971
972 #[gpui::test]
973 async fn test_grep_long_function_bottom(cx: &mut TestAppContext) {
974 use unindent::Unindent;
975 let project = setup_syntax_test(cx).await;
976
977 // Test: Line in the long function
978 let input = GrepToolInput {
979 regex: "Line 12".to_string(),
980 include_pattern: Some("**/*.rs".to_string()),
981 offset: 0,
982 case_sensitive: false,
983 };
984
985 let result = run_grep_tool(input, project.clone(), cx).await;
986 let expected = r#"
987 Found 1 matches:
988
989 ## Matches in root/test_syntax.rs
990
991 ### impl MyStruct › fn long_function › L41-45
992 ```
993 println!("Line 10");
994 println!("Line 11");
995 println!("Line 12");
996 }
997 }
998 ```
999 "#
1000 .unindent();
1001 assert_eq!(result, expected);
1002 }
1003
1004 async fn run_grep_tool(
1005 input: GrepToolInput,
1006 project: Entity<Project>,
1007 cx: &mut TestAppContext,
1008 ) -> String {
1009 let tool = Arc::new(GrepTool { project });
1010 let task = cx.update(|cx| {
1011 tool.run(
1012 ToolInput::resolved(input),
1013 ToolCallEventStream::test().0,
1014 cx,
1015 )
1016 });
1017
1018 match task.await {
1019 Ok(result) => {
1020 if cfg!(windows) {
1021 result.replace("root\\", "root/")
1022 } else {
1023 result
1024 }
1025 }
1026 Err(e) => panic!("Failed to run grep tool: {}", e),
1027 }
1028 }
1029
1030 fn init_test(cx: &mut TestAppContext) {
1031 cx.update(|cx| {
1032 let settings_store = SettingsStore::test(cx);
1033 cx.set_global(settings_store);
1034 });
1035 }
1036
1037 #[gpui::test]
1038 async fn test_grep_security_boundaries(cx: &mut TestAppContext) {
1039 init_test(cx);
1040
1041 let fs = FakeFs::new(cx.executor());
1042
1043 fs.insert_tree(
1044 path!("/"),
1045 json!({
1046 "project_root": {
1047 "allowed_file.rs": "fn main() { println!(\"This file is in the project\"); }",
1048 ".mysecrets": "SECRET_KEY=abc123\nfn secret() { /* private */ }",
1049 ".secretdir": {
1050 "config": "fn special_configuration() { /* excluded */ }"
1051 },
1052 ".mymetadata": "fn custom_metadata() { /* excluded */ }",
1053 "subdir": {
1054 "normal_file.rs": "fn normal_file_content() { /* Normal */ }",
1055 "special.privatekey": "fn private_key_content() { /* private */ }",
1056 "data.mysensitive": "fn sensitive_data() { /* private */ }"
1057 }
1058 },
1059 "outside_project": {
1060 "sensitive_file.rs": "fn outside_function() { /* This file is outside the project */ }"
1061 }
1062 }),
1063 )
1064 .await;
1065
1066 cx.update(|cx| {
1067 use gpui::UpdateGlobal;
1068 use settings::SettingsStore;
1069 SettingsStore::update_global(cx, |store, cx| {
1070 store.update_user_settings(cx, |settings| {
1071 settings.project.worktree.file_scan_exclusions = Some(vec![
1072 "**/.secretdir".to_string(),
1073 "**/.mymetadata".to_string(),
1074 ]);
1075 settings.project.worktree.private_files = Some(
1076 vec![
1077 "**/.mysecrets".to_string(),
1078 "**/*.privatekey".to_string(),
1079 "**/*.mysensitive".to_string(),
1080 ]
1081 .into(),
1082 );
1083 });
1084 });
1085 });
1086
1087 let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
1088
1089 // Searching for files outside the project worktree should return no results
1090 let result = run_grep_tool(
1091 GrepToolInput {
1092 regex: "outside_function".to_string(),
1093 include_pattern: None,
1094 offset: 0,
1095 case_sensitive: false,
1096 },
1097 project.clone(),
1098 cx,
1099 )
1100 .await;
1101 let paths = extract_paths_from_results(&result);
1102 assert!(
1103 paths.is_empty(),
1104 "grep_tool should not find files outside the project worktree"
1105 );
1106
1107 // Searching within the project should succeed
1108 let result = run_grep_tool(
1109 GrepToolInput {
1110 regex: "main".to_string(),
1111 include_pattern: None,
1112 offset: 0,
1113 case_sensitive: false,
1114 },
1115 project.clone(),
1116 cx,
1117 )
1118 .await;
1119 let paths = extract_paths_from_results(&result);
1120 assert!(
1121 paths.iter().any(|p| p.contains("allowed_file.rs")),
1122 "grep_tool should be able to search files inside worktrees"
1123 );
1124
1125 // Searching files that match file_scan_exclusions should return no results
1126 let result = run_grep_tool(
1127 GrepToolInput {
1128 regex: "special_configuration".to_string(),
1129 include_pattern: None,
1130 offset: 0,
1131 case_sensitive: false,
1132 },
1133 project.clone(),
1134 cx,
1135 )
1136 .await;
1137 let paths = extract_paths_from_results(&result);
1138 assert!(
1139 paths.is_empty(),
1140 "grep_tool should not search files in .secretdir (file_scan_exclusions)"
1141 );
1142
1143 let result = run_grep_tool(
1144 GrepToolInput {
1145 regex: "custom_metadata".to_string(),
1146 include_pattern: None,
1147 offset: 0,
1148 case_sensitive: false,
1149 },
1150 project.clone(),
1151 cx,
1152 )
1153 .await;
1154 let paths = extract_paths_from_results(&result);
1155 assert!(
1156 paths.is_empty(),
1157 "grep_tool should not search .mymetadata files (file_scan_exclusions)"
1158 );
1159
1160 // Searching private files should return no results
1161 let result = run_grep_tool(
1162 GrepToolInput {
1163 regex: "SECRET_KEY".to_string(),
1164 include_pattern: None,
1165 offset: 0,
1166 case_sensitive: false,
1167 },
1168 project.clone(),
1169 cx,
1170 )
1171 .await;
1172 let paths = extract_paths_from_results(&result);
1173 assert!(
1174 paths.is_empty(),
1175 "grep_tool should not search .mysecrets (private_files)"
1176 );
1177
1178 let result = run_grep_tool(
1179 GrepToolInput {
1180 regex: "private_key_content".to_string(),
1181 include_pattern: None,
1182 offset: 0,
1183 case_sensitive: false,
1184 },
1185 project.clone(),
1186 cx,
1187 )
1188 .await;
1189 let paths = extract_paths_from_results(&result);
1190
1191 assert!(
1192 paths.is_empty(),
1193 "grep_tool should not search .privatekey files (private_files)"
1194 );
1195
1196 let result = run_grep_tool(
1197 GrepToolInput {
1198 regex: "sensitive_data".to_string(),
1199 include_pattern: None,
1200 offset: 0,
1201 case_sensitive: false,
1202 },
1203 project.clone(),
1204 cx,
1205 )
1206 .await;
1207 let paths = extract_paths_from_results(&result);
1208 assert!(
1209 paths.is_empty(),
1210 "grep_tool should not search .mysensitive files (private_files)"
1211 );
1212
1213 // Searching a normal file should still work, even with private_files configured
1214 let result = run_grep_tool(
1215 GrepToolInput {
1216 regex: "normal_file_content".to_string(),
1217 include_pattern: None,
1218 offset: 0,
1219 case_sensitive: false,
1220 },
1221 project.clone(),
1222 cx,
1223 )
1224 .await;
1225 let paths = extract_paths_from_results(&result);
1226 assert!(
1227 paths.iter().any(|p| p.contains("normal_file.rs")),
1228 "Should be able to search normal files"
1229 );
1230
1231 // Path traversal attempts with .. in include_pattern should not escape project
1232 let result = run_grep_tool(
1233 GrepToolInput {
1234 regex: "outside_function".to_string(),
1235 include_pattern: Some("../outside_project/**/*.rs".to_string()),
1236 offset: 0,
1237 case_sensitive: false,
1238 },
1239 project.clone(),
1240 cx,
1241 )
1242 .await;
1243 let paths = extract_paths_from_results(&result);
1244 assert!(
1245 paths.is_empty(),
1246 "grep_tool should not allow escaping project boundaries with relative paths"
1247 );
1248 }
1249
1250 #[gpui::test]
1251 async fn test_grep_with_multiple_worktree_settings(cx: &mut TestAppContext) {
1252 init_test(cx);
1253
1254 let fs = FakeFs::new(cx.executor());
1255
1256 // Create first worktree with its own private files
1257 fs.insert_tree(
1258 path!("/worktree1"),
1259 json!({
1260 ".zed": {
1261 "settings.json": r#"{
1262 "file_scan_exclusions": ["**/fixture.*"],
1263 "private_files": ["**/secret.rs"]
1264 }"#
1265 },
1266 "src": {
1267 "main.rs": "fn main() { let secret_key = \"hidden\"; }",
1268 "secret.rs": "const API_KEY: &str = \"secret_value\";",
1269 "utils.rs": "pub fn get_config() -> String { \"config\".to_string() }"
1270 },
1271 "tests": {
1272 "test.rs": "fn test_secret() { assert!(true); }",
1273 "fixture.sql": "SELECT * FROM secret_table;"
1274 }
1275 }),
1276 )
1277 .await;
1278
1279 // Create second worktree with different private files
1280 fs.insert_tree(
1281 path!("/worktree2"),
1282 json!({
1283 ".zed": {
1284 "settings.json": r#"{
1285 "file_scan_exclusions": ["**/internal.*"],
1286 "private_files": ["**/private.js", "**/data.json"]
1287 }"#
1288 },
1289 "lib": {
1290 "public.js": "export function getSecret() { return 'public'; }",
1291 "private.js": "const SECRET_KEY = \"private_value\";",
1292 "data.json": "{\"secret_data\": \"hidden\"}"
1293 },
1294 "docs": {
1295 "README.md": "# Documentation with secret info",
1296 "internal.md": "Internal secret documentation"
1297 }
1298 }),
1299 )
1300 .await;
1301
1302 // Set global settings
1303 cx.update(|cx| {
1304 SettingsStore::update_global(cx, |store, cx| {
1305 store.update_user_settings(cx, |settings| {
1306 settings.project.worktree.file_scan_exclusions =
1307 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
1308 settings.project.worktree.private_files =
1309 Some(vec!["**/.env".to_string()].into());
1310 });
1311 });
1312 });
1313
1314 let project = Project::test(
1315 fs.clone(),
1316 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
1317 cx,
1318 )
1319 .await;
1320
1321 // Wait for worktrees to be fully scanned
1322 cx.executor().run_until_parked();
1323
1324 // Search for "secret" - should exclude files based on worktree-specific settings
1325 let result = run_grep_tool(
1326 GrepToolInput {
1327 regex: "secret".to_string(),
1328 include_pattern: None,
1329 offset: 0,
1330 case_sensitive: false,
1331 },
1332 project.clone(),
1333 cx,
1334 )
1335 .await;
1336 let paths = extract_paths_from_results(&result);
1337
1338 // Should find matches in non-private files
1339 assert!(
1340 paths.iter().any(|p| p.contains("main.rs")),
1341 "Should find 'secret' in worktree1/src/main.rs"
1342 );
1343 assert!(
1344 paths.iter().any(|p| p.contains("test.rs")),
1345 "Should find 'secret' in worktree1/tests/test.rs"
1346 );
1347 assert!(
1348 paths.iter().any(|p| p.contains("public.js")),
1349 "Should find 'secret' in worktree2/lib/public.js"
1350 );
1351 assert!(
1352 paths.iter().any(|p| p.contains("README.md")),
1353 "Should find 'secret' in worktree2/docs/README.md"
1354 );
1355
1356 // Should NOT find matches in private/excluded files based on worktree settings
1357 assert!(
1358 !paths.iter().any(|p| p.contains("secret.rs")),
1359 "Should not search in worktree1/src/secret.rs (local private_files)"
1360 );
1361 assert!(
1362 !paths.iter().any(|p| p.contains("fixture.sql")),
1363 "Should not search in worktree1/tests/fixture.sql (local file_scan_exclusions)"
1364 );
1365 assert!(
1366 !paths.iter().any(|p| p.contains("private.js")),
1367 "Should not search in worktree2/lib/private.js (local private_files)"
1368 );
1369 assert!(
1370 !paths.iter().any(|p| p.contains("data.json")),
1371 "Should not search in worktree2/lib/data.json (local private_files)"
1372 );
1373 assert!(
1374 !paths.iter().any(|p| p.contains("internal.md")),
1375 "Should not search in worktree2/docs/internal.md (local file_scan_exclusions)"
1376 );
1377
1378 // Test with `include_pattern` specific to one worktree
1379 let result = run_grep_tool(
1380 GrepToolInput {
1381 regex: "secret".to_string(),
1382 include_pattern: Some("worktree1/**/*.rs".to_string()),
1383 offset: 0,
1384 case_sensitive: false,
1385 },
1386 project.clone(),
1387 cx,
1388 )
1389 .await;
1390
1391 let paths = extract_paths_from_results(&result);
1392
1393 // Should only find matches in worktree1 *.rs files (excluding private ones)
1394 assert!(
1395 paths.iter().any(|p| p.contains("main.rs")),
1396 "Should find match in worktree1/src/main.rs"
1397 );
1398 assert!(
1399 paths.iter().any(|p| p.contains("test.rs")),
1400 "Should find match in worktree1/tests/test.rs"
1401 );
1402 assert!(
1403 !paths.iter().any(|p| p.contains("secret.rs")),
1404 "Should not find match in excluded worktree1/src/secret.rs"
1405 );
1406 assert!(
1407 paths.iter().all(|p| !p.contains("worktree2")),
1408 "Should not find any matches in worktree2"
1409 );
1410 }
1411
1412 // Helper function to extract file paths from grep results
1413 fn extract_paths_from_results(results: &str) -> Vec<String> {
1414 results
1415 .lines()
1416 .filter(|line| line.starts_with("## Matches in "))
1417 .map(|line| {
1418 line.strip_prefix("## Matches in ")
1419 .unwrap()
1420 .trim()
1421 .to_string()
1422 })
1423 .collect()
1424 }
1425}
1426