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 if let Some(parent_symbols) = &parent_symbols {
271 for symbol in parent_symbols {
272 write!(output, "{} › ", symbol.text)?;
273 }
274 }
275
276 if range.start.row == end_row {
277 writeln!(output, "L{}", range.start.row + 1)?;
278 } else {
279 writeln!(output, "L{}-{}", range.start.row + 1, end_row + 1)?;
280 }
281
282 output.push_str("```\n");
283 output.extend(snapshot.text_for_range(range));
284 output.push_str("\n```\n");
285
286 if let Some(ancestor_range) = ancestor_range
287 && end_row < ancestor_range.end.row {
288 let remaining_lines = ancestor_range.end.row - end_row;
289 writeln!(output, "\n{} lines remaining in ancestor node. Read the file to see all.", remaining_lines)?;
290 }
291
292 matches_found += 1;
293 }
294 }
295
296 if matches_found == 0 {
297 Ok("No matches found".to_string().into())
298 } else if has_more_matches {
299 Ok(format!(
300 "Showing matches {}-{} (there were more matches found; use offset: {} to see next page):\n{output}",
301 input.offset + 1,
302 input.offset + matches_found,
303 input.offset + RESULTS_PER_PAGE,
304 ).into())
305 } else {
306 Ok(format!("Found {matches_found} matches:\n{output}").into())
307 }
308 }).into()
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use assistant_tool::Tool;
316 use gpui::{AppContext, TestAppContext, UpdateGlobal};
317 use language::{Language, LanguageConfig, LanguageMatcher};
318 use language_model::fake_provider::FakeLanguageModel;
319 use project::{FakeFs, Project, WorktreeSettings};
320 use serde_json::json;
321 use settings::SettingsStore;
322 use unindent::Unindent;
323 use util::path;
324
325 #[gpui::test]
326 async fn test_grep_tool_with_include_pattern(cx: &mut TestAppContext) {
327 init_test(cx);
328 cx.executor().allow_parking();
329
330 let fs = FakeFs::new(cx.executor());
331 fs.insert_tree(
332 path!("/root"),
333 serde_json::json!({
334 "src": {
335 "main.rs": "fn main() {\n println!(\"Hello, world!\");\n}",
336 "utils": {
337 "helper.rs": "fn helper() {\n println!(\"I'm a helper!\");\n}",
338 },
339 },
340 "tests": {
341 "test_main.rs": "fn test_main() {\n assert!(true);\n}",
342 }
343 }),
344 )
345 .await;
346
347 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
348
349 // Test with include pattern for Rust files inside the root of the project
350 let input = serde_json::to_value(GrepToolInput {
351 regex: "println".to_string(),
352 include_pattern: Some("root/**/*.rs".to_string()),
353 offset: 0,
354 case_sensitive: false,
355 })
356 .unwrap();
357
358 let result = run_grep_tool(input, project.clone(), cx).await;
359 assert!(result.contains("main.rs"), "Should find matches in main.rs");
360 assert!(
361 result.contains("helper.rs"),
362 "Should find matches in helper.rs"
363 );
364 assert!(
365 !result.contains("test_main.rs"),
366 "Should not include test_main.rs even though it's a .rs file (because it doesn't have the pattern)"
367 );
368
369 // Test with include pattern for src directory only
370 let input = serde_json::to_value(GrepToolInput {
371 regex: "fn".to_string(),
372 include_pattern: Some("root/**/src/**".to_string()),
373 offset: 0,
374 case_sensitive: false,
375 })
376 .unwrap();
377
378 let result = run_grep_tool(input, project.clone(), cx).await;
379 assert!(
380 result.contains("main.rs"),
381 "Should find matches in src/main.rs"
382 );
383 assert!(
384 result.contains("helper.rs"),
385 "Should find matches in src/utils/helper.rs"
386 );
387 assert!(
388 !result.contains("test_main.rs"),
389 "Should not include test_main.rs as it's not in src directory"
390 );
391
392 // Test with empty include pattern (should default to all files)
393 let input = serde_json::to_value(GrepToolInput {
394 regex: "fn".to_string(),
395 include_pattern: None,
396 offset: 0,
397 case_sensitive: false,
398 })
399 .unwrap();
400
401 let result = run_grep_tool(input, project.clone(), cx).await;
402 assert!(result.contains("main.rs"), "Should find matches in main.rs");
403 assert!(
404 result.contains("helper.rs"),
405 "Should find matches in helper.rs"
406 );
407 assert!(
408 result.contains("test_main.rs"),
409 "Should include test_main.rs"
410 );
411 }
412
413 #[gpui::test]
414 async fn test_grep_tool_with_case_sensitivity(cx: &mut TestAppContext) {
415 init_test(cx);
416 cx.executor().allow_parking();
417
418 let fs = FakeFs::new(cx.executor());
419 fs.insert_tree(
420 path!("/root"),
421 serde_json::json!({
422 "case_test.txt": "This file has UPPERCASE and lowercase text.\nUPPERCASE patterns should match only with case_sensitive: true",
423 }),
424 )
425 .await;
426
427 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
428
429 // Test case-insensitive search (default)
430 let input = serde_json::to_value(GrepToolInput {
431 regex: "uppercase".to_string(),
432 include_pattern: Some("**/*.txt".to_string()),
433 offset: 0,
434 case_sensitive: false,
435 })
436 .unwrap();
437
438 let result = run_grep_tool(input, project.clone(), cx).await;
439 assert!(
440 result.contains("UPPERCASE"),
441 "Case-insensitive search should match uppercase"
442 );
443
444 // Test case-sensitive search
445 let input = serde_json::to_value(GrepToolInput {
446 regex: "uppercase".to_string(),
447 include_pattern: Some("**/*.txt".to_string()),
448 offset: 0,
449 case_sensitive: true,
450 })
451 .unwrap();
452
453 let result = run_grep_tool(input, project.clone(), cx).await;
454 assert!(
455 !result.contains("UPPERCASE"),
456 "Case-sensitive search should not match uppercase"
457 );
458
459 // Test case-sensitive search
460 let input = serde_json::to_value(GrepToolInput {
461 regex: "LOWERCASE".to_string(),
462 include_pattern: Some("**/*.txt".to_string()),
463 offset: 0,
464 case_sensitive: true,
465 })
466 .unwrap();
467
468 let result = run_grep_tool(input, project.clone(), cx).await;
469
470 assert!(
471 !result.contains("lowercase"),
472 "Case-sensitive search should match lowercase"
473 );
474
475 // Test case-sensitive search for lowercase pattern
476 let input = serde_json::to_value(GrepToolInput {
477 regex: "lowercase".to_string(),
478 include_pattern: Some("**/*.txt".to_string()),
479 offset: 0,
480 case_sensitive: true,
481 })
482 .unwrap();
483
484 let result = run_grep_tool(input, project.clone(), cx).await;
485 assert!(
486 result.contains("lowercase"),
487 "Case-sensitive search should match lowercase text"
488 );
489 }
490
491 /// Helper function to set up a syntax test environment
492 async fn setup_syntax_test(cx: &mut TestAppContext) -> Entity<Project> {
493 use unindent::Unindent;
494 init_test(cx);
495 cx.executor().allow_parking();
496
497 let fs = FakeFs::new(cx.executor());
498
499 // Create test file with syntax structures
500 fs.insert_tree(
501 path!("/root"),
502 serde_json::json!({
503 "test_syntax.rs": r#"
504 fn top_level_function() {
505 println!("This is at the top level");
506 }
507
508 mod feature_module {
509 pub mod nested_module {
510 pub fn nested_function(
511 first_arg: String,
512 second_arg: i32,
513 ) {
514 println!("Function in nested module");
515 println!("{first_arg}");
516 println!("{second_arg}");
517 }
518 }
519 }
520
521 struct MyStruct {
522 field1: String,
523 field2: i32,
524 }
525
526 impl MyStruct {
527 fn method_with_block() {
528 let condition = true;
529 if condition {
530 println!("Inside if block");
531 }
532 }
533
534 fn long_function() {
535 println!("Line 1");
536 println!("Line 2");
537 println!("Line 3");
538 println!("Line 4");
539 println!("Line 5");
540 println!("Line 6");
541 println!("Line 7");
542 println!("Line 8");
543 println!("Line 9");
544 println!("Line 10");
545 println!("Line 11");
546 println!("Line 12");
547 }
548 }
549
550 trait Processor {
551 fn process(&self, input: &str) -> String;
552 }
553
554 impl Processor for MyStruct {
555 fn process(&self, input: &str) -> String {
556 format!("Processed: {}", input)
557 }
558 }
559 "#.unindent().trim(),
560 }),
561 )
562 .await;
563
564 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
565
566 project.update(cx, |project, _cx| {
567 project.languages().add(rust_lang().into())
568 });
569
570 project
571 }
572
573 #[gpui::test]
574 async fn test_grep_top_level_function(cx: &mut TestAppContext) {
575 let project = setup_syntax_test(cx).await;
576
577 // Test: Line at the top level of the file
578 let input = serde_json::to_value(GrepToolInput {
579 regex: "This is at the top level".to_string(),
580 include_pattern: Some("**/*.rs".to_string()),
581 offset: 0,
582 case_sensitive: false,
583 })
584 .unwrap();
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 = serde_json::to_value(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 .unwrap();
615
616 let result = run_grep_tool(input, project.clone(), cx).await;
617 let expected = r#"
618 Found 1 matches:
619
620 ## Matches in root/test_syntax.rs
621
622 ### mod feature_module › pub mod nested_module › pub fn nested_function › L10-14
623 ```
624 ) {
625 println!("Function in nested module");
626 println!("{first_arg}");
627 println!("{second_arg}");
628 }
629 ```
630 "#
631 .unindent();
632 assert_eq!(result, expected);
633 }
634
635 #[gpui::test]
636 async fn test_grep_function_args_and_body(cx: &mut TestAppContext) {
637 let project = setup_syntax_test(cx).await;
638
639 // Test: Line with a function argument
640 let input = serde_json::to_value(GrepToolInput {
641 regex: "second_arg".to_string(),
642 include_pattern: Some("**/*.rs".to_string()),
643 offset: 0,
644 case_sensitive: false,
645 })
646 .unwrap();
647
648 let result = run_grep_tool(input, project.clone(), cx).await;
649 let expected = r#"
650 Found 1 matches:
651
652 ## Matches in root/test_syntax.rs
653
654 ### mod feature_module › pub mod nested_module › pub fn nested_function › L7-14
655 ```
656 pub fn nested_function(
657 first_arg: String,
658 second_arg: i32,
659 ) {
660 println!("Function in nested module");
661 println!("{first_arg}");
662 println!("{second_arg}");
663 }
664 ```
665 "#
666 .unindent();
667 assert_eq!(result, expected);
668 }
669
670 #[gpui::test]
671 async fn test_grep_if_block(cx: &mut TestAppContext) {
672 use unindent::Unindent;
673 let project = setup_syntax_test(cx).await;
674
675 // Test: Line inside an if block
676 let input = serde_json::to_value(GrepToolInput {
677 regex: "Inside if block".to_string(),
678 include_pattern: Some("**/*.rs".to_string()),
679 offset: 0,
680 case_sensitive: false,
681 })
682 .unwrap();
683
684 let result = run_grep_tool(input, project.clone(), cx).await;
685 let expected = r#"
686 Found 1 matches:
687
688 ## Matches in root/test_syntax.rs
689
690 ### impl MyStruct › fn method_with_block › L26-28
691 ```
692 if condition {
693 println!("Inside if block");
694 }
695 ```
696 "#
697 .unindent();
698 assert_eq!(result, expected);
699 }
700
701 #[gpui::test]
702 async fn test_grep_long_function_top(cx: &mut TestAppContext) {
703 use unindent::Unindent;
704 let project = setup_syntax_test(cx).await;
705
706 // Test: Line in the middle of a long function - should show message about remaining lines
707 let input = serde_json::to_value(GrepToolInput {
708 regex: "Line 5".to_string(),
709 include_pattern: Some("**/*.rs".to_string()),
710 offset: 0,
711 case_sensitive: false,
712 })
713 .unwrap();
714
715 let result = run_grep_tool(input, project.clone(), cx).await;
716 let expected = r#"
717 Found 1 matches:
718
719 ## Matches in root/test_syntax.rs
720
721 ### impl MyStruct › fn long_function › L31-41
722 ```
723 fn long_function() {
724 println!("Line 1");
725 println!("Line 2");
726 println!("Line 3");
727 println!("Line 4");
728 println!("Line 5");
729 println!("Line 6");
730 println!("Line 7");
731 println!("Line 8");
732 println!("Line 9");
733 println!("Line 10");
734 ```
735
736 3 lines remaining in ancestor node. Read the file to see all.
737 "#
738 .unindent();
739 assert_eq!(result, expected);
740 }
741
742 #[gpui::test]
743 async fn test_grep_long_function_bottom(cx: &mut TestAppContext) {
744 use unindent::Unindent;
745 let project = setup_syntax_test(cx).await;
746
747 // Test: Line in the long function
748 let input = serde_json::to_value(GrepToolInput {
749 regex: "Line 12".to_string(),
750 include_pattern: Some("**/*.rs".to_string()),
751 offset: 0,
752 case_sensitive: false,
753 })
754 .unwrap();
755
756 let result = run_grep_tool(input, project.clone(), cx).await;
757 let expected = r#"
758 Found 1 matches:
759
760 ## Matches in root/test_syntax.rs
761
762 ### impl MyStruct › fn long_function › L41-45
763 ```
764 println!("Line 10");
765 println!("Line 11");
766 println!("Line 12");
767 }
768 }
769 ```
770 "#
771 .unindent();
772 assert_eq!(result, expected);
773 }
774
775 async fn run_grep_tool(
776 input: serde_json::Value,
777 project: Entity<Project>,
778 cx: &mut TestAppContext,
779 ) -> String {
780 let tool = Arc::new(GrepTool);
781 let action_log = cx.new(|_cx| ActionLog::new(project.clone()));
782 let model = Arc::new(FakeLanguageModel::default());
783 let task =
784 cx.update(|cx| tool.run(input, Arc::default(), project, action_log, model, None, cx));
785
786 match task.output.await {
787 Ok(result) => {
788 if cfg!(windows) {
789 result.content.as_str().unwrap().replace("root\\", "root/")
790 } else {
791 result.content.as_str().unwrap().to_string()
792 }
793 }
794 Err(e) => panic!("Failed to run grep tool: {}", e),
795 }
796 }
797
798 fn init_test(cx: &mut TestAppContext) {
799 cx.update(|cx| {
800 let settings_store = SettingsStore::test(cx);
801 cx.set_global(settings_store);
802 language::init(cx);
803 Project::init_settings(cx);
804 });
805 }
806
807 fn rust_lang() -> Language {
808 Language::new(
809 LanguageConfig {
810 name: "Rust".into(),
811 matcher: LanguageMatcher {
812 path_suffixes: vec!["rs".to_string()],
813 ..Default::default()
814 },
815 ..Default::default()
816 },
817 Some(tree_sitter_rust::LANGUAGE.into()),
818 )
819 .with_outline_query(include_str!("../../languages/src/rust/outline.scm"))
820 .unwrap()
821 }
822
823 #[gpui::test]
824 async fn test_grep_security_boundaries(cx: &mut TestAppContext) {
825 init_test(cx);
826
827 let fs = FakeFs::new(cx.executor());
828
829 fs.insert_tree(
830 path!("/"),
831 json!({
832 "project_root": {
833 "allowed_file.rs": "fn main() { println!(\"This file is in the project\"); }",
834 ".mysecrets": "SECRET_KEY=abc123\nfn secret() { /* private */ }",
835 ".secretdir": {
836 "config": "fn special_configuration() { /* excluded */ }"
837 },
838 ".mymetadata": "fn custom_metadata() { /* excluded */ }",
839 "subdir": {
840 "normal_file.rs": "fn normal_file_content() { /* Normal */ }",
841 "special.privatekey": "fn private_key_content() { /* private */ }",
842 "data.mysensitive": "fn sensitive_data() { /* private */ }"
843 }
844 },
845 "outside_project": {
846 "sensitive_file.rs": "fn outside_function() { /* This file is outside the project */ }"
847 }
848 }),
849 )
850 .await;
851
852 cx.update(|cx| {
853 use gpui::UpdateGlobal;
854 use project::WorktreeSettings;
855 use settings::SettingsStore;
856 SettingsStore::update_global(cx, |store, cx| {
857 store.update_user_settings::<WorktreeSettings>(cx, |settings| {
858 settings.file_scan_exclusions = Some(vec![
859 "**/.secretdir".to_string(),
860 "**/.mymetadata".to_string(),
861 ]);
862 settings.private_files = Some(vec![
863 "**/.mysecrets".to_string(),
864 "**/*.privatekey".to_string(),
865 "**/*.mysensitive".to_string(),
866 ]);
867 });
868 });
869 });
870
871 let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
872 let action_log = cx.new(|_| ActionLog::new(project.clone()));
873 let model = Arc::new(FakeLanguageModel::default());
874
875 // Searching for files outside the project worktree should return no results
876 let result = cx
877 .update(|cx| {
878 let input = json!({
879 "regex": "outside_function"
880 });
881 Arc::new(GrepTool)
882 .run(
883 input,
884 Arc::default(),
885 project.clone(),
886 action_log.clone(),
887 model.clone(),
888 None,
889 cx,
890 )
891 .output
892 })
893 .await;
894 let results = result.unwrap();
895 let paths = extract_paths_from_results(results.content.as_str().unwrap());
896 assert!(
897 paths.is_empty(),
898 "grep_tool should not find files outside the project worktree"
899 );
900
901 // Searching within the project should succeed
902 let result = cx
903 .update(|cx| {
904 let input = json!({
905 "regex": "main"
906 });
907 Arc::new(GrepTool)
908 .run(
909 input,
910 Arc::default(),
911 project.clone(),
912 action_log.clone(),
913 model.clone(),
914 None,
915 cx,
916 )
917 .output
918 })
919 .await;
920 let results = result.unwrap();
921 let paths = extract_paths_from_results(results.content.as_str().unwrap());
922 assert!(
923 paths.iter().any(|p| p.contains("allowed_file.rs")),
924 "grep_tool should be able to search files inside worktrees"
925 );
926
927 // Searching files that match file_scan_exclusions should return no results
928 let result = cx
929 .update(|cx| {
930 let input = json!({
931 "regex": "special_configuration"
932 });
933 Arc::new(GrepTool)
934 .run(
935 input,
936 Arc::default(),
937 project.clone(),
938 action_log.clone(),
939 model.clone(),
940 None,
941 cx,
942 )
943 .output
944 })
945 .await;
946 let results = result.unwrap();
947 let paths = extract_paths_from_results(results.content.as_str().unwrap());
948 assert!(
949 paths.is_empty(),
950 "grep_tool should not search files in .secretdir (file_scan_exclusions)"
951 );
952
953 let result = cx
954 .update(|cx| {
955 let input = json!({
956 "regex": "custom_metadata"
957 });
958 Arc::new(GrepTool)
959 .run(
960 input,
961 Arc::default(),
962 project.clone(),
963 action_log.clone(),
964 model.clone(),
965 None,
966 cx,
967 )
968 .output
969 })
970 .await;
971 let results = result.unwrap();
972 let paths = extract_paths_from_results(results.content.as_str().unwrap());
973 assert!(
974 paths.is_empty(),
975 "grep_tool should not search .mymetadata files (file_scan_exclusions)"
976 );
977
978 // Searching private files should return no results
979 let result = cx
980 .update(|cx| {
981 let input = json!({
982 "regex": "SECRET_KEY"
983 });
984 Arc::new(GrepTool)
985 .run(
986 input,
987 Arc::default(),
988 project.clone(),
989 action_log.clone(),
990 model.clone(),
991 None,
992 cx,
993 )
994 .output
995 })
996 .await;
997 let results = result.unwrap();
998 let paths = extract_paths_from_results(results.content.as_str().unwrap());
999 assert!(
1000 paths.is_empty(),
1001 "grep_tool should not search .mysecrets (private_files)"
1002 );
1003
1004 let result = cx
1005 .update(|cx| {
1006 let input = json!({
1007 "regex": "private_key_content"
1008 });
1009 Arc::new(GrepTool)
1010 .run(
1011 input,
1012 Arc::default(),
1013 project.clone(),
1014 action_log.clone(),
1015 model.clone(),
1016 None,
1017 cx,
1018 )
1019 .output
1020 })
1021 .await;
1022 let results = result.unwrap();
1023 let paths = extract_paths_from_results(results.content.as_str().unwrap());
1024 assert!(
1025 paths.is_empty(),
1026 "grep_tool should not search .privatekey files (private_files)"
1027 );
1028
1029 let result = cx
1030 .update(|cx| {
1031 let input = json!({
1032 "regex": "sensitive_data"
1033 });
1034 Arc::new(GrepTool)
1035 .run(
1036 input,
1037 Arc::default(),
1038 project.clone(),
1039 action_log.clone(),
1040 model.clone(),
1041 None,
1042 cx,
1043 )
1044 .output
1045 })
1046 .await;
1047 let results = result.unwrap();
1048 let paths = extract_paths_from_results(results.content.as_str().unwrap());
1049 assert!(
1050 paths.is_empty(),
1051 "grep_tool should not search .mysensitive files (private_files)"
1052 );
1053
1054 // Searching a normal file should still work, even with private_files configured
1055 let result = cx
1056 .update(|cx| {
1057 let input = json!({
1058 "regex": "normal_file_content"
1059 });
1060 Arc::new(GrepTool)
1061 .run(
1062 input,
1063 Arc::default(),
1064 project.clone(),
1065 action_log.clone(),
1066 model.clone(),
1067 None,
1068 cx,
1069 )
1070 .output
1071 })
1072 .await;
1073 let results = result.unwrap();
1074 let paths = extract_paths_from_results(results.content.as_str().unwrap());
1075 assert!(
1076 paths.iter().any(|p| p.contains("normal_file.rs")),
1077 "Should be able to search normal files"
1078 );
1079
1080 // Path traversal attempts with .. in include_pattern should not escape project
1081 let result = cx
1082 .update(|cx| {
1083 let input = json!({
1084 "regex": "outside_function",
1085 "include_pattern": "../outside_project/**/*.rs"
1086 });
1087 Arc::new(GrepTool)
1088 .run(
1089 input,
1090 Arc::default(),
1091 project.clone(),
1092 action_log.clone(),
1093 model.clone(),
1094 None,
1095 cx,
1096 )
1097 .output
1098 })
1099 .await;
1100 let results = result.unwrap();
1101 let paths = extract_paths_from_results(results.content.as_str().unwrap());
1102 assert!(
1103 paths.is_empty(),
1104 "grep_tool should not allow escaping project boundaries with relative paths"
1105 );
1106 }
1107
1108 #[gpui::test]
1109 async fn test_grep_with_multiple_worktree_settings(cx: &mut TestAppContext) {
1110 init_test(cx);
1111
1112 let fs = FakeFs::new(cx.executor());
1113
1114 // Create first worktree with its own private files
1115 fs.insert_tree(
1116 path!("/worktree1"),
1117 json!({
1118 ".zed": {
1119 "settings.json": r#"{
1120 "file_scan_exclusions": ["**/fixture.*"],
1121 "private_files": ["**/secret.rs"]
1122 }"#
1123 },
1124 "src": {
1125 "main.rs": "fn main() { let secret_key = \"hidden\"; }",
1126 "secret.rs": "const API_KEY: &str = \"secret_value\";",
1127 "utils.rs": "pub fn get_config() -> String { \"config\".to_string() }"
1128 },
1129 "tests": {
1130 "test.rs": "fn test_secret() { assert!(true); }",
1131 "fixture.sql": "SELECT * FROM secret_table;"
1132 }
1133 }),
1134 )
1135 .await;
1136
1137 // Create second worktree with different private files
1138 fs.insert_tree(
1139 path!("/worktree2"),
1140 json!({
1141 ".zed": {
1142 "settings.json": r#"{
1143 "file_scan_exclusions": ["**/internal.*"],
1144 "private_files": ["**/private.js", "**/data.json"]
1145 }"#
1146 },
1147 "lib": {
1148 "public.js": "export function getSecret() { return 'public'; }",
1149 "private.js": "const SECRET_KEY = \"private_value\";",
1150 "data.json": "{\"secret_data\": \"hidden\"}"
1151 },
1152 "docs": {
1153 "README.md": "# Documentation with secret info",
1154 "internal.md": "Internal secret documentation"
1155 }
1156 }),
1157 )
1158 .await;
1159
1160 // Set global settings
1161 cx.update(|cx| {
1162 SettingsStore::update_global(cx, |store, cx| {
1163 store.update_user_settings::<WorktreeSettings>(cx, |settings| {
1164 settings.file_scan_exclusions =
1165 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
1166 settings.private_files = Some(vec!["**/.env".to_string()]);
1167 });
1168 });
1169 });
1170
1171 let project = Project::test(
1172 fs.clone(),
1173 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
1174 cx,
1175 )
1176 .await;
1177
1178 // Wait for worktrees to be fully scanned
1179 cx.executor().run_until_parked();
1180
1181 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1182 let model = Arc::new(FakeLanguageModel::default());
1183
1184 // Search for "secret" - should exclude files based on worktree-specific settings
1185 let result = cx
1186 .update(|cx| {
1187 let input = json!({
1188 "regex": "secret",
1189 "case_sensitive": false
1190 });
1191 Arc::new(GrepTool)
1192 .run(
1193 input,
1194 Arc::default(),
1195 project.clone(),
1196 action_log.clone(),
1197 model.clone(),
1198 None,
1199 cx,
1200 )
1201 .output
1202 })
1203 .await
1204 .unwrap();
1205
1206 let content = result.content.as_str().unwrap();
1207 let paths = extract_paths_from_results(content);
1208
1209 // Should find matches in non-private files
1210 assert!(
1211 paths.iter().any(|p| p.contains("main.rs")),
1212 "Should find 'secret' in worktree1/src/main.rs"
1213 );
1214 assert!(
1215 paths.iter().any(|p| p.contains("test.rs")),
1216 "Should find 'secret' in worktree1/tests/test.rs"
1217 );
1218 assert!(
1219 paths.iter().any(|p| p.contains("public.js")),
1220 "Should find 'secret' in worktree2/lib/public.js"
1221 );
1222 assert!(
1223 paths.iter().any(|p| p.contains("README.md")),
1224 "Should find 'secret' in worktree2/docs/README.md"
1225 );
1226
1227 // Should NOT find matches in private/excluded files based on worktree settings
1228 assert!(
1229 !paths.iter().any(|p| p.contains("secret.rs")),
1230 "Should not search in worktree1/src/secret.rs (local private_files)"
1231 );
1232 assert!(
1233 !paths.iter().any(|p| p.contains("fixture.sql")),
1234 "Should not search in worktree1/tests/fixture.sql (local file_scan_exclusions)"
1235 );
1236 assert!(
1237 !paths.iter().any(|p| p.contains("private.js")),
1238 "Should not search in worktree2/lib/private.js (local private_files)"
1239 );
1240 assert!(
1241 !paths.iter().any(|p| p.contains("data.json")),
1242 "Should not search in worktree2/lib/data.json (local private_files)"
1243 );
1244 assert!(
1245 !paths.iter().any(|p| p.contains("internal.md")),
1246 "Should not search in worktree2/docs/internal.md (local file_scan_exclusions)"
1247 );
1248
1249 // Test with `include_pattern` specific to one worktree
1250 let result = cx
1251 .update(|cx| {
1252 let input = json!({
1253 "regex": "secret",
1254 "include_pattern": "worktree1/**/*.rs"
1255 });
1256 Arc::new(GrepTool)
1257 .run(
1258 input,
1259 Arc::default(),
1260 project.clone(),
1261 action_log.clone(),
1262 model.clone(),
1263 None,
1264 cx,
1265 )
1266 .output
1267 })
1268 .await
1269 .unwrap();
1270
1271 let content = result.content.as_str().unwrap();
1272 let paths = extract_paths_from_results(content);
1273
1274 // Should only find matches in worktree1 *.rs files (excluding private ones)
1275 assert!(
1276 paths.iter().any(|p| p.contains("main.rs")),
1277 "Should find match in worktree1/src/main.rs"
1278 );
1279 assert!(
1280 paths.iter().any(|p| p.contains("test.rs")),
1281 "Should find match in worktree1/tests/test.rs"
1282 );
1283 assert!(
1284 !paths.iter().any(|p| p.contains("secret.rs")),
1285 "Should not find match in excluded worktree1/src/secret.rs"
1286 );
1287 assert!(
1288 paths.iter().all(|p| !p.contains("worktree2")),
1289 "Should not find any matches in worktree2"
1290 );
1291 }
1292
1293 // Helper function to extract file paths from grep results
1294 fn extract_paths_from_results(results: &str) -> Vec<String> {
1295 results
1296 .lines()
1297 .filter(|line| line.starts_with("## Matches in "))
1298 .map(|line| {
1299 line.strip_prefix("## Matches in ")
1300 .unwrap()
1301 .trim()
1302 .to_string()
1303 })
1304 .collect()
1305 }
1306}