Merge pull request #2186 from zed-industries/better-vim-matching-motion

Kay Simmons created

Better vim matching motion

Change summary

crates/editor/src/editor.rs                       |  19 --
crates/editor/src/multi_buffer.rs                 | 143 ++++++++++------
crates/editor/src/test/editor_lsp_test_context.rs |  34 +++
crates/language/src/buffer.rs                     |  25 +-
crates/language/src/buffer_tests.rs               |  11 
crates/util/Cargo.toml                            |   2 
crates/util/src/util.rs                           |  51 +++++
crates/vim/src/motion.rs                          |  58 +++++-
crates/vim/src/normal.rs                          |  62 +++++--
crates/vim/src/test/vim_test_context.rs           |  68 +------
crates/vim/src/visual.rs                          |   2 
crates/vim/test_data/test_o.json                  |   2 
crates/vim/test_data/test_percent.json            |   0 
crates/workspace/src/workspace.rs                 |  18 ++
14 files changed, 303 insertions(+), 192 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -77,14 +77,14 @@ use std::{
     cmp::{self, Ordering, Reverse},
     mem,
     num::NonZeroU32,
-    ops::{Deref, DerefMut, Range, RangeInclusive},
+    ops::{Deref, DerefMut, Range},
     path::Path,
     sync::Arc,
     time::{Duration, Instant},
 };
 pub use sum_tree::Bias;
 use theme::{DiagnosticStyle, Theme};
-use util::{post_inc, ResultExt, TryFutureExt};
+use util::{post_inc, ResultExt, TryFutureExt, RangeExt};
 use workspace::{ItemNavHistory, ViewId, Workspace, WorkspaceId};
 
 use crate::git::diff_hunk_to_display;
@@ -6993,21 +6993,6 @@ pub fn split_words<'a>(text: &'a str) -> impl std::iter::Iterator<Item = &'a str
     .flat_map(|word| word.split_inclusive('_'))
 }
 
-trait RangeExt<T> {
-    fn sorted(&self) -> Range<T>;
-    fn to_inclusive(&self) -> RangeInclusive<T>;
-}
-
-impl<T: Ord + Clone> RangeExt<T> for Range<T> {
-    fn sorted(&self) -> Self {
-        cmp::min(&self.start, &self.end).clone()..cmp::max(&self.start, &self.end).clone()
-    }
-
-    fn to_inclusive(&self) -> RangeInclusive<T> {
-        self.start.clone()..=self.end.clone()
-    }
-}
-
 trait RangeToAnchorExt {
     fn to_anchors(self, snapshot: &MultiBufferSnapshot) -> Range<Anchor>;
 }

crates/editor/src/multi_buffer.rs 🔗

@@ -385,9 +385,13 @@ impl MultiBuffer {
             _ => Default::default(),
         };
 
-        #[allow(clippy::type_complexity)]
-        let mut buffer_edits: HashMap<usize, Vec<(Range<usize>, Arc<str>, bool, u32)>> =
-            Default::default();
+        struct BufferEdit {
+            range: Range<usize>,
+            new_text: Arc<str>,
+            is_insertion: bool,
+            original_indent_column: u32,
+        }
+        let mut buffer_edits: HashMap<usize, Vec<BufferEdit>> = Default::default();
         let mut cursor = snapshot.excerpts.cursor::<usize>();
         for (ix, (range, new_text)) in edits.enumerate() {
             let new_text: Arc<str> = new_text.into();
@@ -422,12 +426,12 @@ impl MultiBuffer {
                 buffer_edits
                     .entry(start_excerpt.buffer_id)
                     .or_insert(Vec::new())
-                    .push((
-                        buffer_start..buffer_end,
+                    .push(BufferEdit {
+                        range: buffer_start..buffer_end,
                         new_text,
-                        true,
+                        is_insertion: true,
                         original_indent_column,
-                    ));
+                    });
             } else {
                 let start_excerpt_range = buffer_start
                     ..start_excerpt
@@ -444,21 +448,21 @@ impl MultiBuffer {
                 buffer_edits
                     .entry(start_excerpt.buffer_id)
                     .or_insert(Vec::new())
-                    .push((
-                        start_excerpt_range,
-                        new_text.clone(),
-                        true,
+                    .push(BufferEdit {
+                        range: start_excerpt_range,
+                        new_text: new_text.clone(),
+                        is_insertion: true,
                         original_indent_column,
-                    ));
+                    });
                 buffer_edits
                     .entry(end_excerpt.buffer_id)
                     .or_insert(Vec::new())
-                    .push((
-                        end_excerpt_range,
-                        new_text.clone(),
-                        false,
+                    .push(BufferEdit {
+                        range: end_excerpt_range,
+                        new_text: new_text.clone(),
+                        is_insertion: false,
                         original_indent_column,
-                    ));
+                    });
 
                 cursor.seek(&range.start, Bias::Right, &());
                 cursor.next(&());
@@ -469,19 +473,19 @@ impl MultiBuffer {
                     buffer_edits
                         .entry(excerpt.buffer_id)
                         .or_insert(Vec::new())
-                        .push((
-                            excerpt.range.context.to_offset(&excerpt.buffer),
-                            new_text.clone(),
-                            false,
+                        .push(BufferEdit {
+                            range: excerpt.range.context.to_offset(&excerpt.buffer),
+                            new_text: new_text.clone(),
+                            is_insertion: false,
                             original_indent_column,
-                        ));
+                        });
                     cursor.next(&());
                 }
             }
         }
 
         for (buffer_id, mut edits) in buffer_edits {
-            edits.sort_unstable_by_key(|(range, _, _, _)| range.start);
+            edits.sort_unstable_by_key(|edit| edit.range.start);
             self.buffers.borrow()[&buffer_id]
                 .buffer
                 .update(cx, |buffer, cx| {
@@ -490,14 +494,19 @@ impl MultiBuffer {
                     let mut original_indent_columns = Vec::new();
                     let mut deletions = Vec::new();
                     let empty_str: Arc<str> = "".into();
-                    while let Some((
+                    while let Some(BufferEdit {
                         mut range,
                         new_text,
                         mut is_insertion,
                         original_indent_column,
-                    )) = edits.next()
+                    }) = edits.next()
                     {
-                        while let Some((next_range, _, next_is_insertion, _)) = edits.peek() {
+                        while let Some(BufferEdit {
+                            range: next_range,
+                            is_insertion: next_is_insertion,
+                            ..
+                        }) = edits.peek()
+                        {
                             if range.end >= next_range.start {
                                 range.end = cmp::max(next_range.end, range.end);
                                 is_insertion |= *next_is_insertion;
@@ -2621,6 +2630,9 @@ impl MultiBufferSnapshot {
         self.parse_count
     }
 
+    /// Returns the smallest enclosing bracket ranges containing the given range or
+    /// None if no brackets contain range or the range is not contained in a single
+    /// excerpt
     pub fn innermost_enclosing_bracket_ranges<T: ToOffset>(
         &self,
         range: Range<T>,
@@ -2648,46 +2660,59 @@ impl MultiBufferSnapshot {
         result
     }
 
-    /// Returns enclosinng bracket ranges containing the given range or returns None if the range is
+    /// Returns enclosing bracket ranges containing the given range or returns None if the range is
     /// not contained in a single excerpt
     pub fn enclosing_bracket_ranges<'a, T: ToOffset>(
         &'a self,
         range: Range<T>,
     ) -> Option<impl Iterator<Item = (Range<usize>, Range<usize>)> + 'a> {
         let range = range.start.to_offset(self)..range.end.to_offset(self);
-        self.excerpt_containing(range.clone())
-            .map(|(excerpt, excerpt_offset)| {
-                let excerpt_buffer_start = excerpt.range.context.start.to_offset(&excerpt.buffer);
-                let excerpt_buffer_end = excerpt_buffer_start + excerpt.text_summary.len;
 
-                let start_in_buffer =
-                    excerpt_buffer_start + range.start.saturating_sub(excerpt_offset);
-                let end_in_buffer = excerpt_buffer_start + range.end.saturating_sub(excerpt_offset);
+        self.bracket_ranges(range.clone()).map(|range_pairs| {
+            range_pairs
+                .filter(move |(open, close)| open.start <= range.start && close.end >= range.end)
+        })
+    }
 
-                excerpt
-                    .buffer
-                    .enclosing_bracket_ranges(start_in_buffer..end_in_buffer)
-                    .filter_map(move |(start_bracket_range, end_bracket_range)| {
-                        if start_bracket_range.start < excerpt_buffer_start
-                            || end_bracket_range.end > excerpt_buffer_end
-                        {
-                            return None;
-                        }
+    /// Returns bracket range pairs overlapping the given `range` or returns None if the `range` is
+    /// not contained in a single excerpt
+    pub fn bracket_ranges<'a, T: ToOffset>(
+        &'a self,
+        range: Range<T>,
+    ) -> Option<impl Iterator<Item = (Range<usize>, Range<usize>)> + 'a> {
+        let range = range.start.to_offset(self)..range.end.to_offset(self);
+        let excerpt = self.excerpt_containing(range.clone());
+        excerpt.map(|(excerpt, excerpt_offset)| {
+            let excerpt_buffer_start = excerpt.range.context.start.to_offset(&excerpt.buffer);
+            let excerpt_buffer_end = excerpt_buffer_start + excerpt.text_summary.len;
 
-                        let mut start_bracket_range = start_bracket_range.clone();
-                        start_bracket_range.start =
-                            excerpt_offset + (start_bracket_range.start - excerpt_buffer_start);
-                        start_bracket_range.end =
-                            excerpt_offset + (start_bracket_range.end - excerpt_buffer_start);
-
-                        let mut end_bracket_range = end_bracket_range.clone();
-                        end_bracket_range.start =
-                            excerpt_offset + (end_bracket_range.start - excerpt_buffer_start);
-                        end_bracket_range.end =
-                            excerpt_offset + (end_bracket_range.end - excerpt_buffer_start);
-                        Some((start_bracket_range, end_bracket_range))
-                    })
-            })
+            let start_in_buffer = excerpt_buffer_start + range.start.saturating_sub(excerpt_offset);
+            let end_in_buffer = excerpt_buffer_start + range.end.saturating_sub(excerpt_offset);
+
+            excerpt
+                .buffer
+                .bracket_ranges(start_in_buffer..end_in_buffer)
+                .filter_map(move |(start_bracket_range, end_bracket_range)| {
+                    if start_bracket_range.start < excerpt_buffer_start
+                        || end_bracket_range.end > excerpt_buffer_end
+                    {
+                        return None;
+                    }
+
+                    let mut start_bracket_range = start_bracket_range.clone();
+                    start_bracket_range.start =
+                        excerpt_offset + (start_bracket_range.start - excerpt_buffer_start);
+                    start_bracket_range.end =
+                        excerpt_offset + (start_bracket_range.end - excerpt_buffer_start);
+
+                    let mut end_bracket_range = end_bracket_range.clone();
+                    end_bracket_range.start =
+                        excerpt_offset + (end_bracket_range.start - excerpt_buffer_start);
+                    end_bracket_range.end =
+                        excerpt_offset + (end_bracket_range.end - excerpt_buffer_start);
+                    Some((start_bracket_range, end_bracket_range))
+                })
+        })
     }
 
     pub fn diagnostics_update_count(&self) -> usize {
@@ -2939,6 +2964,10 @@ impl MultiBufferSnapshot {
         cursor.seek(&range.start, Bias::Right, &());
         let start_excerpt = cursor.item();
 
+        if range.start == range.end {
+            return start_excerpt.map(|excerpt| (excerpt, *cursor.start()));
+        }
+
         cursor.seek(&range.end, Bias::Right, &());
         let end_excerpt = cursor.item();
 

crates/editor/src/test/editor_lsp_test_context.rs 🔗

@@ -62,7 +62,7 @@ impl<'a> EditorLspTestContext<'a> {
         params
             .fs
             .as_fake()
-            .insert_tree("/root", json!({ "dir": { file_name: "" }}))
+            .insert_tree("/root", json!({ "dir": { file_name.clone(): "" }}))
             .await;
 
         let (window_id, workspace) = cx.add_window(|cx| {
@@ -107,7 +107,7 @@ impl<'a> EditorLspTestContext<'a> {
             },
             lsp,
             workspace,
-            buffer_lsp_url: lsp::Url::from_file_path("/root/dir/file.rs").unwrap(),
+            buffer_lsp_url: lsp::Url::from_file_path(format!("/root/dir/{file_name}")).unwrap(),
         }
     }
 
@@ -122,7 +122,33 @@ impl<'a> EditorLspTestContext<'a> {
                 ..Default::default()
             },
             Some(tree_sitter_rust::language()),
-        );
+        )
+        .with_queries(LanguageQueries {
+            indents: Some(Cow::from(indoc! {r#"
+                [
+                    ((where_clause) _ @end)
+                    (field_expression)
+                    (call_expression)
+                    (assignment_expression)
+                    (let_declaration)
+                    (let_chain)
+                    (await_expression)
+                ] @indent
+                
+                (_ "[" "]" @end) @indent
+                (_ "<" ">" @end) @indent
+                (_ "{" "}" @end) @indent
+                (_ "(" ")" @end) @indent"#})),
+            brackets: Some(Cow::from(indoc! {r#"
+                ("(" @open ")" @close)
+                ("[" @open "]" @close)
+                ("{" @open "}" @close)
+                ("<" @open ">" @close)
+                ("\"" @open "\"" @close)
+                (closure_parameters "|" @open "|" @close)"#})),
+            ..Default::default()
+        })
+        .expect("Could not parse queries");
 
         Self::new(language, capabilities, cx).await
     }
@@ -148,7 +174,7 @@ impl<'a> EditorLspTestContext<'a> {
                 ("\"" @open "\"" @close)"#})),
             ..Default::default()
         })
-        .expect("Could not parse brackets");
+        .expect("Could not parse queries");
 
         Self::new(language, capabilities, cx).await
     }

crates/language/src/buffer.rs 🔗

@@ -41,7 +41,7 @@ pub use text::{Buffer as TextBuffer, BufferSnapshot as TextBufferSnapshot, Opera
 use theme::SyntaxTheme;
 #[cfg(any(test, feature = "test-support"))]
 use util::RandomCharIter;
-use util::TryFutureExt as _;
+use util::{RangeExt, TryFutureExt as _};
 
 #[cfg(any(test, feature = "test-support"))]
 pub use {tree_sitter_rust, tree_sitter_typescript};
@@ -1389,12 +1389,12 @@ impl Buffer {
                 .enumerate()
                 .zip(&edit_operation.as_edit().unwrap().new_text)
                 .map(|((ix, (range, _)), new_text)| {
-                    let new_text_len = new_text.len();
+                    let new_text_length = new_text.len();
                     let old_start = range.start.to_point(&before_edit);
                     let new_start = (delta + range.start as isize) as usize;
-                    delta += new_text_len as isize - (range.end as isize - range.start as isize);
+                    delta += new_text_length as isize - (range.end as isize - range.start as isize);
 
-                    let mut range_of_insertion_to_indent = 0..new_text_len;
+                    let mut range_of_insertion_to_indent = 0..new_text_length;
                     let mut first_line_is_new = false;
                     let mut original_indent_column = None;
 
@@ -2358,18 +2358,18 @@ impl BufferSnapshot {
         Some(items)
     }
 
-    pub fn enclosing_bracket_ranges<'a, T: ToOffset>(
+    /// Returns bracket range pairs overlapping or adjacent to `range`
+    pub fn bracket_ranges<'a, T: ToOffset>(
         &'a self,
         range: Range<T>,
     ) -> impl Iterator<Item = (Range<usize>, Range<usize>)> + 'a {
         // Find bracket pairs that *inclusively* contain the given range.
-        let range = range.start.to_offset(self)..range.end.to_offset(self);
+        let range = range.start.to_offset(self).saturating_sub(1)
+            ..self.len().min(range.end.to_offset(self) + 1);
 
-        let mut matches = self.syntax.matches(
-            range.start.saturating_sub(1)..self.len().min(range.end + 1),
-            &self.text,
-            |grammar| grammar.brackets_config.as_ref().map(|c| &c.query),
-        );
+        let mut matches = self.syntax.matches(range.clone(), &self.text, |grammar| {
+            grammar.brackets_config.as_ref().map(|c| &c.query)
+        });
         let configs = matches
             .grammars()
             .iter()
@@ -2393,7 +2393,8 @@ impl BufferSnapshot {
 
                 let Some((open, close)) = open.zip(close) else { continue };
 
-                if open.start > range.start || close.end < range.end {
+                let bracket_range = open.start..=close.end;
+                if !bracket_range.overlaps(&range) {
                     continue;
                 }
 

crates/language/src/buffer_tests.rs 🔗

@@ -578,7 +578,7 @@ async fn test_symbols_containing(cx: &mut gpui::TestAppContext) {
 #[gpui::test]
 fn test_enclosing_bracket_ranges(cx: &mut MutableAppContext) {
     let mut assert = |selection_text, range_markers| {
-        assert_enclosing_bracket_pairs(selection_text, range_markers, rust_lang(), cx)
+        assert_bracket_pairs(selection_text, range_markers, rust_lang(), cx)
     };
 
     assert(
@@ -696,7 +696,7 @@ fn test_enclosing_bracket_ranges_where_brackets_are_not_outermost_children(
     cx: &mut MutableAppContext,
 ) {
     let mut assert = |selection_text, bracket_pair_texts| {
-        assert_enclosing_bracket_pairs(selection_text, bracket_pair_texts, javascript_lang(), cx)
+        assert_bracket_pairs(selection_text, bracket_pair_texts, javascript_lang(), cx)
     };
 
     assert(
@@ -710,6 +710,7 @@ fn test_enclosing_bracket_ranges_where_brackets_are_not_outermost_children(
         }"}],
     );
 
+    eprintln!("-----------------------");
     // Regression test: even though the parent node of the parentheses (the for loop) does
     // intersect the given range, the parentheses themselves do not contain the range, so
     // they should not be returned. Only the curly braces contain the range.
@@ -2047,7 +2048,7 @@ fn get_tree_sexp(buffer: &ModelHandle<Buffer>, cx: &gpui::TestAppContext) -> Str
 }
 
 // Assert that the enclosing bracket ranges around the selection match the pairs indicated by the marked text in `range_markers`
-fn assert_enclosing_bracket_pairs(
+fn assert_bracket_pairs(
     selection_text: &'static str,
     bracket_pair_texts: Vec<&'static str>,
     language: Language,
@@ -2072,9 +2073,7 @@ fn assert_enclosing_bracket_pairs(
         .collect::<Vec<_>>();
 
     assert_set_eq!(
-        buffer
-            .enclosing_bracket_ranges(selection_range)
-            .collect::<Vec<_>>(),
+        buffer.bracket_ranges(selection_range).collect::<Vec<_>>(),
         bracket_pairs
     );
 }

crates/util/Cargo.toml 🔗

@@ -5,6 +5,7 @@ edition = "2021"
 publish = false
 
 [lib]
+path = "src/util.rs"
 doctest = false
 
 [features]
@@ -22,7 +23,6 @@ serde_json = { version = "1.0", features = ["preserve_order"], optional = true }
 git2 = { version = "0.15", default-features = false, optional = true }
 dirs = "3.0"
 
-
 [dev-dependencies]
 tempdir = { version = "0.3.7" }
 serde_json = { version = "1.0", features = ["preserve_order"] }

crates/util/src/lib.rs → crates/util/src/util.rs 🔗

@@ -3,16 +3,17 @@ pub mod paths;
 #[cfg(any(test, feature = "test-support"))]
 pub mod test;
 
-pub use backtrace::Backtrace;
-use futures::Future;
-use rand::{seq::SliceRandom, Rng};
 use std::{
-    cmp::Ordering,
-    ops::AddAssign,
+    cmp::{self, Ordering},
+    ops::{AddAssign, Range, RangeInclusive},
     pin::Pin,
     task::{Context, Poll},
 };
 
+pub use backtrace::Backtrace;
+use futures::Future;
+use rand::{seq::SliceRandom, Rng};
+
 #[derive(Debug, Default)]
 pub struct StaffMode(pub bool);
 
@@ -245,6 +246,46 @@ macro_rules! async_iife {
     };
 }
 
+pub trait RangeExt<T> {
+    fn sorted(&self) -> Self;
+    fn to_inclusive(&self) -> RangeInclusive<T>;
+    fn overlaps(&self, other: &Range<T>) -> bool;
+}
+
+impl<T: Ord + Clone> RangeExt<T> for Range<T> {
+    fn sorted(&self) -> Self {
+        cmp::min(&self.start, &self.end).clone()..cmp::max(&self.start, &self.end).clone()
+    }
+
+    fn to_inclusive(&self) -> RangeInclusive<T> {
+        self.start.clone()..=self.end.clone()
+    }
+
+    fn overlaps(&self, other: &Range<T>) -> bool {
+        self.contains(&other.start)
+            || self.contains(&other.end)
+            || other.contains(&self.start)
+            || other.contains(&self.end)
+    }
+}
+
+impl<T: Ord + Clone> RangeExt<T> for RangeInclusive<T> {
+    fn sorted(&self) -> Self {
+        cmp::min(self.start(), self.end()).clone()..=cmp::max(self.start(), self.end()).clone()
+    }
+
+    fn to_inclusive(&self) -> RangeInclusive<T> {
+        self.clone()
+    }
+
+    fn overlaps(&self, other: &Range<T>) -> bool {
+        self.contains(&other.start)
+            || self.contains(&other.end)
+            || other.contains(&self.start())
+            || other.contains(&self.end())
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;

crates/vim/src/motion.rs 🔗

@@ -3,7 +3,7 @@ use std::sync::Arc;
 use editor::{
     char_kind,
     display_map::{DisplaySnapshot, ToDisplayPoint},
-    movement, Bias, CharKind, DisplayPoint,
+    movement, Bias, CharKind, DisplayPoint, ToOffset,
 };
 use gpui::{actions, impl_actions, MutableAppContext};
 use language::{Point, Selection, SelectionGoal};
@@ -450,19 +450,53 @@ fn end_of_document(map: &DisplaySnapshot, point: DisplayPoint, line: usize) -> D
     map.clip_point(new_point, Bias::Left)
 }
 
-fn matching(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
-    let offset = point.to_offset(map, Bias::Left);
-    if let Some((open_range, close_range)) = map
-        .buffer_snapshot
-        .innermost_enclosing_bracket_ranges(offset..offset)
-    {
-        if open_range.contains(&offset) {
-            close_range.start.to_display_point(map)
-        } else {
-            open_range.start.to_display_point(map)
+fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint {
+    // https://github.com/vim/vim/blob/1d87e11a1ef201b26ed87585fba70182ad0c468a/runtime/doc/motion.txt#L1200
+    let point = display_point.to_point(map);
+    let offset = point.to_offset(&map.buffer_snapshot);
+
+    // Ensure the range is contained by the current line.
+    let mut line_end = map.next_line_boundary(point).0;
+    if line_end == point {
+        line_end = map.max_point().to_point(map);
+    }
+    line_end.column = line_end.column.saturating_sub(1);
+
+    let line_range = map.prev_line_boundary(point).0..line_end;
+    let ranges = map.buffer_snapshot.bracket_ranges(line_range.clone());
+    if let Some(ranges) = ranges {
+        let line_range = line_range.start.to_offset(&map.buffer_snapshot)
+            ..line_range.end.to_offset(&map.buffer_snapshot);
+        let mut closest_pair_destination = None;
+        let mut closest_distance = usize::MAX;
+
+        for (open_range, close_range) in ranges {
+            if open_range.start >= offset && line_range.contains(&open_range.start) {
+                let distance = open_range.start - offset;
+                if distance < closest_distance {
+                    closest_pair_destination = Some(close_range.start);
+                    closest_distance = distance;
+                    continue;
+                }
+            }
+
+            if close_range.start >= offset && line_range.contains(&close_range.start) {
+                let distance = close_range.start - offset;
+                if distance < closest_distance {
+                    closest_pair_destination = Some(open_range.start);
+                    closest_distance = distance;
+                    continue;
+                }
+            }
+
+            continue;
         }
+
+        closest_pair_destination
+            .map(|destination| destination.to_display_point(map))
+            .unwrap_or(display_point)
     } else {
-        point
+        display_point
     }
 }
 

crates/vim/src/normal.rs 🔗

@@ -824,17 +824,34 @@ mod test {
                 ˇ
                 brown fox"})
             .await;
-        cx.assert(indoc! {"
+
+        cx.assert_manual(
+            indoc! {"
                 fn test() {
                     println!(ˇ);
-                }
-            "})
-            .await;
-        cx.assert(indoc! {"
+                }"},
+            Mode::Normal,
+            indoc! {"
+                fn test() {
+                    println!();
+                    ˇ
+                }"},
+            Mode::Insert,
+        );
+
+        cx.assert_manual(
+            indoc! {"
                 fn test(ˇ) {
                     println!();
-                }"})
-            .await;
+                }"},
+            Mode::Normal,
+            indoc! {"
+                fn test() {
+                    ˇ
+                    println!();
+                }"},
+            Mode::Insert,
+        );
     }
 
     #[gpui::test]
@@ -857,13 +874,15 @@ mod test {
         // Our indentation is smarter than vims. So we don't match here
         cx.assert_manual(
             indoc! {"
-                fn test()
-                    println!(ˇ);"},
+                fn test() {
+                    println!(ˇ);
+                }"},
             Mode::Normal,
             indoc! {"
-                fn test()
+                fn test() {
                     ˇ
-                    println!();"},
+                    println!();
+                }"},
             Mode::Insert,
         );
         cx.assert_manual(
@@ -994,14 +1013,14 @@ mod test {
     #[gpui::test]
     async fn test_capital_f_and_capital_t(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new(cx).await;
-        for count in 1..=3 {
-            let test_case = indoc! {"
-                ˇaaaˇbˇ ˇbˇ   ˇbˇbˇ aˇaaˇbaaa
-                ˇ    ˇbˇaaˇa ˇbˇbˇb
-                ˇ   
-                ˇb
+        let test_case = indoc! {"
+            ˇaaaˇbˇ ˇbˇ   ˇbˇbˇ aˇaaˇbaaa
+            ˇ    ˇbˇaaˇa ˇbˇbˇb
+            ˇ   
+            ˇb
             "};
 
+        for count in 1..=3 {
             cx.assert_binding_matches_all([&count.to_string(), "shift-f", "b"], test_case)
                 .await;
 
@@ -1009,4 +1028,13 @@ mod test {
                 .await;
         }
     }
+
+    #[gpui::test]
+    async fn test_percent(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["%"]);
+        cx.assert_all("ˇconsole.logˇ(ˇvaˇrˇ)ˇ;").await;
+        cx.assert_all("ˇconsole.logˇ(ˇ'var', ˇ[ˇ1, ˇ2, 3ˇ]ˇ)ˇ;")
+            .await;
+        cx.assert_all("let result = curried_funˇ(ˇ)ˇ(ˇ)ˇ;").await;
+    }
 }

crates/vim/src/test/vim_test_context.rs 🔗

@@ -1,33 +1,29 @@
 use std::ops::{Deref, DerefMut};
 
-use editor::test::editor_test_context::EditorTestContext;
-use gpui::{json::json, AppContext, ContextHandle, ViewHandle};
-use project::Project;
+use editor::test::{
+    editor_lsp_test_context::EditorLspTestContext, editor_test_context::EditorTestContext,
+};
+use gpui::{AppContext, ContextHandle};
 use search::{BufferSearchBar, ProjectSearchBar};
-use workspace::{pane, AppState, WorkspaceHandle};
 
 use crate::{state::Operator, *};
 
 use super::VimBindingTestContext;
 
 pub struct VimTestContext<'a> {
-    cx: EditorTestContext<'a>,
-    workspace: ViewHandle<Workspace>,
+    cx: EditorLspTestContext<'a>,
 }
 
 impl<'a> VimTestContext<'a> {
     pub async fn new(cx: &'a mut gpui::TestAppContext, enabled: bool) -> VimTestContext<'a> {
         cx.update(|cx| {
-            editor::init(cx);
-            pane::init(cx);
             search::init(cx);
             crate::init(cx);
 
             settings::KeymapFileContent::load("keymaps/vim.json", cx).unwrap();
         });
 
-        let params = cx.update(AppState::test);
-        let project = Project::test(params.fs.clone(), [], cx).await;
+        let mut cx = EditorLspTestContext::new_rust(Default::default(), cx).await;
 
         cx.update(|cx| {
             cx.update_global(|settings: &mut Settings, _| {
@@ -35,24 +31,10 @@ impl<'a> VimTestContext<'a> {
             });
         });
 
-        params
-            .fs
-            .as_fake()
-            .insert_tree("/root", json!({ "dir": { "test.txt": "" } }))
-            .await;
-
-        let (window_id, workspace) = cx.add_window(|cx| {
-            Workspace::new(
-                Default::default(),
-                0,
-                project.clone(),
-                |_, _| unimplemented!(),
-                cx,
-            )
-        });
+        let window_id = cx.window_id;
 
         // Setup search toolbars and keypress hook
-        workspace.update(cx, |workspace, cx| {
+        cx.update_workspace(|workspace, cx| {
             observe_keystrokes(window_id, cx);
             workspace.active_pane().update(cx, |pane, cx| {
                 pane.toolbar().update(cx, |toolbar, cx| {
@@ -64,44 +46,14 @@ impl<'a> VimTestContext<'a> {
             });
         });
 
-        project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/root", true, cx)
-            })
-            .await
-            .unwrap();
-        cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
-            .await;
-
-        let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone());
-        let item = workspace
-            .update(cx, |workspace, cx| {
-                workspace.open_path(file, None, true, cx)
-            })
-            .await
-            .expect("Could not open test file");
-
-        let editor = cx.update(|cx| {
-            item.act_as::<Editor>(cx)
-                .expect("Opened test file wasn't an editor")
-        });
-        editor.update(cx, |_, cx| cx.focus_self());
-
-        Self {
-            cx: EditorTestContext {
-                cx,
-                window_id,
-                editor,
-            },
-            workspace,
-        }
+        Self { cx }
     }
 
     pub fn workspace<F, T>(&mut self, read: F) -> T
     where
         F: FnOnce(&Workspace, &AppContext) -> T,
     {
-        self.workspace.read_with(self.cx.cx, read)
+        self.cx.workspace.read_with(self.cx.cx.cx, read)
     }
 
     pub fn enable_vim(&mut self) {

crates/vim/test_data/test_o.json 🔗

@@ -1 +1 @@
-[{"Text":"\n"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\n"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\n\nbrown fox\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox\n\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox\njumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[3,0],"end":[3,0]}},{"Mode":"Insert"},{"Text":"The quick\n\n\nbrown fox"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"fn test() {\n    println!();\n    \n}\n"},{"Mode":"Insert"},{"Selection":{"start":[2,4],"end":[2,4]}},{"Mode":"Insert"},{"Text":"fn test() {\n\n    println!();\n}"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"}]
+[{"Text":"\n"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\n"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\n\nbrown fox\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox\n\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox\njumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[3,0],"end":[3,0]}},{"Mode":"Insert"},{"Text":"The quick\n\n\nbrown fox"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"}]

crates/workspace/src/workspace.rs 🔗

@@ -1330,7 +1330,19 @@ impl Workspace {
         focus_item: bool,
         cx: &mut ViewContext<Self>,
     ) -> Task<Result<Box<dyn ItemHandle>, anyhow::Error>> {
-        let pane = pane.unwrap_or_else(|| self.active_pane().downgrade());
+        let pane = pane.unwrap_or_else(|| {
+            if !self.dock_active() {
+                self.active_pane().downgrade()
+            } else {
+                self.last_active_center_pane.clone().unwrap_or_else(|| {
+                    self.panes
+                        .first()
+                        .expect("There must be an active pane")
+                        .downgrade()
+                })
+            }
+        });
+
         let task = self.load_path(path.into(), cx);
         cx.spawn(|this, mut cx| async move {
             let (project_entry_id, build_item) = task.await?;
@@ -1637,6 +1649,10 @@ impl Workspace {
         self.dock.pane()
     }
 
+    fn dock_active(&self) -> bool {
+        &self.active_pane == self.dock.pane()
+    }
+
     fn project_remote_id_changed(&mut self, remote_id: Option<u64>, cx: &mut ViewContext<Self>) {
         if let Some(remote_id) = remote_id {
             self.remote_entity_subscription =