vim: Add basic mark support (#11507)

Zachiah Sawyer and Conrad Irwin created

Release Notes:
- vim: Added support for buffer-local marks (`'a-'z`) and some builtin
marks `'<`,`'>`,`'[`,`']`, `'{`, `'}` and `^`. Global marks (`'A-'Z`),
and other builtin marks (`'0-'9`, `'(`, `')`, `''`, `'.`, `'"`) are not
yet implemented. (#5122)

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

assets/keymaps/vim.json                        |   3 
crates/vim/src/motion.rs                       |  13 +
crates/vim/src/normal.rs                       |   1 
crates/vim/src/normal/mark.rs                  | 147 ++++++++++++++++++++
crates/vim/src/state.rs                        |  14 +
crates/vim/src/test.rs                         | 130 +++++++++++++++++
crates/vim/src/utils.rs                        |  18 ++
crates/vim/src/vim.rs                          |  27 +++
crates/vim/test_data/test_builtin_marks.json   |  36 ++++
crates/vim/test_data/test_caret_mark.json      |  26 +++
crates/vim/test_data/test_lowercase_marks.json |  15 ++
crates/vim/test_data/test_lt_gt_marks.json     |  18 ++
crates/vim/test_data/test_marks.json           |  15 ++
crates/vim/test_data/test_period_mark.json     |  14 +
14 files changed, 471 insertions(+), 6 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -117,6 +117,9 @@
           }
         }
       ],
+      "m": ["vim::PushOperator", "Mark"],
+      "'": ["vim::PushOperator", { "Jump": { "line": true } }],
+      "`": ["vim::PushOperator", { "Jump": { "line": false } }],
       ";": "vim::RepeatFind",
       ",": "vim::RepeatFindReversed",
       "ctrl-o": "pane::GoBack",

crates/vim/src/motion.rs 🔗

@@ -13,7 +13,7 @@ use std::ops::Range;
 use workspace::Workspace;
 
 use crate::{
-    normal::normal_motion,
+    normal::{mark, normal_motion},
     state::{Mode, Operator},
     surrounds::SurroundsType,
     utils::coerce_punctuation,
@@ -105,6 +105,10 @@ pub enum Motion {
         prior_selections: Vec<Range<Anchor>>,
         new_selections: Vec<Range<Anchor>>,
     },
+    Jump {
+        anchor: Anchor,
+        line: bool,
+    },
 }
 
 #[derive(Clone, Deserialize, PartialEq)]
@@ -469,6 +473,7 @@ impl Motion {
             | WindowTop
             | WindowMiddle
             | WindowBottom
+            | Jump { line: true, .. }
             | EndOfParagraph => true,
             EndOfLine { .. }
             | Matching
@@ -492,6 +497,7 @@ impl Motion {
             | FindBackward { .. }
             | RepeatFind { .. }
             | RepeatFindReversed { .. }
+            | Jump { line: false, .. }
             | ZedSearchResult { .. } => false,
         }
     }
@@ -531,7 +537,8 @@ impl Motion {
             | WindowMiddle
             | WindowBottom
             | NextLineStart
-            | ZedSearchResult { .. } => false,
+            | ZedSearchResult { .. }
+            | Jump { .. } => false,
         }
     }
 
@@ -570,6 +577,7 @@ impl Motion {
             | PreviousSubwordStart { .. }
             | FirstNonWhitespace { .. }
             | FindBackward { .. }
+            | Jump { .. }
             | ZedSearchResult { .. } => false,
             RepeatFind { last_find: motion } | RepeatFindReversed { last_find: motion } => {
                 motion.inclusive()
@@ -761,6 +769,7 @@ impl Motion {
             WindowTop => window_top(map, point, &text_layout_details, times - 1),
             WindowMiddle => window_middle(map, point, &text_layout_details),
             WindowBottom => window_bottom(map, point, &text_layout_details, times - 1),
+            Jump { line, anchor } => mark::jump_motion(map, *anchor, *line),
             ZedSearchResult { new_selections, .. } => {
                 // There will be only one selection, as
                 // Search::SelectNextMatch selects a single match.

crates/vim/src/normal.rs 🔗

@@ -2,6 +2,7 @@ mod case;
 mod change;
 mod delete;
 mod increment;
+pub(crate) mod mark;
 mod paste;
 pub(crate) mod repeat;
 mod scroll;

crates/vim/src/normal/mark.rs 🔗

@@ -0,0 +1,147 @@
+use std::{ops::Range, sync::Arc};
+
+use editor::{
+    display_map::{DisplaySnapshot, ToDisplayPoint},
+    movement,
+    scroll::Autoscroll,
+    Anchor, Bias, DisplayPoint,
+};
+use gpui::WindowContext;
+use language::SelectionGoal;
+
+use crate::{
+    motion::{self, Motion},
+    Vim,
+};
+
+pub fn create_mark(vim: &mut Vim, text: Arc<str>, tail: bool, cx: &mut WindowContext) {
+    let Some(anchors) = vim.update_active_editor(cx, |_, editor, _| {
+        editor
+            .selections
+            .disjoint_anchors()
+            .iter()
+            .map(|s| if tail { s.tail() } else { s.head() })
+            .collect::<Vec<_>>()
+    }) else {
+        return;
+    };
+    vim.update_state(|state| state.marks.insert(text.to_string(), anchors));
+    vim.clear_operator(cx);
+}
+
+pub fn create_mark_after(vim: &mut Vim, text: Arc<str>, cx: &mut WindowContext) {
+    let Some(anchors) = vim.update_active_editor(cx, |_, editor, cx| {
+        let (map, selections) = editor.selections.all_display(cx);
+        selections
+            .into_iter()
+            .map(|selection| {
+                let point = movement::saturating_right(&map, selection.tail());
+                map.buffer_snapshot
+                    .anchor_before(point.to_offset(&map, Bias::Left))
+            })
+            .collect::<Vec<_>>()
+    }) else {
+        return;
+    };
+
+    vim.update_state(|state| state.marks.insert(text.to_string(), anchors));
+    vim.clear_operator(cx);
+}
+
+pub fn create_mark_before(vim: &mut Vim, text: Arc<str>, cx: &mut WindowContext) {
+    let Some(anchors) = vim.update_active_editor(cx, |_, editor, cx| {
+        let (map, selections) = editor.selections.all_display(cx);
+        selections
+            .into_iter()
+            .map(|selection| {
+                let point = movement::saturating_left(&map, selection.head());
+                map.buffer_snapshot
+                    .anchor_before(point.to_offset(&map, Bias::Left))
+            })
+            .collect::<Vec<_>>()
+    }) else {
+        return;
+    };
+
+    vim.update_state(|state| state.marks.insert(text.to_string(), anchors));
+    vim.clear_operator(cx);
+}
+
+pub fn jump(text: Arc<str>, line: bool, cx: &mut WindowContext) {
+    let anchors = match &*text {
+        "{" | "}" => Vim::update(cx, |vim, cx| {
+            vim.update_active_editor(cx, |_, editor, cx| {
+                let (map, selections) = editor.selections.all_display(cx);
+                selections
+                    .into_iter()
+                    .map(|selection| {
+                        let point = if &*text == "{" {
+                            movement::start_of_paragraph(&map, selection.head(), 1)
+                        } else {
+                            movement::end_of_paragraph(&map, selection.head(), 1)
+                        };
+                        map.buffer_snapshot
+                            .anchor_before(point.to_offset(&map, Bias::Left))
+                    })
+                    .collect::<Vec<Anchor>>()
+            })
+        }),
+        _ => Vim::read(cx).state().marks.get(&*text).cloned(),
+    };
+
+    Vim::update(cx, |vim, cx| {
+        vim.pop_operator(cx);
+    });
+
+    let Some(anchors) = anchors else { return };
+
+    let is_active_operator = Vim::read(cx).state().active_operator().is_some();
+    if is_active_operator {
+        if let Some(anchor) = anchors.last() {
+            motion::motion(
+                Motion::Jump {
+                    anchor: *anchor,
+                    line,
+                },
+                cx,
+            )
+        }
+        return;
+    } else {
+        Vim::update(cx, |vim, cx| {
+            vim.update_active_editor(cx, |_, editor, cx| {
+                let map = editor.snapshot(cx);
+                let mut ranges: Vec<Range<Anchor>> = Vec::new();
+                for mut anchor in anchors {
+                    if line {
+                        let mut point = anchor.to_display_point(&map.display_snapshot);
+                        point = motion::first_non_whitespace(&map.display_snapshot, false, point);
+                        anchor = map
+                            .display_snapshot
+                            .buffer_snapshot
+                            .anchor_before(point.to_point(&map.display_snapshot));
+                    }
+                    if ranges.last() != Some(&(anchor..anchor)) {
+                        ranges.push(anchor..anchor);
+                    }
+                }
+                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+                    s.select_anchor_ranges(ranges)
+                })
+            });
+        })
+    }
+}
+
+pub fn jump_motion(
+    map: &DisplaySnapshot,
+    anchor: Anchor,
+    line: bool,
+) -> (DisplayPoint, SelectionGoal) {
+    let mut point = anchor.to_display_point(map);
+    if line {
+        point = motion::first_non_whitespace(map, false, point)
+    }
+
+    (point, SelectionGoal::None)
+}

crates/vim/src/state.rs 🔗

@@ -59,6 +59,8 @@ pub enum Operator {
     AddSurrounds { target: Option<SurroundsType> },
     ChangeSurrounds { target: Option<Object> },
     DeleteSurrounds,
+    Mark,
+    Jump { line: bool },
 }
 
 #[derive(Default, Clone)]
@@ -74,6 +76,8 @@ pub struct EditorState {
     pub operator_stack: Vec<Operator>,
     pub replacements: Vec<(Range<editor::Anchor>, String)>,
 
+    pub marks: HashMap<String, Vec<Anchor>>,
+
     pub current_tx: Option<TransactionId>,
     pub current_anchor: Option<Selection<Anchor>>,
     pub undo_modes: HashMap<TransactionId, Mode>,
@@ -172,7 +176,10 @@ impl EditorState {
         }
         matches!(
             self.operator_stack.last(),
-            Some(Operator::FindForward { .. }) | Some(Operator::FindBackward { .. })
+            Some(Operator::FindForward { .. })
+                | Some(Operator::FindBackward { .. })
+                | Some(Operator::Mark)
+                | Some(Operator::Jump { .. })
         )
     }
 
@@ -254,6 +261,9 @@ impl Operator {
             Operator::AddSurrounds { .. } => "ys",
             Operator::ChangeSurrounds { .. } => "cs",
             Operator::DeleteSurrounds => "ds",
+            Operator::Mark => "m",
+            Operator::Jump { line: true } => "'",
+            Operator::Jump { line: false } => "`",
         }
     }
 
@@ -261,6 +271,8 @@ impl Operator {
         match self {
             Operator::Object { .. } | Operator::ChangeSurrounds { target: None } => &["VimObject"],
             Operator::FindForward { .. }
+            | Operator::Mark
+            | Operator::Jump { .. }
             | Operator::FindBackward { .. }
             | Operator::Replace
             | Operator::AddSurrounds { target: Some(_) }

crates/vim/src/test.rs 🔗

@@ -1073,3 +1073,133 @@ async fn test_mouse_selection(cx: &mut TestAppContext) {
 
     cx.assert_state("one «ˇtwo» three", Mode::Visual)
 }
+
+#[gpui::test]
+async fn test_lowercase_marks(cx: &mut TestAppContext) {
+    let mut cx = NeovimBackedTestContext::new(cx).await;
+
+    cx.set_shared_state("line one\nline ˇtwo\nline three").await;
+    cx.simulate_shared_keystrokes(["m", "a", "l", "'", "a"])
+        .await;
+    cx.assert_shared_state("line one\nˇline two\nline three")
+        .await;
+    cx.simulate_shared_keystrokes(["`", "a"]).await;
+    cx.assert_shared_state("line one\nline ˇtwo\nline three")
+        .await;
+
+    cx.simulate_shared_keystrokes(["^", "d", "`", "a"]).await;
+    cx.assert_shared_state("line one\nˇtwo\nline three").await;
+}
+
+#[gpui::test]
+async fn test_lt_gt_marks(cx: &mut TestAppContext) {
+    let mut cx = NeovimBackedTestContext::new(cx).await;
+
+    cx.set_shared_state(indoc!(
+        "
+        Line one
+        Line two
+        Line ˇthree
+        Line four
+        Line five
+    "
+    ))
+    .await;
+
+    cx.simulate_shared_keystrokes(["v", "j", "escape", "k", "k"])
+        .await;
+
+    cx.simulate_shared_keystrokes(["'", "<"]).await;
+    cx.assert_shared_state(indoc!(
+        "
+        Line one
+        Line two
+        ˇLine three
+        Line four
+        Line five
+    "
+    ))
+    .await;
+
+    cx.simulate_shared_keystrokes(["`", "<"]).await;
+    cx.assert_shared_state(indoc!(
+        "
+        Line one
+        Line two
+        Line ˇthree
+        Line four
+        Line five
+    "
+    ))
+    .await;
+
+    cx.simulate_shared_keystrokes(["'", ">"]).await;
+    cx.assert_shared_state(indoc!(
+        "
+        Line one
+        Line two
+        Line three
+        ˇLine four
+        Line five
+    "
+    ))
+    .await;
+
+    cx.simulate_shared_keystrokes(["`", ">"]).await;
+    cx.assert_shared_state(indoc!(
+        "
+        Line one
+        Line two
+        Line three
+        Line ˇfour
+        Line five
+    "
+    ))
+    .await;
+}
+
+#[gpui::test]
+async fn test_caret_mark(cx: &mut TestAppContext) {
+    let mut cx = NeovimBackedTestContext::new(cx).await;
+
+    cx.set_shared_state(indoc!(
+        "
+        Line one
+        Line two
+        Line three
+        ˇLine four
+        Line five
+    "
+    ))
+    .await;
+
+    cx.simulate_shared_keystrokes([
+        "c", "w", "shift-s", "t", "r", "a", "i", "g", "h", "t", " ", "t", "h", "i", "n", "g",
+        "escape", "j", "j",
+    ])
+    .await;
+
+    cx.simulate_shared_keystrokes(["'", "^"]).await;
+    cx.assert_shared_state(indoc!(
+        "
+        Line one
+        Line two
+        Line three
+        ˇStraight thing four
+        Line five
+    "
+    ))
+    .await;
+
+    cx.simulate_shared_keystrokes(["`", "^"]).await;
+    cx.assert_shared_state(indoc!(
+        "
+        Line one
+        Line two
+        Line three
+        Straight thingˇ four
+        Line five
+    "
+    ))
+    .await;
+}

crates/vim/src/utils.rs 🔗

@@ -39,6 +39,24 @@ fn copy_selections_content_internal(
     let mut text = String::new();
     let mut clipboard_selections = Vec::with_capacity(selections.len());
     let mut ranges_to_highlight = Vec::new();
+
+    vim.update_state(|state| {
+        state.marks.insert(
+            "[".to_string(),
+            selections
+                .iter()
+                .map(|s| buffer.anchor_before(s.start))
+                .collect(),
+        );
+        state.marks.insert(
+            "]".to_string(),
+            selections
+                .iter()
+                .map(|s| buffer.anchor_after(s.end))
+                .collect(),
+        )
+    });
+
     {
         let mut is_first = true;
         for selection in selections.iter() {

crates/vim/src/vim.rs 🔗

@@ -30,7 +30,10 @@ use gpui::{
 use language::{CursorShape, Point, SelectionGoal, TransactionId};
 pub use mode_indicator::ModeIndicator;
 use motion::Motion;
-use normal::normal_replace;
+use normal::{
+    mark::{create_mark, create_mark_after, create_mark_before},
+    normal_replace,
+};
 use replace::multi_replace;
 use schemars::JsonSchema;
 use serde::Deserialize;
@@ -194,7 +197,9 @@ fn observe_keystrokes(keystroke_event: &KeystrokeEvent, cx: &mut WindowContext)
             | Operator::Replace
             | Operator::AddSurrounds { .. }
             | Operator::ChangeSurrounds { .. }
-            | Operator::DeleteSurrounds,
+            | Operator::DeleteSurrounds
+            | Operator::Mark
+            | Operator::Jump { .. },
         ) => {}
         Some(_) => {
             vim.clear_operator(cx);
@@ -418,6 +423,10 @@ impl Vim {
         // Sync editor settings like clip mode
         self.sync_vim_settings(cx);
 
+        if mode != Mode::Insert && last_mode == Mode::Insert {
+            create_mark_after(self, "^".into(), cx)
+        }
+
         if leave_selections {
             return;
         }
@@ -614,6 +623,7 @@ impl Vim {
         let is_multicursor = editor.read(cx).selections.count() > 1;
 
         let state = self.state();
+        let mut is_visual = state.mode.is_visual();
         if state.mode == Mode::Insert && state.current_tx.is_some() {
             if state.current_anchor.is_none() {
                 self.update_state(|state| state.current_anchor = Some(newest));
@@ -630,11 +640,18 @@ impl Vim {
             } else {
                 self.switch_mode(Mode::Visual, false, cx)
             }
+            is_visual = true;
         } else if newest.start == newest.end
             && !is_multicursor
             && [Mode::Visual, Mode::VisualLine, Mode::VisualBlock].contains(&state.mode)
         {
-            self.switch_mode(Mode::Normal, true, cx)
+            self.switch_mode(Mode::Normal, true, cx);
+            is_visual = false;
+        }
+
+        if is_visual {
+            create_mark_before(self, ">".into(), cx);
+            create_mark(self, "<".into(), true, cx)
         }
     }
 
@@ -706,6 +723,10 @@ impl Vim {
                 }
                 _ => Vim::update(cx, |vim, cx| vim.clear_operator(cx)),
             },
+            Some(Operator::Mark) => Vim::update(cx, |vim, cx| {
+                normal::mark::create_mark(vim, text, false, cx)
+            }),
+            Some(Operator::Jump { line }) => normal::mark::jump(text, line, cx),
             _ => match Vim::read(cx).state().mode {
                 Mode::Replace => multi_replace(text, cx),
                 _ => {}

crates/vim/test_data/test_builtin_marks.json 🔗

@@ -0,0 +1,36 @@
+{"Put":{"state":"Line one\nLine two\nLine ˇthree\nLine four\nLine five\n"}}
+{"Key":"v"}
+{"Key":"j"}
+{"Key":"escape"}
+{"Key":"k"}
+{"Key":"k"}
+{"Key":"'"}
+{"Key":"<"}
+{"Get":{"state":"Line one\nLine two\nˇLine three\nLine four\nLine five\n","mode":"Normal"}}
+{"Key":"`"}
+{"Key":"<"}
+{"Get":{"state":"Line one\nLine two\nLine ˇthree\nLine four\nLine five\n","mode":"Normal"}}
+{"Key":"'"}
+{"Key":">"}
+{"Get":{"state":"Line one\nLine two\nLine three\nˇLine four\nLine five\n","mode":"Normal"}}
+{"Key":"`"}
+{"Key":">"}
+{"Get":{"state":"Line one\nLine two\nLine three\nLine ˇfour\nLine five\n","mode":"Normal"}}
+{"Key":"g"}
+{"Key":"g"}
+{"Key":"^"}
+{"Key":"j"}
+{"Key":"j"}
+{"Key":"l"}
+{"Key":"l"}
+{"Key":"c"}
+{"Key":"e"}
+{"Key":"k"}
+{"Key":"e"}
+{"Key":"escape"}
+{"Key":"'"}
+{"Key":"."}
+{"Get":{"state":"Line one\nLine two\nˇLike three\nLine four\nLine five\n","mode":"Normal"}}
+{"Key":"`"}
+{"Key":"."}
+{"Get":{"state":"Line one\nLine two\nLiˇke three\nLine four\nLine five\n","mode":"Normal"}}

crates/vim/test_data/test_caret_mark.json 🔗

@@ -0,0 +1,26 @@
+{"Put":{"state":"Line one\nLine two\nLine three\nˇLine four\nLine five\n"}}
+{"Key":"c"}
+{"Key":"w"}
+{"Key":"shift-s"}
+{"Key":"t"}
+{"Key":"r"}
+{"Key":"a"}
+{"Key":"i"}
+{"Key":"g"}
+{"Key":"h"}
+{"Key":"t"}
+{"Key":" "}
+{"Key":"t"}
+{"Key":"h"}
+{"Key":"i"}
+{"Key":"n"}
+{"Key":"g"}
+{"Key":"escape"}
+{"Key":"j"}
+{"Key":"j"}
+{"Key":"'"}
+{"Key":"^"}
+{"Get":{"state":"Line one\nLine two\nLine three\nˇStraight thing four\nLine five\n","mode":"Normal"}}
+{"Key":"`"}
+{"Key":"^"}
+{"Get":{"state":"Line one\nLine two\nLine three\nStraight thingˇ four\nLine five\n","mode":"Normal"}}

crates/vim/test_data/test_lowercase_marks.json 🔗

@@ -0,0 +1,15 @@
+{"Put":{"state":"line one\nline ˇtwo\nline three"}}
+{"Key":"m"}
+{"Key":"a"}
+{"Key":"l"}
+{"Key":"'"}
+{"Key":"a"}
+{"Get":{"state":"line one\nˇline two\nline three","mode":"Normal"}}
+{"Key":"`"}
+{"Key":"a"}
+{"Get":{"state":"line one\nline ˇtwo\nline three","mode":"Normal"}}
+{"Key":"^"}
+{"Key":"d"}
+{"Key":"`"}
+{"Key":"a"}
+{"Get":{"state":"line one\nˇtwo\nline three","mode":"Normal"}}

crates/vim/test_data/test_lt_gt_marks.json 🔗

@@ -0,0 +1,18 @@
+{"Put":{"state":"Line one\nLine two\nLine ˇthree\nLine four\nLine five\n"}}
+{"Key":"v"}
+{"Key":"j"}
+{"Key":"escape"}
+{"Key":"k"}
+{"Key":"k"}
+{"Key":"'"}
+{"Key":"<"}
+{"Get":{"state":"Line one\nLine two\nˇLine three\nLine four\nLine five\n","mode":"Normal"}}
+{"Key":"`"}
+{"Key":"<"}
+{"Get":{"state":"Line one\nLine two\nLine ˇthree\nLine four\nLine five\n","mode":"Normal"}}
+{"Key":"'"}
+{"Key":">"}
+{"Get":{"state":"Line one\nLine two\nLine three\nˇLine four\nLine five\n","mode":"Normal"}}
+{"Key":"`"}
+{"Key":">"}
+{"Get":{"state":"Line one\nLine two\nLine three\nLine ˇfour\nLine five\n","mode":"Normal"}}

crates/vim/test_data/test_marks.json 🔗

@@ -0,0 +1,15 @@
+{"Put":{"state":"line one\nline ˇtwo\nline three"}}
+{"Key":"m"}
+{"Key":"a"}
+{"Key":"l"}
+{"Key":"'"}
+{"Key":"a"}
+{"Get":{"state":"line one\nˇline two\nline three","mode":"Normal"}}
+{"Key":"`"}
+{"Key":"a"}
+{"Get":{"state":"line one\nline ˇtwo\nline three","mode":"Normal"}}
+{"Key":"^"}
+{"Key":"d"}
+{"Key":"`"}
+{"Key":"a"}
+{"Get":{"state":"line one\nˇtwo\nline three","mode":"Normal"}}

crates/vim/test_data/test_period_mark.json 🔗

@@ -0,0 +1,14 @@
+{"Put":{"state":"Line one\nLine two\nLiˇne three\nLine four\nLine five\n"}}
+{"Key":"c"}
+{"Key":"e"}
+{"Key":"k"}
+{"Key":"e"}
+{"Key":"escape"}
+{"Key":"j"}
+{"Key":"j"}
+{"Key":"'"}
+{"Key":"."}
+{"Get":{"state":"Line one\nLine two\nˇLike three\nLine four\nLine five\n","mode":"Normal"}}
+{"Key":"`"}
+{"Key":"."}
+{"Get":{"state":"Line one\nLine two\nLiˇke three\nLine four\nLine five\n","mode":"Normal"}}