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