Implement regex search with multiline support

Antonio Scandurra and Nathan Sobo created

Co-Authored-By: Nathan Sobo <nathan@zed.dev>

Change summary

Cargo.lock                          |   2 
crates/editor/src/editor.rs         |   5 
crates/editor/src/element.rs        |   9 +
crates/find/Cargo.toml              |   2 
crates/find/src/find.rs             | 173 +++++++++++++++++++++---------
crates/theme/src/theme.rs           |   1 
crates/zed/assets/themes/_base.toml |   6 
7 files changed, 137 insertions(+), 61 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1724,10 +1724,12 @@ name = "find"
 version = "0.1.0"
 dependencies = [
  "aho-corasick",
+ "anyhow",
  "collections",
  "editor",
  "gpui",
  "postage",
+ "regex",
  "smol",
  "theme",
  "workspace",

crates/editor/src/editor.rs 🔗

@@ -29,10 +29,11 @@ use language::{
     AnchorRangeExt as _, BracketPair, Buffer, Diagnostic, DiagnosticSeverity, Language, Point,
     Selection, SelectionGoal, TransactionId,
 };
+use multi_buffer::MultiBufferChunks;
 pub use multi_buffer::{
-    Anchor, AnchorRangeExt, ExcerptId, ExcerptProperties, MultiBuffer, ToOffset, ToPoint,
+    Anchor, AnchorRangeExt, ExcerptId, ExcerptProperties, MultiBuffer, MultiBufferSnapshot,
+    ToOffset, ToPoint,
 };
-use multi_buffer::{MultiBufferChunks, MultiBufferSnapshot};
 use postage::watch;
 use serde::{Deserialize, Serialize};
 use smallvec::SmallVec;

crates/editor/src/element.rs 🔗

@@ -323,6 +323,7 @@ impl EditorElement {
                 end_row,
                 *color,
                 0.,
+                0.15 * layout.line_height,
                 layout,
                 content_origin,
                 scroll_top,
@@ -344,6 +345,7 @@ impl EditorElement {
                     end_row,
                     style.selection,
                     corner_radius,
+                    corner_radius * 2.,
                     layout,
                     content_origin,
                     scroll_top,
@@ -400,6 +402,7 @@ impl EditorElement {
         end_row: u32,
         color: Color,
         corner_radius: f32,
+        line_end_overshoot: f32,
         layout: &LayoutState,
         content_origin: Vector2F,
         scroll_top: f32,
@@ -414,7 +417,7 @@ impl EditorElement {
                 cmp::max(range.start.row(), start_row)..cmp::min(range.end.row() + 1, end_row)
             };
 
-            let selection = HighlightedRange {
+            let highlighted_range = HighlightedRange {
                 color,
                 line_height: layout.line_height,
                 corner_radius,
@@ -437,7 +440,7 @@ impl EditorElement {
                                     + line_layout.x_for_index(range.end.column() as usize)
                                     - scroll_left
                             } else {
-                                content_origin.x() + line_layout.width() + corner_radius * 2.0
+                                content_origin.x() + line_layout.width() + line_end_overshoot
                                     - scroll_left
                             },
                         }
@@ -445,7 +448,7 @@ impl EditorElement {
                     .collect(),
             };
 
-            selection.paint(bounds, cx.scene);
+            highlighted_range.paint(bounds, cx.scene);
         }
     }
 

crates/find/Cargo.toml 🔗

@@ -13,5 +13,7 @@ gpui = { path = "../gpui" }
 theme = { path = "../theme" }
 workspace = { path = "../workspace" }
 aho-corasick = "0.7"
+anyhow = "1.0"
 postage = { version = "0.4.1", features = ["futures-traits"] }
+regex = "1.5"
 smol = { version = "1.2" }

crates/find/src/find.rs 🔗

@@ -1,13 +1,15 @@
 use aho_corasick::AhoCorasickBuilder;
+use anyhow::Result;
 use collections::HashSet;
-use editor::{char_kind, Editor, EditorSettings};
+use editor::{char_kind, Anchor, Editor, EditorSettings, MultiBufferSnapshot};
 use gpui::{
     action, elements::*, keymap::Binding, Entity, MutableAppContext, RenderContext, Subscription,
     Task, View, ViewContext, ViewHandle, WeakViewHandle,
 };
 use postage::watch;
+use regex::RegexBuilder;
 use smol::future::yield_now;
-use std::sync::Arc;
+use std::{ops::Range, sync::Arc};
 use workspace::{ItemViewHandle, Settings, Toolbar, Workspace};
 
 action!(Deploy);
@@ -41,6 +43,7 @@ struct FindBar {
     case_sensitive_mode: bool,
     whole_word_mode: bool,
     regex_mode: bool,
+    query_contains_error: bool,
 }
 
 impl Entity for FindBar {
@@ -58,11 +61,16 @@ impl View for FindBar {
 
     fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
         let theme = &self.settings.borrow().theme.find;
+        let editor_container = if self.query_contains_error {
+            theme.invalid_editor
+        } else {
+            theme.editor.input.container
+        };
         Flex::row()
             .with_child(
                 ChildView::new(&self.query_editor)
                     .contained()
-                    .with_style(theme.editor.input.container)
+                    .with_style(editor_container)
                     .constrained()
                     .with_max_width(theme.editor.max_width)
                     .boxed(),
@@ -135,6 +143,7 @@ impl FindBar {
             regex_mode: false,
             settings,
             pending_search: None,
+            query_contains_error: false,
         }
     }
 
@@ -214,7 +223,9 @@ impl FindBar {
                         }
                     }
                 }
+                self.query_contains_error = false;
                 self.update_matches(cx);
+                cx.notify();
             }
             _ => {}
         }
@@ -233,70 +244,122 @@ impl FindBar {
     }
 
     fn update_matches(&mut self, cx: &mut ViewContext<Self>) {
-        let search = self.query_editor.read(cx).text(cx);
+        let query = self.query_editor.read(cx).text(cx);
         self.pending_search.take();
         if let Some(editor) = self.active_editor.as_ref() {
-            if search.is_empty() {
+            if query.is_empty() {
                 editor.update(cx, |editor, cx| editor.clear_highlighted_ranges::<Self>(cx));
             } else {
                 let buffer = editor.read(cx).buffer().read(cx).snapshot(cx);
-                let case_sensitive_mode = self.case_sensitive_mode;
-                let whole_word_mode = self.whole_word_mode;
-                let ranges = cx.background().spawn(async move {
-                    const YIELD_INTERVAL: usize = 20000;
-
-                    let search = AhoCorasickBuilder::new()
-                        .auto_configure(&[&search])
-                        .ascii_case_insensitive(!case_sensitive_mode)
-                        .build(&[&search]);
-                    let mut ranges = Vec::new();
-                    for (ix, mat) in search
-                        .stream_find_iter(buffer.bytes_in_range(0..buffer.len()))
-                        .enumerate()
-                    {
-                        if (ix + 1) % YIELD_INTERVAL == 0 {
-                            yield_now().await;
-                        }
+                let case_sensitive = self.case_sensitive_mode;
+                let whole_word = self.whole_word_mode;
+                let ranges = if self.regex_mode {
+                    cx.background()
+                        .spawn(regex_search(buffer, query, case_sensitive, whole_word))
+                } else {
+                    cx.background().spawn(async move {
+                        Ok(search(buffer, query, case_sensitive, whole_word).await)
+                    })
+                };
 
-                        let mat = mat.unwrap();
-
-                        if whole_word_mode {
-                            let prev_kind =
-                                buffer.reversed_chars_at(mat.start()).next().map(char_kind);
-                            let start_kind =
-                                char_kind(buffer.chars_at(mat.start()).next().unwrap());
-                            let end_kind =
-                                char_kind(buffer.reversed_chars_at(mat.end()).next().unwrap());
-                            let next_kind = buffer.chars_at(mat.end()).next().map(char_kind);
-                            if Some(start_kind) == prev_kind || Some(end_kind) == next_kind {
-                                continue;
+                let editor = editor.downgrade();
+                self.pending_search = Some(cx.spawn(|this, mut cx| async move {
+                    match ranges.await {
+                        Ok(ranges) => {
+                            if let Some(editor) = cx.read(|cx| editor.upgrade(cx)) {
+                                this.update(&mut cx, |this, cx| {
+                                    let theme = &this.settings.borrow().theme.find;
+                                    this.highlighted_editors.insert(editor.downgrade());
+                                    editor.update(cx, |editor, cx| {
+                                        editor.highlight_ranges::<Self>(
+                                            ranges,
+                                            theme.match_background,
+                                            cx,
+                                        )
+                                    });
+                                });
                             }
                         }
-
-                        ranges.push(
-                            buffer.anchor_after(mat.start())..buffer.anchor_before(mat.end()),
-                        );
-                    }
-
-                    ranges
-                });
-
-                let editor = editor.downgrade();
-                self.pending_search = Some(cx.spawn_weak(|this, mut cx| async move {
-                    let ranges = ranges.await;
-                    if let Some((this, editor)) =
-                        cx.read(|cx| this.upgrade(cx).zip(editor.upgrade(cx)))
-                    {
-                        this.update(&mut cx, |this, cx| {
-                            let theme = &this.settings.borrow().theme.find;
-                            this.highlighted_editors.insert(editor.downgrade());
-                            editor.update(cx, |editor, cx| {
-                                editor.highlight_ranges::<Self>(ranges, theme.match_background, cx)
+                        Err(_) => {
+                            this.update(&mut cx, |this, cx| {
+                                this.query_contains_error = true;
+                                cx.notify();
                             });
-                        });
+                        }
                     }
                 }));
             }
         }
     }
 }
+
+const YIELD_INTERVAL: usize = 20000;
+
+async fn search(
+    buffer: MultiBufferSnapshot,
+    query: String,
+    case_sensitive: bool,
+    whole_word: bool,
+) -> Vec<Range<Anchor>> {
+    let mut ranges = Vec::new();
+
+    let search = AhoCorasickBuilder::new()
+        .auto_configure(&[&query])
+        .ascii_case_insensitive(!case_sensitive)
+        .build(&[&query]);
+    for (ix, mat) in search
+        .stream_find_iter(buffer.bytes_in_range(0..buffer.len()))
+        .enumerate()
+    {
+        if (ix + 1) % YIELD_INTERVAL == 0 {
+            yield_now().await;
+        }
+
+        let mat = mat.unwrap();
+
+        if whole_word {
+            let prev_kind = buffer.reversed_chars_at(mat.start()).next().map(char_kind);
+            let start_kind = char_kind(buffer.chars_at(mat.start()).next().unwrap());
+            let end_kind = char_kind(buffer.reversed_chars_at(mat.end()).next().unwrap());
+            let next_kind = buffer.chars_at(mat.end()).next().map(char_kind);
+            if Some(start_kind) == prev_kind || Some(end_kind) == next_kind {
+                continue;
+            }
+        }
+
+        ranges.push(buffer.anchor_after(mat.start())..buffer.anchor_before(mat.end()));
+    }
+
+    ranges
+}
+
+async fn regex_search(
+    buffer: MultiBufferSnapshot,
+    mut query: String,
+    case_sensitive: bool,
+    whole_word: bool,
+) -> Result<Vec<Range<Anchor>>> {
+    if whole_word {
+        let mut word_query = String::new();
+        word_query.push_str("\\b");
+        word_query.push_str(&query);
+        word_query.push_str("\\b");
+        query = word_query;
+    }
+
+    let mut ranges = Vec::new();
+
+    let regex = RegexBuilder::new(&query)
+        .case_insensitive(!case_sensitive)
+        .multi_line(true)
+        .build()?;
+    for (ix, mat) in regex.find_iter(&buffer.text()).enumerate() {
+        if (ix + 1) % YIELD_INTERVAL == 0 {
+            yield_now().await;
+        }
+
+        ranges.push(buffer.anchor_after(mat.start())..buffer.anchor_before(mat.end()));
+    }
+
+    Ok(ranges)
+}

crates/theme/src/theme.rs 🔗

@@ -93,6 +93,7 @@ pub struct Find {
     #[serde(flatten)]
     pub container: ContainerStyle,
     pub editor: FindEditor,
+    pub invalid_editor: ContainerStyle,
     pub mode_button_group: ContainerStyle,
     pub mode_button: ContainedText,
     pub active_mode_button: ContainedText,

crates/zed/assets/themes/_base.toml 🔗

@@ -185,7 +185,7 @@ corner_radius = 6
 
 [project_panel]
 extends = "$panel"
-padding.top = 6    # ($workspace.tab.height - $project_panel.entry.height) / 2
+padding.top = 6 # ($workspace.tab.height - $project_panel.entry.height) / 2
 
 [project_panel.entry]
 text = "$text.1"
@@ -352,3 +352,7 @@ text = "$text.0"
 placeholder_text = "$text.2"
 selection = "$selection.host"
 border = { width = 1, color = "$border.0" }
+
+[find.invalid_editor]
+extends = "$find.editor"
+border = { width = 1, color = "$status.bad" }