Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:37:40.113Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

grep_tool.rs

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