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