grep_tool.rs

   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}