Fix incorrect search ranges when rendering search matches in the outline panel (#41859)

Kirill Bulatov created

Closes https://github.com/zed-industries/zed/issues/41792

Release Notes:

- Fixed outline panel panicking when rendering certain search matches

Change summary

crates/outline_panel/src/outline_panel.rs | 294 +++++++++++++++++-------
1 file changed, 201 insertions(+), 93 deletions(-)

Detailed changes

crates/outline_panel/src/outline_panel.rs 🔗

@@ -465,14 +465,8 @@ impl SearchData {
         let match_offset_range = match_range.to_offset(multi_buffer_snapshot);
 
         let mut search_match_indices = vec![
-            multi_buffer_snapshot.clip_offset(
-                match_offset_range.start - context_offset_range.start,
-                Bias::Left,
-            )
-                ..multi_buffer_snapshot.clip_offset(
-                    match_offset_range.end - context_offset_range.start,
-                    Bias::Right,
-                ),
+            match_offset_range.start - context_offset_range.start
+                ..match_offset_range.end - context_offset_range.start,
         ];
 
         let entire_context_text = multi_buffer_snapshot
@@ -509,14 +503,8 @@ impl SearchData {
                 .next()
                 .is_some_and(|c| !c.is_whitespace());
         search_match_indices.iter_mut().for_each(|range| {
-            range.start = multi_buffer_snapshot.clip_offset(
-                range.start.saturating_sub(left_whitespaces_offset),
-                Bias::Left,
-            );
-            range.end = multi_buffer_snapshot.clip_offset(
-                range.end.saturating_sub(left_whitespaces_offset),
-                Bias::Right,
-            );
+            range.start = range.start.saturating_sub(left_whitespaces_offset);
+            range.end = range.end.saturating_sub(left_whitespaces_offset);
         });
 
         let trimmed_row_offset_range =
@@ -5256,10 +5244,13 @@ mod tests {
     use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust};
     use pretty_assertions::assert_eq;
     use project::FakeFs;
-    use search::project_search::{self, perform_project_search};
+    use search::{
+        buffer_search,
+        project_search::{self, perform_project_search},
+    };
     use serde_json::json;
     use util::path;
-    use workspace::{OpenOptions, OpenVisible};
+    use workspace::{OpenOptions, OpenVisible, ToolbarItemView};
 
     use super::*;
 
@@ -5322,25 +5313,28 @@ mod tests {
     ide/src/
       inlay_hints/
         fn_lifetime_fn.rs
-          search: match config.param_names_for_lifetime_elision_hints {
-          search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {
-          search: Some(it) if config.param_names_for_lifetime_elision_hints => {
-          search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },
+          search: match config.«param_names_for_lifetime_elision_hints» {
+          search: allocated_lifetimes.push(if config.«param_names_for_lifetime_elision_hints» {
+          search: Some(it) if config.«param_names_for_lifetime_elision_hints» => {
+          search: InlayHintsConfig { «param_names_for_lifetime_elision_hints»: true, ..TEST_CONFIG },
       inlay_hints.rs
-        search: pub param_names_for_lifetime_elision_hints: bool,
-        search: param_names_for_lifetime_elision_hints: self
+        search: pub «param_names_for_lifetime_elision_hints»: bool,
+        search: «param_names_for_lifetime_elision_hints»: self
       static_index.rs
-        search: param_names_for_lifetime_elision_hints: false,
+        search: «param_names_for_lifetime_elision_hints»: false,
     rust-analyzer/src/
       cli/
         analysis_stats.rs
-          search: param_names_for_lifetime_elision_hints: true,
+          search: «param_names_for_lifetime_elision_hints»: true,
       config.rs
-        search: param_names_for_lifetime_elision_hints: self"#
+        search: «param_names_for_lifetime_elision_hints»: self"#
             .to_string();
 
         let select_first_in_all_matches = |line_to_select: &str| {
-            assert!(all_matches.contains(line_to_select));
+            assert!(
+                all_matches.contains(line_to_select),
+                "`{line_to_select}` was not found in all matches `{all_matches}`"
+            );
             all_matches.replacen(
                 line_to_select,
                 &format!("{line_to_select}{SELECTED_MARKER}"),
@@ -5361,7 +5355,7 @@ mod tests {
                     cx,
                 ),
                 select_first_in_all_matches(
-                    "search: match config.param_names_for_lifetime_elision_hints {"
+                    "search: match config.«param_names_for_lifetime_elision_hints» {"
                 )
             );
         });
@@ -5401,16 +5395,16 @@ mod tests {
       inlay_hints/
         fn_lifetime_fn.rs{SELECTED_MARKER}
       inlay_hints.rs
-        search: pub param_names_for_lifetime_elision_hints: bool,
-        search: param_names_for_lifetime_elision_hints: self
+        search: pub «param_names_for_lifetime_elision_hints»: bool,
+        search: «param_names_for_lifetime_elision_hints»: self
       static_index.rs
-        search: param_names_for_lifetime_elision_hints: false,
+        search: «param_names_for_lifetime_elision_hints»: false,
     rust-analyzer/src/
       cli/
         analysis_stats.rs
-          search: param_names_for_lifetime_elision_hints: true,
+          search: «param_names_for_lifetime_elision_hints»: true,
       config.rs
-        search: param_names_for_lifetime_elision_hints: self"#,
+        search: «param_names_for_lifetime_elision_hints»: self"#,
                 )
             );
         });
@@ -5471,9 +5465,9 @@ mod tests {
     rust-analyzer/src/
       cli/
         analysis_stats.rs
-          search: param_names_for_lifetime_elision_hints: true,
+          search: «param_names_for_lifetime_elision_hints»: true,
       config.rs
-        search: param_names_for_lifetime_elision_hints: self"#,
+        search: «param_names_for_lifetime_elision_hints»: self"#,
                 )
             );
         });
@@ -5553,21 +5547,21 @@ mod tests {
     ide/src/
       inlay_hints/
         fn_lifetime_fn.rs
-          search: match config.param_names_for_lifetime_elision_hints {
-          search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {
-          search: Some(it) if config.param_names_for_lifetime_elision_hints => {
-          search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },
+          search: match config.«param_names_for_lifetime_elision_hints» {
+          search: allocated_lifetimes.push(if config.«param_names_for_lifetime_elision_hints» {
+          search: Some(it) if config.«param_names_for_lifetime_elision_hints» => {
+          search: InlayHintsConfig { «param_names_for_lifetime_elision_hints»: true, ..TEST_CONFIG },
       inlay_hints.rs
-        search: pub param_names_for_lifetime_elision_hints: bool,
-        search: param_names_for_lifetime_elision_hints: self
+        search: pub «param_names_for_lifetime_elision_hints»: bool,
+        search: «param_names_for_lifetime_elision_hints»: self
       static_index.rs
-        search: param_names_for_lifetime_elision_hints: false,
+        search: «param_names_for_lifetime_elision_hints»: false,
     rust-analyzer/src/
       cli/
         analysis_stats.rs
-          search: param_names_for_lifetime_elision_hints: true,
+          search: «param_names_for_lifetime_elision_hints»: true,
       config.rs
-        search: param_names_for_lifetime_elision_hints: self"#
+        search: «param_names_for_lifetime_elision_hints»: self"#
             .to_string();
 
         cx.executor()
@@ -5692,30 +5686,40 @@ mod tests {
     ide/src/
       inlay_hints/
         fn_lifetime_fn.rs
-          search: match config.param_names_for_lifetime_elision_hints {
-          search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {
-          search: Some(it) if config.param_names_for_lifetime_elision_hints => {
-          search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },
+          search: match config.«param_names_for_lifetime_elision_hints» {
+          search: allocated_lifetimes.push(if config.«param_names_for_lifetime_elision_hints» {
+          search: Some(it) if config.«param_names_for_lifetime_elision_hints» => {
+          search: InlayHintsConfig { «param_names_for_lifetime_elision_hints»: true, ..TEST_CONFIG },
       inlay_hints.rs
-        search: pub param_names_for_lifetime_elision_hints: bool,
-        search: param_names_for_lifetime_elision_hints: self
+        search: pub «param_names_for_lifetime_elision_hints»: bool,
+        search: «param_names_for_lifetime_elision_hints»: self
       static_index.rs
-        search: param_names_for_lifetime_elision_hints: false,
+        search: «param_names_for_lifetime_elision_hints»: false,
     rust-analyzer/src/
       cli/
         analysis_stats.rs
-          search: param_names_for_lifetime_elision_hints: true,
+          search: «param_names_for_lifetime_elision_hints»: true,
       config.rs
-        search: param_names_for_lifetime_elision_hints: self"#
+        search: «param_names_for_lifetime_elision_hints»: self"#
             .to_string();
         let select_first_in_all_matches = |line_to_select: &str| {
-            assert!(all_matches.contains(line_to_select));
+            assert!(
+                all_matches.contains(line_to_select),
+                "`{line_to_select}` was not found in all matches `{all_matches}`"
+            );
             all_matches.replacen(
                 line_to_select,
                 &format!("{line_to_select}{SELECTED_MARKER}"),
                 1,
             )
         };
+        let clear_outline_metadata = |input: &str| {
+            input
+                .replace("search: ", "")
+                .replace("«", "")
+                .replace("»", "")
+        };
+
         cx.executor()
             .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
         cx.run_until_parked();
@@ -5726,7 +5730,7 @@ mod tests {
                 .expect("should have an active editor open")
         });
         let initial_outline_selection =
-            "search: match config.param_names_for_lifetime_elision_hints {";
+            "search: match config.«param_names_for_lifetime_elision_hints» {";
         outline_panel.update_in(cx, |outline_panel, window, cx| {
             assert_eq!(
                 display_entries(
@@ -5740,7 +5744,7 @@ mod tests {
             );
             assert_eq!(
                 selected_row_text(&active_editor, cx),
-                initial_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
+                clear_outline_metadata(initial_outline_selection),
                 "Should place the initial editor selection on the corresponding search result"
             );
 
@@ -5749,7 +5753,7 @@ mod tests {
         });
 
         let navigated_outline_selection =
-            "search: Some(it) if config.param_names_for_lifetime_elision_hints => {";
+            "search: Some(it) if config.«param_names_for_lifetime_elision_hints» => {";
         outline_panel.update(cx, |outline_panel, cx| {
             assert_eq!(
                 display_entries(
@@ -5767,7 +5771,7 @@ mod tests {
         outline_panel.update(cx, |_, cx| {
             assert_eq!(
                 selected_row_text(&active_editor, cx),
-                navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
+                clear_outline_metadata(navigated_outline_selection),
                 "Should still have the initial caret position after SelectNext calls"
             );
         });
@@ -5778,7 +5782,7 @@ mod tests {
         outline_panel.update(cx, |_outline_panel, cx| {
             assert_eq!(
                 selected_row_text(&active_editor, cx),
-                navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
+                clear_outline_metadata(navigated_outline_selection),
                 "After opening, should move the caret to the opened outline entry's position"
             );
         });
@@ -5786,7 +5790,7 @@ mod tests {
         outline_panel.update_in(cx, |outline_panel, window, cx| {
             outline_panel.select_next(&SelectNext, window, cx);
         });
-        let next_navigated_outline_selection = "search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },";
+        let next_navigated_outline_selection = "search: InlayHintsConfig { «param_names_for_lifetime_elision_hints»: true, ..TEST_CONFIG },";
         outline_panel.update(cx, |outline_panel, cx| {
             assert_eq!(
                 display_entries(
@@ -5804,7 +5808,7 @@ mod tests {
         outline_panel.update(cx, |_outline_panel, cx| {
             assert_eq!(
                 selected_row_text(&active_editor, cx),
-                next_navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
+                clear_outline_metadata(next_navigated_outline_selection),
                 "Should again preserve the selection after another SelectNext call"
             );
         });
@@ -5837,7 +5841,7 @@ mod tests {
             );
             assert_eq!(
                 selected_row_text(&new_active_editor, cx),
-                next_navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
+                clear_outline_metadata(next_navigated_outline_selection),
                 "When opening the excerpt, should navigate to the place corresponding the outline entry"
             );
         });
@@ -5939,11 +5943,11 @@ mod tests {
                 format!(
                     r#"one/
   a.txt
-    search: aaa aaa  <==== selected
-    search: aaa aaa
+    search: «aaa» aaa  <==== selected
+    search: aaa «aaa»
 two/
   b.txt
-    search: a aaa"#,
+    search: a «aaa»"#,
                 ),
             );
         });
@@ -5969,7 +5973,7 @@ two/
   a.txt  <==== selected
 two/
   b.txt
-    search: a aaa"#,
+    search: a «aaa»"#,
                 ),
             );
         });
@@ -6018,7 +6022,7 @@ two/  <==== selected"#,
   a.txt
 two/  <==== selected
   b.txt
-    search: a aaa"#,
+    search: a «aaa»"#,
                 )
             );
         });
@@ -6483,18 +6487,18 @@ outline: struct OutlineEntryExcerpt
                     r#"frontend-project/
   public/lottie/
     syntax-tree.json
-      search: {{ "something": "static" }}  <==== selected
+      search: {{ "something": "«static»" }}  <==== selected
   src/
     app/(site)/
       (about)/jobs/[slug]/
         page.tsx
-          search: static
+          search: «static»
       (blog)/post/[slug]/
         page.tsx
-          search: static
+          search: «static»
     components/
       ErrorBoundary.tsx
-        search: static"#
+        search: «static»"#
                 )
             );
         });
@@ -6522,12 +6526,12 @@ outline: struct OutlineEntryExcerpt
                     r#"frontend-project/
   public/lottie/
     syntax-tree.json
-      search: {{ "something": "static" }}
+      search: {{ "something": "«static»" }}
   src/
     app/(site)/  <==== selected
     components/
       ErrorBoundary.tsx
-        search: static"#
+        search: «static»"#
                 )
             );
         });
@@ -6552,12 +6556,12 @@ outline: struct OutlineEntryExcerpt
                     r#"frontend-project/
   public/lottie/
     syntax-tree.json
-      search: {{ "something": "static" }}
+      search: {{ "something": "«static»" }}
   src/
     app/(site)/
     components/
       ErrorBoundary.tsx
-        search: static  <==== selected"#
+        search: «static»  <==== selected"#
                 )
             );
         });
@@ -6586,7 +6590,7 @@ outline: struct OutlineEntryExcerpt
                     r#"frontend-project/
   public/lottie/
     syntax-tree.json
-      search: {{ "something": "static" }}
+      search: {{ "something": "«static»" }}
   src/
     app/(site)/
     components/
@@ -6619,12 +6623,12 @@ outline: struct OutlineEntryExcerpt
                     r#"frontend-project/
   public/lottie/
     syntax-tree.json
-      search: {{ "something": "static" }}
+      search: {{ "something": "«static»" }}
   src/
     app/(site)/
     components/
       ErrorBoundary.tsx  <==== selected
-        search: static"#
+        search: «static»"#
                 )
             );
         });
@@ -6667,18 +6671,18 @@ outline: struct OutlineEntryExcerpt
                     r#"frontend-project/
   public/lottie/
     syntax-tree.json
-      search: {{ "something": "static" }}
+      search: {{ "something": "«static»" }}
   src/
     app/(site)/
       (about)/jobs/[slug]/
         page.tsx
-          search: static
+          search: «static»
       (blog)/post/[slug]/
         page.tsx
-          search: static
+          search: «static»
     components/
       ErrorBoundary.tsx  <==== selected
-        search: static"#
+        search: «static»"#
                 )
             );
         });
@@ -6784,16 +6788,21 @@ outline: struct OutlineEntryExcerpt
                     }
                 },
                 PanelEntry::Search(search_entry) => {
-                    format!(
-                        "search: {}",
-                        search_entry
-                            .render_data
-                            .get_or_init(|| SearchData::new(
-                                &search_entry.match_range,
-                                multi_buffer_snapshot
-                            ))
-                            .context_text
-                    )
+                    let search_data = search_entry.render_data.get_or_init(|| {
+                        SearchData::new(&search_entry.match_range, multi_buffer_snapshot)
+                    });
+                    let mut search_result = String::new();
+                    let mut last_end = 0;
+                    for range in &search_data.search_match_indices {
+                        search_result.push_str(&search_data.context_text[last_end..range.start]);
+                        search_result.push('«');
+                        search_result.push_str(&search_data.context_text[range.start..range.end]);
+                        search_result.push('»');
+                        last_end = range.end;
+                    }
+                    search_result.push_str(&search_data.context_text[last_end..]);
+
+                    format!("search: {search_result}")
                 }
             };
 
@@ -6816,6 +6825,7 @@ outline: struct OutlineEntryExcerpt
             workspace::init_settings(cx);
             Project::init_settings(cx);
             project_search::init(cx);
+            buffer_search::init(cx);
             super::init(cx);
         });
     }
@@ -7827,4 +7837,102 @@ outline: fn main()"
             };
         });
     }
+
+    #[gpui::test]
+    async fn test_buffer_search(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.background_executor.clone());
+        fs.insert_tree(
+            "/test",
+            json!({
+                "foo.txt": r#"<_constitution>
+
+</_constitution>
+
+
+
+## 📊 Output
+
+| Field          | Meaning                |
+"#
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
+        let workspace = add_outline_panel(&project, cx).await;
+        let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+        let editor = workspace
+            .update(cx, |workspace, window, cx| {
+                workspace.open_abs_path(
+                    PathBuf::from("/test/foo.txt"),
+                    OpenOptions {
+                        visible: Some(OpenVisible::All),
+                        ..OpenOptions::default()
+                    },
+                    window,
+                    cx,
+                )
+            })
+            .unwrap()
+            .await
+            .unwrap()
+            .downcast::<Editor>()
+            .unwrap();
+
+        let search_bar = workspace
+            .update(cx, |_, window, cx| {
+                cx.new(|cx| {
+                    let mut search_bar = BufferSearchBar::new(None, window, cx);
+                    search_bar.set_active_pane_item(Some(&editor), window, cx);
+                    search_bar.show(window, cx);
+                    search_bar
+                })
+            })
+            .unwrap();
+
+        let outline_panel = outline_panel(&workspace, cx);
+
+        outline_panel.update_in(cx, |outline_panel, window, cx| {
+            outline_panel.set_active(true, window, cx)
+        });
+
+        search_bar
+            .update_in(cx, |search_bar, window, cx| {
+                search_bar.search("  ", None, true, window, cx)
+            })
+            .await
+            .unwrap();
+
+        cx.executor()
+            .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500));
+        cx.run_until_parked();
+
+        outline_panel.update(cx, |outline_panel, cx| {
+            assert_eq!(
+                display_entries(
+                    &project,
+                    &snapshot(outline_panel, cx),
+                    &outline_panel.cached_entries,
+                    outline_panel.selected_entry(),
+                    cx,
+                ),
+                "search: | Field«  »        | Meaning                |  <==== selected
+search: | Field  «  »      | Meaning                |
+search: | Field    «  »    | Meaning                |
+search: | Field      «  »  | Meaning                |
+search: | Field        «  »| Meaning                |
+search: | Field          | Meaning«  »              |
+search: | Field          | Meaning  «  »            |
+search: | Field          | Meaning    «  »          |
+search: | Field          | Meaning      «  »        |
+search: | Field          | Meaning        «  »      |
+search: | Field          | Meaning          «  »    |
+search: | Field          | Meaning            «  »  |
+search: | Field          | Meaning              «  »|"
+            );
+        });
+    }
 }