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