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