helix: Add match operator (#34060)

fantacell and Conrad Irwin created

This is an implementation of matching like "m i (", as well as "] (" and
"[ (" in `helix_mode` with a few supported objects and a basis for more.

Release Notes:

- Added helix operators for selecting text objects

---------

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

Change summary

assets/keymaps/vim.json          | 137 +++--
crates/editor/src/movement.rs    |  33 
crates/vim/src/helix.rs          |   4 
crates/vim/src/helix/boundary.rs | 740 ++++++++++++++++++++++++++++++++++
crates/vim/src/helix/object.rs   | 182 ++++++++
crates/vim/src/helix/select.rs   |  84 +++
crates/vim/src/normal.rs         |   9 
crates/vim/src/object.rs         |   6 
crates/vim/src/state.rs          |  25 +
crates/vim/src/vim.rs            |  39 +
crates/zed/src/zed.rs            |   2 
docs/src/helix.md                |   4 
12 files changed, 1,196 insertions(+), 69 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -32,34 +32,6 @@
       "(": "vim::SentenceBackward",
       ")": "vim::SentenceForward",
       "|": "vim::GoToColumn",
-      "] ]": "vim::NextSectionStart",
-      "] [": "vim::NextSectionEnd",
-      "[ [": "vim::PreviousSectionStart",
-      "[ ]": "vim::PreviousSectionEnd",
-      "] m": "vim::NextMethodStart",
-      "] shift-m": "vim::NextMethodEnd",
-      "[ m": "vim::PreviousMethodStart",
-      "[ shift-m": "vim::PreviousMethodEnd",
-      "[ *": "vim::PreviousComment",
-      "[ /": "vim::PreviousComment",
-      "] *": "vim::NextComment",
-      "] /": "vim::NextComment",
-      "[ -": "vim::PreviousLesserIndent",
-      "[ +": "vim::PreviousGreaterIndent",
-      "[ =": "vim::PreviousSameIndent",
-      "] -": "vim::NextLesserIndent",
-      "] +": "vim::NextGreaterIndent",
-      "] =": "vim::NextSameIndent",
-      "] b": "pane::ActivateNextItem",
-      "[ b": "pane::ActivatePreviousItem",
-      "] shift-b": "pane::ActivateLastItem",
-      "[ shift-b": ["pane::ActivateItem", 0],
-      "] space": "vim::InsertEmptyLineBelow",
-      "[ space": "vim::InsertEmptyLineAbove",
-      "[ e": "editor::MoveLineUp",
-      "] e": "editor::MoveLineDown",
-      "[ f": "workspace::FollowNextCollaborator",
-      "] f": "workspace::FollowNextCollaborator",
 
       // Word motions
       "w": "vim::NextWordStart",
@@ -83,10 +55,6 @@
       "n": "vim::MoveToNextMatch",
       "shift-n": "vim::MoveToPreviousMatch",
       "%": "vim::Matching",
-      "] }": ["vim::UnmatchedForward", { "char": "}" }],
-      "[ {": ["vim::UnmatchedBackward", { "char": "{" }],
-      "] )": ["vim::UnmatchedForward", { "char": ")" }],
-      "[ (": ["vim::UnmatchedBackward", { "char": "(" }],
       "f": ["vim::PushFindForward", { "before": false, "multiline": false }],
       "t": ["vim::PushFindForward", { "before": true, "multiline": false }],
       "shift-f": ["vim::PushFindBackward", { "after": false, "multiline": false }],
@@ -219,6 +187,46 @@
       ".": "vim::Repeat"
     }
   },
+  {
+    "context": "vim_mode == normal || vim_mode == visual || vim_mode == operator",
+    "bindings": {
+      "] ]": "vim::NextSectionStart",
+      "] [": "vim::NextSectionEnd",
+      "[ [": "vim::PreviousSectionStart",
+      "[ ]": "vim::PreviousSectionEnd",
+      "] m": "vim::NextMethodStart",
+      "] shift-m": "vim::NextMethodEnd",
+      "[ m": "vim::PreviousMethodStart",
+      "[ shift-m": "vim::PreviousMethodEnd",
+      "[ *": "vim::PreviousComment",
+      "[ /": "vim::PreviousComment",
+      "] *": "vim::NextComment",
+      "] /": "vim::NextComment",
+      "[ -": "vim::PreviousLesserIndent",
+      "[ +": "vim::PreviousGreaterIndent",
+      "[ =": "vim::PreviousSameIndent",
+      "] -": "vim::NextLesserIndent",
+      "] +": "vim::NextGreaterIndent",
+      "] =": "vim::NextSameIndent",
+      "] b": "pane::ActivateNextItem",
+      "[ b": "pane::ActivatePreviousItem",
+      "] shift-b": "pane::ActivateLastItem",
+      "[ shift-b": ["pane::ActivateItem", 0],
+      "] space": "vim::InsertEmptyLineBelow",
+      "[ space": "vim::InsertEmptyLineAbove",
+      "[ e": "editor::MoveLineUp",
+      "] e": "editor::MoveLineDown",
+      "[ f": "workspace::FollowNextCollaborator",
+      "] f": "workspace::FollowNextCollaborator",
+      "] }": ["vim::UnmatchedForward", { "char": "}" }],
+      "[ {": ["vim::UnmatchedBackward", { "char": "{" }],
+      "] )": ["vim::UnmatchedForward", { "char": ")" }],
+      "[ (": ["vim::UnmatchedBackward", { "char": "(" }],
+      // tree-sitter related commands
+      "[ x": "vim::SelectLargerSyntaxNode",
+      "] x": "vim::SelectSmallerSyntaxNode"
+    }
+  },
   {
     "context": "vim_mode == normal",
     "bindings": {
@@ -249,9 +257,6 @@
       "g w": "vim::PushRewrap",
       "g q": "vim::PushRewrap",
       "insert": "vim::InsertBefore",
-      // tree-sitter related commands
-      "[ x": "vim::SelectLargerSyntaxNode",
-      "] x": "vim::SelectSmallerSyntaxNode",
       "] d": "editor::GoToDiagnostic",
       "[ d": "editor::GoToPreviousDiagnostic",
       "] c": "editor::GoToHunk",
@@ -317,10 +322,7 @@
       "g w": "vim::Rewrap",
       "g ?": "vim::ConvertToRot13",
       // "g ?": "vim::ConvertToRot47",
-      "\"": "vim::PushRegister",
-      // tree-sitter related commands
-      "[ x": "editor::SelectLargerSyntaxNode",
-      "] x": "editor::SelectSmallerSyntaxNode"
+      "\"": "vim::PushRegister"
     }
   },
   {
@@ -397,6 +399,9 @@
       "ctrl-[": "editor::Cancel",
       ";": "vim::HelixCollapseSelection",
       ":": "command_palette::Toggle",
+      "m": "vim::PushHelixMatch",
+      "]": ["vim::PushHelixNext", { "around": true }],
+      "[": ["vim::PushHelixPrevious", { "around": true }],
       "left": "vim::WrappingLeft",
       "right": "vim::WrappingRight",
       "h": "vim::WrappingLeft",
@@ -419,13 +424,6 @@
       "insert": "vim::InsertBefore",
       "alt-.": "vim::RepeatFind",
       "alt-s": ["editor::SplitSelectionIntoLines", { "keep_selections": true }],
-      // tree-sitter related commands
-      "[ x": "editor::SelectLargerSyntaxNode",
-      "] x": "editor::SelectSmallerSyntaxNode",
-      "] d": "editor::GoToDiagnostic",
-      "[ d": "editor::GoToPreviousDiagnostic",
-      "] c": "editor::GoToHunk",
-      "[ c": "editor::GoToPreviousHunk",
       // Goto mode
       "g n": "pane::ActivateNextItem",
       "g p": "pane::ActivatePreviousItem",
@@ -469,9 +467,6 @@
       "space c": "editor::ToggleComments",
       "space y": "editor::Copy",
       "space p": "editor::Paste",
-      // Match mode
-      "m m": "vim::Matching",
-      "m i w": ["workspace::SendKeystrokes", "v i w"],
       "shift-u": "editor::Redo",
       "ctrl-c": "editor::ToggleComments",
       "d": "vim::HelixDelete",
@@ -540,7 +535,7 @@
     }
   },
   {
-    "context": "vim_operator == a || vim_operator == i || vim_operator == cs",
+    "context": "vim_operator == a || vim_operator == i || vim_operator == cs || vim_operator == helix_next || vim_operator == helix_previous",
     "bindings": {
       "w": "vim::Word",
       "shift-w": ["vim::Word", { "ignore_punctuation": true }],
@@ -577,6 +572,48 @@
       "e": "vim::EntireFile"
     }
   },
+  {
+    "context": "vim_operator == helix_m",
+    "bindings": {
+      "m": "vim::Matching"
+    }
+  },
+  {
+    "context": "vim_operator == helix_next",
+    "bindings": {
+      "z": "vim::NextSectionStart",
+      "shift-z": "vim::NextSectionEnd",
+      "*": "vim::NextComment",
+      "/": "vim::NextComment",
+      "-": "vim::NextLesserIndent",
+      "+": "vim::NextGreaterIndent",
+      "=": "vim::NextSameIndent",
+      "b": "pane::ActivateNextItem",
+      "shift-b": "pane::ActivateLastItem",
+      "x": "editor::SelectSmallerSyntaxNode",
+      "d": "editor::GoToDiagnostic",
+      "c": "editor::GoToHunk",
+      "space": "vim::InsertEmptyLineBelow"
+    }
+  },
+  {
+    "context": "vim_operator == helix_previous",
+    "bindings": {
+      "z": "vim::PreviousSectionStart",
+      "shift-z": "vim::PreviousSectionEnd",
+      "*": "vim::PreviousComment",
+      "/": "vim::PreviousComment",
+      "-": "vim::PreviousLesserIndent",
+      "+": "vim::PreviousGreaterIndent",
+      "=": "vim::PreviousSameIndent",
+      "b": "pane::ActivatePreviousItem",
+      "shift-b": ["pane::ActivateItem", 0],
+      "x": "editor::SelectLargerSyntaxNode",
+      "d": "editor::GoToPreviousDiagnostic",
+      "c": "editor::GoToPreviousHunk",
+      "space": "vim::InsertEmptyLineAbove"
+    }
+  },
   {
     "context": "vim_operator == c",
     "bindings": {

crates/editor/src/movement.rs 🔗

@@ -4,7 +4,7 @@
 use super::{Bias, DisplayPoint, DisplaySnapshot, SelectionGoal, ToDisplayPoint};
 use crate::{DisplayRow, EditorStyle, ToOffset, ToPoint, scroll::ScrollAnchor};
 use gpui::{Pixels, WindowTextSystem};
-use language::Point;
+use language::{CharClassifier, Point};
 use multi_buffer::{MultiBufferRow, MultiBufferSnapshot};
 use serde::Deserialize;
 use workspace::searchable::Direction;
@@ -405,15 +405,18 @@ pub fn previous_subword_start(map: &DisplaySnapshot, point: DisplayPoint) -> Dis
     let classifier = map.buffer_snapshot.char_classifier_at(raw_point);
 
     find_preceding_boundary_display_point(map, point, FindRange::MultiLine, |left, right| {
-        let is_word_start =
-            classifier.kind(left) != classifier.kind(right) && !right.is_whitespace();
-        let is_subword_start = classifier.is_word('-') && left == '-' && right != '-'
-            || left == '_' && right != '_'
-            || left.is_lowercase() && right.is_uppercase();
-        is_word_start || is_subword_start || left == '\n'
+        is_subword_start(left, right, &classifier) || left == '\n'
     })
 }
 
+pub fn is_subword_start(left: char, right: char, classifier: &CharClassifier) -> bool {
+    let is_word_start = classifier.kind(left) != classifier.kind(right) && !right.is_whitespace();
+    let is_subword_start = classifier.is_word('-') && left == '-' && right != '-'
+        || left == '_' && right != '_'
+        || left.is_lowercase() && right.is_uppercase();
+    is_word_start || is_subword_start
+}
+
 /// Returns a position of the next word boundary, where a word character is defined as either
 /// uppercase letter, lowercase letter, '_' character or language-specific word character (like '-' in CSS).
 pub fn next_word_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
@@ -463,15 +466,19 @@ pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPo
     let classifier = map.buffer_snapshot.char_classifier_at(raw_point);
 
     find_boundary(map, point, FindRange::MultiLine, |left, right| {
-        let is_word_end =
-            (classifier.kind(left) != classifier.kind(right)) && !classifier.is_whitespace(left);
-        let is_subword_end = classifier.is_word('-') && left != '-' && right == '-'
-            || left != '_' && right == '_'
-            || left.is_lowercase() && right.is_uppercase();
-        is_word_end || is_subword_end || right == '\n'
+        is_subword_end(left, right, &classifier) || right == '\n'
     })
 }
 
+pub fn is_subword_end(left: char, right: char, classifier: &CharClassifier) -> bool {
+    let is_word_end =
+        (classifier.kind(left) != classifier.kind(right)) && !classifier.is_whitespace(left);
+    let is_subword_end = classifier.is_word('-') && left != '-' && right == '-'
+        || left != '_' && right == '_'
+        || left.is_lowercase() && right.is_uppercase();
+    is_word_end || is_subword_end
+}
+
 /// Returns a position of the start of the current paragraph, where a paragraph
 /// is defined as a run of non-blank lines.
 pub fn start_of_paragraph(

crates/vim/src/helix.rs 🔗

@@ -1,3 +1,7 @@
+mod boundary;
+mod object;
+mod select;
+
 use editor::display_map::DisplaySnapshot;
 use editor::{
     DisplayPoint, Editor, HideMouseCursorOrigin, SelectionEffects, ToOffset, ToPoint, movement,

crates/vim/src/helix/boundary.rs 🔗

@@ -0,0 +1,740 @@
+use std::{
+    cmp::Ordering,
+    ops::{Deref, DerefMut, Range},
+};
+
+use editor::{
+    DisplayPoint,
+    display_map::{DisplaySnapshot, ToDisplayPoint},
+    movement,
+};
+use language::{CharClassifier, CharKind};
+use text::Bias;
+
+use crate::helix::object::HelixTextObject;
+
+/// Text objects (after helix definition) that can easily be
+/// found by reading a buffer and comparing two neighboring chars
+/// until a start / end is found
+trait BoundedObject {
+    /// The next start since `from` (inclusive).
+    /// If outer is true it is the start of "a" object (m a) rather than "inner" object (m i).
+    fn next_start(&self, map: &DisplaySnapshot, from: Offset, outer: bool) -> Option<Offset>;
+    /// The next end since `from` (inclusive).
+    /// If outer is true it is the end of "a" object (m a) rather than "inner" object (m i).
+    fn next_end(&self, map: &DisplaySnapshot, from: Offset, outer: bool) -> Option<Offset>;
+    /// The previous start since `from` (inclusive).
+    /// If outer is true it is the start of "a" object (m a) rather than "inner" object (m i).
+    fn previous_start(&self, map: &DisplaySnapshot, from: Offset, outer: bool) -> Option<Offset>;
+    /// The previous end since `from` (inclusive).
+    /// If outer is true it is the end of "a" object (m a) rather than "inner" object (m i).
+    fn previous_end(&self, map: &DisplaySnapshot, from: Offset, outer: bool) -> Option<Offset>;
+
+    /// Whether the range inside the object can be zero characters wide.
+    /// If so, the trait assumes that these ranges can't be directly adjacent to each other.
+    fn inner_range_can_be_zero_width(&self) -> bool;
+    /// Whether the "ma" can exceed the "mi" range on both sides at the same time
+    fn surround_on_both_sides(&self) -> bool;
+    /// Whether the outer range of an object could overlap with the outer range of the neighboring
+    /// object. If so, they can't be nested.
+    fn ambiguous_outer(&self) -> bool;
+
+    fn can_be_zero_width(&self, around: bool) -> bool {
+        if around {
+            false
+        } else {
+            self.inner_range_can_be_zero_width()
+        }
+    }
+
+    /// Switches from an "mi" range to an "ma" one.
+    /// Assumes the inner range is valid.
+    fn around(&self, map: &DisplaySnapshot, inner_range: Range<Offset>) -> Range<Offset> {
+        if self.surround_on_both_sides() {
+            let start = self
+                .previous_start(map, inner_range.start, true)
+                .unwrap_or(inner_range.start);
+            let end = self
+                .next_end(map, inner_range.end, true)
+                .unwrap_or(inner_range.end);
+
+            return start..end;
+        }
+
+        let mut start = inner_range.start;
+        let end = self
+            .next_end(map, inner_range.end, true)
+            .unwrap_or(inner_range.end);
+        if end == inner_range.end {
+            start = self
+                .previous_start(map, inner_range.start, true)
+                .unwrap_or(inner_range.start)
+        }
+
+        start..end
+    }
+    /// Switches from an "ma" range to an "mi" one.
+    /// Assumes the inner range is valid.
+    fn inside(&self, map: &DisplaySnapshot, outer_range: Range<Offset>) -> Range<Offset> {
+        let inner_start = self
+            .next_start(map, outer_range.start, false)
+            .unwrap_or_else(|| {
+                log::warn!("The motion might not have found the text object correctly");
+                outer_range.start
+            });
+        let inner_end = self
+            .previous_end(map, outer_range.end, false)
+            .unwrap_or_else(|| {
+                log::warn!("The motion might not have found the text object correctly");
+                outer_range.end
+            });
+        inner_start..inner_end
+    }
+
+    /// The next end since `start` (inclusive) on the same nesting level.
+    fn close_at_end(&self, start: Offset, map: &DisplaySnapshot, outer: bool) -> Option<Offset> {
+        let mut end_search_start = if self.can_be_zero_width(outer) {
+            start
+        } else {
+            start.next(map)?
+        };
+        let mut start_search_start = start.next(map)?;
+
+        loop {
+            let next_end = self.next_end(map, end_search_start, outer)?;
+            let maybe_next_start = self.next_start(map, start_search_start, outer);
+            if let Some(next_start) = maybe_next_start
+                && (*next_start < *next_end
+                    || *next_start == *next_end && self.can_be_zero_width(outer))
+                && !self.ambiguous_outer()
+            {
+                let closing = self.close_at_end(next_start, map, outer)?;
+                end_search_start = closing.next(map)?;
+                start_search_start = if self.can_be_zero_width(outer) {
+                    closing.next(map)?
+                } else {
+                    closing
+                };
+            } else {
+                return Some(next_end);
+            }
+        }
+    }
+    /// The previous start since `end` (inclusive) on the same nesting level.
+    fn close_at_start(&self, end: Offset, map: &DisplaySnapshot, outer: bool) -> Option<Offset> {
+        let mut start_search_end = if self.can_be_zero_width(outer) {
+            end
+        } else {
+            end.previous(map)?
+        };
+        let mut end_search_end = end.previous(map)?;
+
+        loop {
+            let previous_start = self.previous_start(map, start_search_end, outer)?;
+            let maybe_previous_end = self.previous_end(map, end_search_end, outer);
+            if let Some(previous_end) = maybe_previous_end
+                && (*previous_end > *previous_start
+                    || *previous_end == *previous_start && self.can_be_zero_width(outer))
+                && !self.ambiguous_outer()
+            {
+                let closing = self.close_at_start(previous_end, map, outer)?;
+                start_search_end = closing.previous(map)?;
+                end_search_end = if self.can_be_zero_width(outer) {
+                    closing.previous(map)?
+                } else {
+                    closing
+                };
+            } else {
+                return Some(previous_start);
+            }
+        }
+    }
+}
+
+#[derive(Clone, Copy, PartialEq, Debug)]
+struct Offset(usize);
+impl Deref for Offset {
+    type Target = usize;
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+impl DerefMut for Offset {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.0
+    }
+}
+impl Offset {
+    fn next(self, map: &DisplaySnapshot) -> Option<Self> {
+        let next = Self(map.buffer_snapshot.clip_offset(*self + 1, Bias::Right));
+        (*next > *self).then(|| next)
+    }
+    fn previous(self, map: &DisplaySnapshot) -> Option<Self> {
+        if *self == 0 {
+            return None;
+        }
+        Some(Self(map.buffer_snapshot.clip_offset(*self - 1, Bias::Left)))
+    }
+    fn range(
+        start: (DisplayPoint, Bias),
+        end: (DisplayPoint, Bias),
+        map: &DisplaySnapshot,
+    ) -> Range<Self> {
+        Self(start.0.to_offset(map, start.1))..Self(end.0.to_offset(map, end.1))
+    }
+}
+
+impl<B: BoundedObject> HelixTextObject for B {
+    fn range(
+        &self,
+        map: &DisplaySnapshot,
+        relative_to: Range<DisplayPoint>,
+        around: bool,
+    ) -> Option<Range<DisplayPoint>> {
+        let relative_to = Offset::range(
+            (relative_to.start, Bias::Left),
+            (relative_to.end, Bias::Left),
+            map,
+        );
+
+        relative_range(self, around, map, |find_outer| {
+            let search_start = if self.can_be_zero_width(find_outer) {
+                relative_to.end
+            } else {
+                // If the objects can be directly next to each other an object end the
+                // cursor (relative_to) end would not count for close_at_end, so the search
+                // needs to start one character to the left.
+                relative_to.end.previous(map)?
+            };
+            let max_end = self.close_at_end(search_start, map, find_outer)?;
+            let min_start = self.close_at_start(max_end, map, find_outer)?;
+
+            (*min_start <= *relative_to.start).then(|| min_start..max_end)
+        })
+    }
+
+    fn next_range(
+        &self,
+        map: &DisplaySnapshot,
+        relative_to: Range<DisplayPoint>,
+        around: bool,
+    ) -> Option<Range<DisplayPoint>> {
+        let relative_to = Offset::range(
+            (relative_to.start, Bias::Left),
+            (relative_to.end, Bias::Left),
+            map,
+        );
+
+        relative_range(self, around, map, |find_outer| {
+            let min_start = self.next_start(map, relative_to.end, find_outer)?;
+            let max_end = self.close_at_end(min_start, map, find_outer)?;
+
+            Some(min_start..max_end)
+        })
+    }
+
+    fn previous_range(
+        &self,
+        map: &DisplaySnapshot,
+        relative_to: Range<DisplayPoint>,
+        around: bool,
+    ) -> Option<Range<DisplayPoint>> {
+        let relative_to = Offset::range(
+            (relative_to.start, Bias::Left),
+            (relative_to.end, Bias::Left),
+            map,
+        );
+
+        relative_range(self, around, map, |find_outer| {
+            let max_end = self.previous_end(map, relative_to.start, find_outer)?;
+            let min_start = self.close_at_start(max_end, map, find_outer)?;
+
+            Some(min_start..max_end)
+        })
+    }
+}
+
+fn relative_range<B: BoundedObject>(
+    object: &B,
+    outer: bool,
+    map: &DisplaySnapshot,
+    find_range: impl Fn(bool) -> Option<Range<Offset>>,
+) -> Option<Range<DisplayPoint>> {
+    // The cursor could be inside the outer range, but not the inner range.
+    // Whether that should count as found.
+    let find_outer = object.surround_on_both_sides() && !object.ambiguous_outer();
+    let range = find_range(find_outer)?;
+    let min_start = range.start;
+    let max_end = range.end;
+
+    let wanted_range = if outer && !find_outer {
+        // max_end is not yet the outer end
+        object.around(map, min_start..max_end)
+    } else if !outer && find_outer {
+        // max_end is the outer end, but the final result should have the inner end
+        object.inside(map, min_start..max_end)
+    } else {
+        min_start..max_end
+    };
+
+    let start = wanted_range.start.clone().to_display_point(map);
+    let end = wanted_range.end.clone().to_display_point(map);
+
+    Some(start..end)
+}
+
+/// A textobject whose boundaries can easily be found between two chars
+pub enum ImmediateBoundary {
+    Word { ignore_punctuation: bool },
+    Subword { ignore_punctuation: bool },
+    AngleBrackets,
+    BackQuotes,
+    CurlyBrackets,
+    DoubleQuotes,
+    Parentheses,
+    SingleQuotes,
+    SquareBrackets,
+    VerticalBars,
+}
+
+/// A textobject whose start and end can be found from an easy-to-find
+/// boundary between two chars by following a simple path from there
+pub enum FuzzyBoundary {
+    Sentence,
+    Paragraph,
+}
+
+impl ImmediateBoundary {
+    fn is_inner_start(&self, left: char, right: char, classifier: CharClassifier) -> bool {
+        match self {
+            Self::Word { ignore_punctuation } => {
+                let classifier = classifier.ignore_punctuation(*ignore_punctuation);
+                is_word_start(left, right, &classifier)
+                    || (is_buffer_start(left) && classifier.kind(right) != CharKind::Whitespace)
+            }
+            Self::Subword { ignore_punctuation } => {
+                let classifier = classifier.ignore_punctuation(*ignore_punctuation);
+                movement::is_subword_start(left, right, &classifier)
+                    || (is_buffer_start(left) && classifier.kind(right) != CharKind::Whitespace)
+            }
+            Self::AngleBrackets => left == '<',
+            Self::BackQuotes => left == '`',
+            Self::CurlyBrackets => left == '{',
+            Self::DoubleQuotes => left == '"',
+            Self::Parentheses => left == '(',
+            Self::SingleQuotes => left == '\'',
+            Self::SquareBrackets => left == '[',
+            Self::VerticalBars => left == '|',
+        }
+    }
+    fn is_inner_end(&self, left: char, right: char, classifier: CharClassifier) -> bool {
+        match self {
+            Self::Word { ignore_punctuation } => {
+                let classifier = classifier.ignore_punctuation(*ignore_punctuation);
+                is_word_end(left, right, &classifier)
+                    || (is_buffer_end(right) && classifier.kind(left) != CharKind::Whitespace)
+            }
+            Self::Subword { ignore_punctuation } => {
+                let classifier = classifier.ignore_punctuation(*ignore_punctuation);
+                movement::is_subword_start(left, right, &classifier)
+                    || (is_buffer_end(right) && classifier.kind(left) != CharKind::Whitespace)
+            }
+            Self::AngleBrackets => right == '>',
+            Self::BackQuotes => right == '`',
+            Self::CurlyBrackets => right == '}',
+            Self::DoubleQuotes => right == '"',
+            Self::Parentheses => right == ')',
+            Self::SingleQuotes => right == '\'',
+            Self::SquareBrackets => right == ']',
+            Self::VerticalBars => right == '|',
+        }
+    }
+    fn is_outer_start(&self, left: char, right: char, classifier: CharClassifier) -> bool {
+        match self {
+            word @ Self::Word { .. } => word.is_inner_end(left, right, classifier) || left == '\n',
+            subword @ Self::Subword { .. } => {
+                subword.is_inner_end(left, right, classifier) || left == '\n'
+            }
+            Self::AngleBrackets => right == '<',
+            Self::BackQuotes => right == '`',
+            Self::CurlyBrackets => right == '{',
+            Self::DoubleQuotes => right == '"',
+            Self::Parentheses => right == '(',
+            Self::SingleQuotes => right == '\'',
+            Self::SquareBrackets => right == '[',
+            Self::VerticalBars => right == '|',
+        }
+    }
+    fn is_outer_end(&self, left: char, right: char, classifier: CharClassifier) -> bool {
+        match self {
+            word @ Self::Word { .. } => {
+                word.is_inner_start(left, right, classifier) || right == '\n'
+            }
+            subword @ Self::Subword { .. } => {
+                subword.is_inner_start(left, right, classifier) || right == '\n'
+            }
+            Self::AngleBrackets => left == '>',
+            Self::BackQuotes => left == '`',
+            Self::CurlyBrackets => left == '}',
+            Self::DoubleQuotes => left == '"',
+            Self::Parentheses => left == ')',
+            Self::SingleQuotes => left == '\'',
+            Self::SquareBrackets => left == ']',
+            Self::VerticalBars => left == '|',
+        }
+    }
+}
+
+impl BoundedObject for ImmediateBoundary {
+    fn next_start(&self, map: &DisplaySnapshot, from: Offset, outer: bool) -> Option<Offset> {
+        try_find_boundary(map, from, |left, right| {
+            let classifier = map.buffer_snapshot.char_classifier_at(*from);
+            if outer {
+                self.is_outer_start(left, right, classifier)
+            } else {
+                self.is_inner_start(left, right, classifier)
+            }
+        })
+    }
+    fn next_end(&self, map: &DisplaySnapshot, from: Offset, outer: bool) -> Option<Offset> {
+        try_find_boundary(map, from, |left, right| {
+            let classifier = map.buffer_snapshot.char_classifier_at(*from);
+            if outer {
+                self.is_outer_end(left, right, classifier)
+            } else {
+                self.is_inner_end(left, right, classifier)
+            }
+        })
+    }
+    fn previous_start(&self, map: &DisplaySnapshot, from: Offset, outer: bool) -> Option<Offset> {
+        try_find_preceding_boundary(map, from, |left, right| {
+            let classifier = map.buffer_snapshot.char_classifier_at(*from);
+            if outer {
+                self.is_outer_start(left, right, classifier)
+            } else {
+                self.is_inner_start(left, right, classifier)
+            }
+        })
+    }
+    fn previous_end(&self, map: &DisplaySnapshot, from: Offset, outer: bool) -> Option<Offset> {
+        try_find_preceding_boundary(map, from, |left, right| {
+            let classifier = map.buffer_snapshot.char_classifier_at(*from);
+            if outer {
+                self.is_outer_end(left, right, classifier)
+            } else {
+                self.is_inner_end(left, right, classifier)
+            }
+        })
+    }
+    fn inner_range_can_be_zero_width(&self) -> bool {
+        match self {
+            Self::Subword { .. } | Self::Word { .. } => false,
+            _ => true,
+        }
+    }
+    fn surround_on_both_sides(&self) -> bool {
+        match self {
+            Self::Subword { .. } | Self::Word { .. } => false,
+            _ => true,
+        }
+    }
+    fn ambiguous_outer(&self) -> bool {
+        match self {
+            Self::BackQuotes
+            | Self::DoubleQuotes
+            | Self::SingleQuotes
+            | Self::VerticalBars
+            | Self::Subword { .. }
+            | Self::Word { .. } => true,
+            _ => false,
+        }
+    }
+}
+
+impl FuzzyBoundary {
+    /// When between two chars that form an easy-to-find identifier boundary,
+    /// what's the way to get to the actual start of the object, if any
+    fn is_near_potential_inner_start<'a>(
+        &self,
+        left: char,
+        right: char,
+        classifier: &CharClassifier,
+    ) -> Option<Box<dyn Fn(Offset, &'a DisplaySnapshot) -> Option<Offset>>> {
+        if is_buffer_start(left) {
+            return Some(Box::new(|identifier, _| Some(identifier)));
+        }
+        match self {
+            Self::Paragraph => {
+                if left != '\n' || right != '\n' {
+                    return None;
+                }
+                Some(Box::new(|identifier, map| {
+                    try_find_boundary(map, identifier, |left, right| left == '\n' && right != '\n')
+                }))
+            }
+            Self::Sentence => {
+                if let Some(find_paragraph_start) =
+                    Self::Paragraph.is_near_potential_inner_start(left, right, classifier)
+                {
+                    return Some(find_paragraph_start);
+                } else if !is_sentence_end(left, right, classifier) {
+                    return None;
+                }
+                Some(Box::new(|identifier, map| {
+                    let word = ImmediateBoundary::Word {
+                        ignore_punctuation: false,
+                    };
+                    word.next_start(map, identifier, false)
+                }))
+            }
+        }
+    }
+    /// When between two chars that form an easy-to-find identifier boundary,
+    /// what's the way to get to the actual end of the object, if any
+    fn is_near_potential_inner_end<'a>(
+        &self,
+        left: char,
+        right: char,
+        classifier: &CharClassifier,
+    ) -> Option<Box<dyn Fn(Offset, &'a DisplaySnapshot) -> Option<Offset>>> {
+        if is_buffer_end(right) {
+            return Some(Box::new(|identifier, _| Some(identifier)));
+        }
+        match self {
+            Self::Paragraph => {
+                if left != '\n' || right != '\n' {
+                    return None;
+                }
+                Some(Box::new(|identifier, map| {
+                    try_find_preceding_boundary(map, identifier, |left, right| {
+                        left != '\n' && right == '\n'
+                    })
+                }))
+            }
+            Self::Sentence => {
+                if let Some(find_paragraph_end) =
+                    Self::Paragraph.is_near_potential_inner_end(left, right, classifier)
+                {
+                    return Some(find_paragraph_end);
+                } else if !is_sentence_end(left, right, classifier) {
+                    return None;
+                }
+                Some(Box::new(|identifier, _| Some(identifier)))
+            }
+        }
+    }
+    /// When between two chars that form an easy-to-find identifier boundary,
+    /// what's the way to get to the actual end of the object, if any
+    fn is_near_potential_outer_start<'a>(
+        &self,
+        left: char,
+        right: char,
+        classifier: &CharClassifier,
+    ) -> Option<Box<dyn Fn(Offset, &'a DisplaySnapshot) -> Option<Offset>>> {
+        match self {
+            paragraph @ Self::Paragraph => {
+                paragraph.is_near_potential_inner_end(left, right, classifier)
+            }
+            sentence @ Self::Sentence => {
+                sentence.is_near_potential_inner_end(left, right, classifier)
+            }
+        }
+    }
+    /// When between two chars that form an easy-to-find identifier boundary,
+    /// what's the way to get to the actual end of the object, if any
+    fn is_near_potential_outer_end<'a>(
+        &self,
+        left: char,
+        right: char,
+        classifier: &CharClassifier,
+    ) -> Option<Box<dyn Fn(Offset, &'a DisplaySnapshot) -> Option<Offset>>> {
+        match self {
+            paragraph @ Self::Paragraph => {
+                paragraph.is_near_potential_inner_start(left, right, classifier)
+            }
+            sentence @ Self::Sentence => {
+                sentence.is_near_potential_inner_start(left, right, classifier)
+            }
+        }
+    }
+
+    // The boundary can be on the other side of `from` than the identifier, so the search needs to go both ways.
+    // Also, the distance (and direction) between identifier and boundary could vary, so a few ones need to be
+    // compared, even if one boundary was already found on the right side of `from`.
+    fn to_boundary(
+        &self,
+        map: &DisplaySnapshot,
+        from: Offset,
+        outer: bool,
+        backward: bool,
+        boundary_kind: Boundary,
+    ) -> Option<Offset> {
+        let generate_boundary_data = |left, right, point: Offset| {
+            let classifier = map.buffer_snapshot.char_classifier_at(*from);
+            let reach_boundary = if outer && boundary_kind == Boundary::Start {
+                self.is_near_potential_outer_start(left, right, &classifier)
+            } else if !outer && boundary_kind == Boundary::Start {
+                self.is_near_potential_inner_start(left, right, &classifier)
+            } else if outer && boundary_kind == Boundary::End {
+                self.is_near_potential_outer_end(left, right, &classifier)
+            } else {
+                self.is_near_potential_inner_end(left, right, &classifier)
+            };
+
+            reach_boundary.map(|reach_start| (point, reach_start))
+        };
+
+        let forwards = try_find_boundary_data(map, from, generate_boundary_data);
+        let backwards = try_find_preceding_boundary_data(map, from, generate_boundary_data);
+        let boundaries = [forwards, backwards]
+            .into_iter()
+            .flatten()
+            .filter_map(|(identifier, reach_boundary)| reach_boundary(identifier, map))
+            .filter(|boundary| match boundary.cmp(&from) {
+                Ordering::Equal => true,
+                Ordering::Less => backward,
+                Ordering::Greater => !backward,
+            });
+        if backward {
+            boundaries.max_by_key(|boundary| **boundary)
+        } else {
+            boundaries.min_by_key(|boundary| **boundary)
+        }
+    }
+}
+
+#[derive(PartialEq)]
+enum Boundary {
+    Start,
+    End,
+}
+
+impl BoundedObject for FuzzyBoundary {
+    fn next_start(&self, map: &DisplaySnapshot, from: Offset, outer: bool) -> Option<Offset> {
+        self.to_boundary(map, from, outer, false, Boundary::Start)
+    }
+    fn next_end(&self, map: &DisplaySnapshot, from: Offset, outer: bool) -> Option<Offset> {
+        self.to_boundary(map, from, outer, false, Boundary::End)
+    }
+    fn previous_start(&self, map: &DisplaySnapshot, from: Offset, outer: bool) -> Option<Offset> {
+        self.to_boundary(map, from, outer, true, Boundary::Start)
+    }
+    fn previous_end(&self, map: &DisplaySnapshot, from: Offset, outer: bool) -> Option<Offset> {
+        self.to_boundary(map, from, outer, true, Boundary::End)
+    }
+    fn inner_range_can_be_zero_width(&self) -> bool {
+        false
+    }
+    fn surround_on_both_sides(&self) -> bool {
+        false
+    }
+    fn ambiguous_outer(&self) -> bool {
+        false
+    }
+}
+
+/// Returns the first boundary after or at `from` in text direction.
+/// The start and end of the file are the chars `'\0'`.
+fn try_find_boundary(
+    map: &DisplaySnapshot,
+    from: Offset,
+    is_boundary: impl Fn(char, char) -> bool,
+) -> Option<Offset> {
+    let boundary = try_find_boundary_data(map, from, |left, right, point| {
+        if is_boundary(left, right) {
+            Some(point)
+        } else {
+            None
+        }
+    })?;
+    Some(boundary)
+}
+
+/// Returns some information about it (of type `T`) as soon as
+/// there is a boundary after or at `from` in text direction
+/// The start and end of the file are the chars `'\0'`.
+fn try_find_boundary_data<T>(
+    map: &DisplaySnapshot,
+    mut from: Offset,
+    boundary_information: impl Fn(char, char, Offset) -> Option<T>,
+) -> Option<T> {
+    let mut prev_ch = map
+        .buffer_snapshot
+        .reversed_chars_at(*from)
+        .next()
+        .unwrap_or('\0');
+
+    for ch in map.buffer_snapshot.chars_at(*from).chain(['\0']) {
+        if let Some(boundary_information) = boundary_information(prev_ch, ch, from) {
+            return Some(boundary_information);
+        }
+        *from += ch.len_utf8();
+        prev_ch = ch;
+    }
+
+    None
+}
+
+/// Returns the first boundary after or at `from` in text direction.
+/// The start and end of the file are the chars `'\0'`.
+fn try_find_preceding_boundary(
+    map: &DisplaySnapshot,
+    from: Offset,
+    is_boundary: impl Fn(char, char) -> bool,
+) -> Option<Offset> {
+    let boundary = try_find_preceding_boundary_data(map, from, |left, right, point| {
+        if is_boundary(left, right) {
+            Some(point)
+        } else {
+            None
+        }
+    })?;
+    Some(boundary)
+}
+
+/// Returns some information about it (of type `T`) as soon as
+/// there is a boundary before or at `from` in opposite text direction
+/// The start and end of the file are the chars `'\0'`.
+fn try_find_preceding_boundary_data<T>(
+    map: &DisplaySnapshot,
+    mut from: Offset,
+    is_boundary: impl Fn(char, char, Offset) -> Option<T>,
+) -> Option<T> {
+    let mut prev_ch = map.buffer_snapshot.chars_at(*from).next().unwrap_or('\0');
+
+    for ch in map.buffer_snapshot.reversed_chars_at(*from).chain(['\0']) {
+        if let Some(boundary_information) = is_boundary(ch, prev_ch, from) {
+            return Some(boundary_information);
+        }
+        from.0 = from.0.saturating_sub(ch.len_utf8());
+        prev_ch = ch;
+    }
+
+    None
+}
+
+fn is_buffer_start(left: char) -> bool {
+    left == '\0'
+}
+
+fn is_buffer_end(right: char) -> bool {
+    right == '\0'
+}
+
+fn is_word_start(left: char, right: char, classifier: &CharClassifier) -> bool {
+    classifier.kind(left) != classifier.kind(right)
+        && classifier.kind(right) != CharKind::Whitespace
+}
+
+fn is_word_end(left: char, right: char, classifier: &CharClassifier) -> bool {
+    classifier.kind(left) != classifier.kind(right) && classifier.kind(left) != CharKind::Whitespace
+}
+
+fn is_sentence_end(left: char, right: char, classifier: &CharClassifier) -> bool {
+    const ENDS: [char; 1] = ['.'];
+
+    if classifier.kind(right) != CharKind::Whitespace {
+        return false;
+    }
+    ENDS.into_iter().any(|end| left == end)
+}

crates/vim/src/helix/object.rs 🔗

@@ -0,0 +1,182 @@
+use std::{
+    error::Error,
+    fmt::{self, Display},
+    ops::Range,
+};
+
+use editor::{DisplayPoint, display_map::DisplaySnapshot, movement};
+use text::Selection;
+
+use crate::{
+    helix::boundary::{FuzzyBoundary, ImmediateBoundary},
+    object::Object as VimObject,
+};
+
+/// A text object from helix or an extra one
+pub trait HelixTextObject {
+    fn range(
+        &self,
+        map: &DisplaySnapshot,
+        relative_to: Range<DisplayPoint>,
+        around: bool,
+    ) -> Option<Range<DisplayPoint>>;
+
+    fn next_range(
+        &self,
+        map: &DisplaySnapshot,
+        relative_to: Range<DisplayPoint>,
+        around: bool,
+    ) -> Option<Range<DisplayPoint>>;
+
+    fn previous_range(
+        &self,
+        map: &DisplaySnapshot,
+        relative_to: Range<DisplayPoint>,
+        around: bool,
+    ) -> Option<Range<DisplayPoint>>;
+}
+
+impl VimObject {
+    /// Returns the range of the object the cursor is over.
+    /// Follows helix convention.
+    pub fn helix_range(
+        self,
+        map: &DisplaySnapshot,
+        selection: Selection<DisplayPoint>,
+        around: bool,
+    ) -> Result<Option<Range<DisplayPoint>>, VimToHelixError> {
+        let cursor = cursor_range(&selection, map);
+        if let Some(helix_object) = self.to_helix_object() {
+            Ok(helix_object.range(map, cursor, around))
+        } else {
+            Err(VimToHelixError)
+        }
+    }
+    /// Returns the range of the next object the cursor is not over.
+    /// Follows helix convention.
+    pub fn helix_next_range(
+        self,
+        map: &DisplaySnapshot,
+        selection: Selection<DisplayPoint>,
+        around: bool,
+    ) -> Result<Option<Range<DisplayPoint>>, VimToHelixError> {
+        let cursor = cursor_range(&selection, map);
+        if let Some(helix_object) = self.to_helix_object() {
+            Ok(helix_object.next_range(map, cursor, around))
+        } else {
+            Err(VimToHelixError)
+        }
+    }
+    /// Returns the range of the previous object the cursor is not over.
+    /// Follows helix convention.
+    pub fn helix_previous_range(
+        self,
+        map: &DisplaySnapshot,
+        selection: Selection<DisplayPoint>,
+        around: bool,
+    ) -> Result<Option<Range<DisplayPoint>>, VimToHelixError> {
+        let cursor = cursor_range(&selection, map);
+        if let Some(helix_object) = self.to_helix_object() {
+            Ok(helix_object.previous_range(map, cursor, around))
+        } else {
+            Err(VimToHelixError)
+        }
+    }
+}
+
+#[derive(Debug)]
+pub struct VimToHelixError;
+impl Display for VimToHelixError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(
+            f,
+            "Not all vim text objects have an implemented helix equivalent"
+        )
+    }
+}
+impl Error for VimToHelixError {}
+
+impl VimObject {
+    fn to_helix_object(self) -> Option<Box<dyn HelixTextObject>> {
+        Some(match self {
+            Self::AngleBrackets => Box::new(ImmediateBoundary::AngleBrackets),
+            Self::BackQuotes => Box::new(ImmediateBoundary::BackQuotes),
+            Self::CurlyBrackets => Box::new(ImmediateBoundary::CurlyBrackets),
+            Self::DoubleQuotes => Box::new(ImmediateBoundary::DoubleQuotes),
+            Self::Paragraph => Box::new(FuzzyBoundary::Paragraph),
+            Self::Parentheses => Box::new(ImmediateBoundary::Parentheses),
+            Self::Quotes => Box::new(ImmediateBoundary::SingleQuotes),
+            Self::Sentence => Box::new(FuzzyBoundary::Sentence),
+            Self::SquareBrackets => Box::new(ImmediateBoundary::SquareBrackets),
+            Self::Subword { ignore_punctuation } => {
+                Box::new(ImmediateBoundary::Subword { ignore_punctuation })
+            }
+            Self::VerticalBars => Box::new(ImmediateBoundary::VerticalBars),
+            Self::Word { ignore_punctuation } => {
+                Box::new(ImmediateBoundary::Word { ignore_punctuation })
+            }
+            _ => return None,
+        })
+    }
+}
+
+/// Returns the start of the cursor of a selection, whether that is collapsed or not.
+pub(crate) fn cursor_range(
+    selection: &Selection<DisplayPoint>,
+    map: &DisplaySnapshot,
+) -> Range<DisplayPoint> {
+    if selection.is_empty() | selection.reversed {
+        selection.head()..movement::right(map, selection.head())
+    } else {
+        movement::left(map, selection.head())..selection.head()
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use db::indoc;
+
+    use crate::{state::Mode, test::VimTestContext};
+
+    #[gpui::test]
+    async fn test_select_word_object(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        let start = indoc! {"
+                The quick brˇowˇnˇ
+                fox «ˇjumps» ov«er
+                the laˇ»zy dogˇ
+
+                "
+        };
+
+        cx.set_state(start, Mode::HelixNormal);
+
+        cx.simulate_keystrokes("m i w");
+
+        cx.assert_state(
+            indoc! {"
+            The quick «brownˇ»
+            fox «jumpsˇ» over
+            the «lazyˇ» dogˇ
+
+            "
+            },
+            Mode::HelixNormal,
+        );
+
+        cx.set_state(start, Mode::HelixNormal);
+
+        cx.simulate_keystrokes("m a w");
+
+        cx.assert_state(
+            indoc! {"
+            The quick« brownˇ»
+            fox «jumps ˇ»over
+            the «lazy ˇ»dogˇ
+
+            "
+            },
+            Mode::HelixNormal,
+        );
+    }
+}

crates/vim/src/helix/select.rs 🔗

@@ -0,0 +1,84 @@
+use text::SelectionGoal;
+use ui::{Context, Window};
+
+use crate::{Vim, helix::object::cursor_range, object::Object};
+
+impl Vim {
+    /// Selects the object each cursor is over.
+    /// Follows helix convention.
+    pub fn select_current_object(
+        &mut self,
+        object: Object,
+        around: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.stop_recording(cx);
+        self.update_editor(cx, |_, editor, cx| {
+            editor.change_selections(Default::default(), window, cx, |s| {
+                s.move_with(|map, selection| {
+                    let Some(range) = object
+                        .helix_range(map, selection.clone(), around)
+                        .unwrap_or({
+                            let vim_range = object.range(map, selection.clone(), around, None);
+                            vim_range.filter(|r| r.start <= cursor_range(selection, map).start)
+                        })
+                    else {
+                        return;
+                    };
+
+                    selection.set_head_tail(range.end, range.start, SelectionGoal::None);
+                });
+            });
+        });
+    }
+
+    /// Selects the next object from each cursor which the cursor is not over.
+    /// Follows helix convention.
+    pub fn select_next_object(
+        &mut self,
+        object: Object,
+        around: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.stop_recording(cx);
+        self.update_editor(cx, |_, editor, cx| {
+            editor.change_selections(Default::default(), window, cx, |s| {
+                s.move_with(|map, selection| {
+                    let Ok(Some(range)) = object.helix_next_range(map, selection.clone(), around)
+                    else {
+                        return;
+                    };
+
+                    selection.set_head_tail(range.end, range.start, SelectionGoal::None);
+                });
+            });
+        });
+    }
+
+    /// Selects the previous object from each cursor which the cursor is not over.
+    /// Follows helix convention.
+    pub fn select_previous_object(
+        &mut self,
+        object: Object,
+        around: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.stop_recording(cx);
+        self.update_editor(cx, |_, editor, cx| {
+            editor.change_selections(Default::default(), window, cx, |s| {
+                s.move_with(|map, selection| {
+                    let Ok(Some(range)) =
+                        object.helix_previous_range(map, selection.clone(), around)
+                    else {
+                        return;
+                    };
+
+                    selection.set_head_tail(range.start, range.end, SelectionGoal::None);
+                });
+            });
+        });
+    }
+}

crates/vim/src/normal.rs 🔗

@@ -495,10 +495,19 @@ impl Vim {
                     self.replace_with_register_object(object, around, window, cx)
                 }
                 Some(Operator::Exchange) => self.exchange_object(object, around, window, cx),
+                Some(Operator::HelixMatch) => {
+                    self.select_current_object(object, around, window, cx)
+                }
                 _ => {
                     // Can't do anything for namespace operators. Ignoring
                 }
             },
+            Some(Operator::HelixNext { around }) => {
+                self.select_next_object(object, around, window, cx);
+            }
+            Some(Operator::HelixPrevious { around }) => {
+                self.select_previous_object(object, around, window, cx);
+            }
             Some(Operator::DeleteSurrounds) => {
                 waiting_operator = Some(Operator::DeleteSurrounds);
             }

crates/vim/src/object.rs 🔗

@@ -397,11 +397,11 @@ impl Vim {
         let count = Self::take_count(cx);
 
         match self.mode {
-            Mode::Normal => self.normal_object(object, count, window, cx),
+            Mode::Normal | Mode::HelixNormal => self.normal_object(object, count, window, cx),
             Mode::Visual | Mode::VisualLine | Mode::VisualBlock => {
                 self.visual_object(object, count, window, cx)
             }
-            Mode::Insert | Mode::Replace | Mode::HelixNormal => {
+            Mode::Insert | Mode::Replace => {
                 // Shouldn't execute a text object in insert mode. Ignoring
             }
         }
@@ -1364,7 +1364,7 @@ fn is_sentence_end(map: &DisplaySnapshot, offset: usize) -> bool {
 
 /// Expands the passed range to include whitespace on one side or the other in a line. Attempts to add the
 /// whitespace to the end first and falls back to the start if there was none.
-fn expand_to_include_whitespace(
+pub fn expand_to_include_whitespace(
     map: &DisplaySnapshot,
     range: Range<DisplayPoint>,
     stop_at_newline: bool,

crates/vim/src/state.rs 🔗

@@ -134,6 +134,13 @@ pub enum Operator {
     ToggleComments,
     ReplaceWithRegister,
     Exchange,
+    HelixMatch,
+    HelixNext {
+        around: bool,
+    },
+    HelixPrevious {
+        around: bool,
+    },
 }
 
 #[derive(Default, Clone, Debug)]
@@ -1020,6 +1027,9 @@ impl Operator {
             Operator::RecordRegister => "q",
             Operator::ReplayRegister => "@",
             Operator::ToggleComments => "gc",
+            Operator::HelixMatch => "helix_m",
+            Operator::HelixNext { .. } => "helix_next",
+            Operator::HelixPrevious { .. } => "helix_previous",
         }
     }
 
@@ -1041,6 +1051,9 @@ impl Operator {
             } => format!("^V{}", make_visible(prefix)),
             Operator::AutoIndent => "=".to_string(),
             Operator::ShellCommand => "=".to_string(),
+            Operator::HelixMatch => "m".to_string(),
+            Operator::HelixNext { .. } => "]".to_string(),
+            Operator::HelixPrevious { .. } => "[".to_string(),
             _ => self.id().to_string(),
         }
     }
@@ -1079,7 +1092,10 @@ impl Operator {
             | Operator::Object { .. }
             | Operator::ChangeSurrounds { target: None }
             | Operator::OppositeCase
-            | Operator::ToggleComments => false,
+            | Operator::ToggleComments
+            | Operator::HelixMatch
+            | Operator::HelixNext { .. }
+            | Operator::HelixPrevious { .. } => false,
         }
     }
 
@@ -1103,7 +1119,9 @@ impl Operator {
             | Operator::AddSurrounds { target: None }
             | Operator::ChangeSurrounds { target: None }
             | Operator::DeleteSurrounds
-            | Operator::Exchange => true,
+            | Operator::Exchange
+            | Operator::HelixNext { .. }
+            | Operator::HelixPrevious { .. } => true,
             Operator::Yank
             | Operator::Object { .. }
             | Operator::FindForward { .. }
@@ -1118,7 +1136,8 @@ impl Operator {
             | Operator::Jump { .. }
             | Operator::Register
             | Operator::RecordRegister
-            | Operator::ReplayRegister => false,
+            | Operator::ReplayRegister
+            | Operator::HelixMatch => false,
         }
     }
 }

crates/vim/src/vim.rs 🔗

@@ -86,6 +86,22 @@ struct PushFindBackward {
     multiline: bool,
 }
 
+#[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)]
+#[action(namespace = vim)]
+#[serde(deny_unknown_fields)]
+/// Selects the next object.
+struct PushHelixNext {
+    around: bool,
+}
+
+#[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)]
+#[action(namespace = vim)]
+#[serde(deny_unknown_fields)]
+/// Selects the previous object.
+struct PushHelixPrevious {
+    around: bool,
+}
+
 #[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)]
 #[action(namespace = vim)]
 #[serde(deny_unknown_fields)]
@@ -224,6 +240,8 @@ actions!(
         PushReplaceWithRegister,
         /// Toggles comments.
         PushToggleComments,
+        /// Starts a match operation.
+        PushHelixMatch,
     ]
 );
 
@@ -761,6 +779,27 @@ impl Vim {
             Vim::action(editor, cx, |vim, _: &Enter, window, cx| {
                 vim.input_ignored("\n".into(), window, cx)
             });
+            Vim::action(editor, cx, |vim, _: &PushHelixMatch, window, cx| {
+                vim.push_operator(Operator::HelixMatch, window, cx)
+            });
+            Vim::action(editor, cx, |vim, action: &PushHelixNext, window, cx| {
+                vim.push_operator(
+                    Operator::HelixNext {
+                        around: action.around,
+                    },
+                    window,
+                    cx,
+                );
+            });
+            Vim::action(editor, cx, |vim, action: &PushHelixPrevious, window, cx| {
+                vim.push_operator(
+                    Operator::HelixPrevious {
+                        around: action.around,
+                    },
+                    window,
+                    cx,
+                );
+            });
 
             normal::register(editor, cx);
             insert::register(editor, cx);

crates/zed/src/zed.rs 🔗

@@ -4382,6 +4382,8 @@ mod tests {
                     | "vim::PushJump"
                     | "vim::PushDigraph"
                     | "vim::PushLiteral"
+                    | "vim::PushHelixNext"
+                    | "vim::PushHelixPrevious"
                     | "vim::Number"
                     | "vim::SelectRegister"
                     | "git::StageAndNext"

docs/src/helix.md 🔗

@@ -9,3 +9,7 @@ For a guide on Vim-related features that are also available in Helix mode, pleas
 To check the current status of Helix mode, or to request a missing Helix feature, checkout out the ["Are we Helix yet?" discussion](https://github.com/zed-industries/zed/discussions/33580).
 
 For a detailed list of Helix's default keybindings, please visit the [official Helix documentation](https://docs.helix-editor.com/keymap.html).
+
+## Core differences
+
+Any text object that works with `m i` or `m a` also works with `]` and `[`, so for example `] (` selects the next pair of parentheses after the cursor.