From 10989c702ca708cf9eb9ef45126351e9bad944b3 Mon Sep 17 00:00:00 2001 From: fantacell Date: Mon, 8 Sep 2025 16:48:47 +0200 Subject: [PATCH] helix: Add match operator (#34060) 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 --- 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, 1196 insertions(+), 69 deletions(-) create mode 100644 crates/vim/src/helix/boundary.rs create mode 100644 crates/vim/src/helix/object.rs create mode 100644 crates/vim/src/helix/select.rs diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index fa7f82e1032ead9cb1f1ce12f3484602954123ca..508d8949d2e38bfcf324ad611461d7077621a301 100644 --- a/assets/keymaps/vim.json +++ b/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": { diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 216bea169683409b219641cc3496de9280bb05f6..4bd353a2873431d8102dfc15dea9a74ac2b2c241 100644 --- a/crates/editor/src/movement.rs +++ b/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( diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index abde3a8ce6e8755bb49826fb408a6af36661f00c..34ef2b40ec1bad03957a22ef66c40f6f53697699 100644 --- a/crates/vim/src/helix.rs +++ b/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, diff --git a/crates/vim/src/helix/boundary.rs b/crates/vim/src/helix/boundary.rs new file mode 100644 index 0000000000000000000000000000000000000000..77d31eea3327a853d21a68065e0adc6230ad2c5a --- /dev/null +++ b/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; + /// 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; + /// 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; + /// 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; + + /// 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) -> Range { + 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) -> Range { + 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 { + 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 { + 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 { + let next = Self(map.buffer_snapshot.clip_offset(*self + 1, Bias::Right)); + (*next > *self).then(|| next) + } + fn previous(self, map: &DisplaySnapshot) -> Option { + 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(start.0.to_offset(map, start.1))..Self(end.0.to_offset(map, end.1)) + } +} + +impl HelixTextObject for B { + fn range( + &self, + map: &DisplaySnapshot, + relative_to: Range, + around: bool, + ) -> Option> { + 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, + around: bool, + ) -> Option> { + 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, + around: bool, + ) -> Option> { + 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( + object: &B, + outer: bool, + map: &DisplaySnapshot, + find_range: impl Fn(bool) -> Option>, +) -> Option> { + // 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 { + 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 { + 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 { + 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 { + 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 Option>> { + 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 Option>> { + 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 Option>> { + 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 Option>> { + 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 { + 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 { + self.to_boundary(map, from, outer, false, Boundary::Start) + } + fn next_end(&self, map: &DisplaySnapshot, from: Offset, outer: bool) -> Option { + self.to_boundary(map, from, outer, false, Boundary::End) + } + fn previous_start(&self, map: &DisplaySnapshot, from: Offset, outer: bool) -> Option { + self.to_boundary(map, from, outer, true, Boundary::Start) + } + fn previous_end(&self, map: &DisplaySnapshot, from: Offset, outer: bool) -> Option { + 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 { + 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( + map: &DisplaySnapshot, + mut from: Offset, + boundary_information: impl Fn(char, char, Offset) -> Option, +) -> Option { + 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 { + 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( + map: &DisplaySnapshot, + mut from: Offset, + is_boundary: impl Fn(char, char, Offset) -> Option, +) -> Option { + 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) +} diff --git a/crates/vim/src/helix/object.rs b/crates/vim/src/helix/object.rs new file mode 100644 index 0000000000000000000000000000000000000000..798cd7162eb58a56c7362aa5fdcb37a33d48daa8 --- /dev/null +++ b/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, + around: bool, + ) -> Option>; + + fn next_range( + &self, + map: &DisplaySnapshot, + relative_to: Range, + around: bool, + ) -> Option>; + + fn previous_range( + &self, + map: &DisplaySnapshot, + relative_to: Range, + around: bool, + ) -> Option>; +} + +impl VimObject { + /// Returns the range of the object the cursor is over. + /// Follows helix convention. + pub fn helix_range( + self, + map: &DisplaySnapshot, + selection: Selection, + around: bool, + ) -> Result>, 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, + around: bool, + ) -> Result>, 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, + around: bool, + ) -> Result>, 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> { + 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, + map: &DisplaySnapshot, +) -> Range { + 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, + ); + } +} diff --git a/crates/vim/src/helix/select.rs b/crates/vim/src/helix/select.rs new file mode 100644 index 0000000000000000000000000000000000000000..d782e8b4505691060b0a0898f9a71047ed7956cf --- /dev/null +++ b/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.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.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.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); + }); + }); + }); + } +} diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 0c7b6e55a10f60f673cc44dddd0710f03a7d0435..b8d1325a8b19aaa2dcbc2611b2ff66df721c17f3 100644 --- a/crates/vim/src/normal.rs +++ b/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); } diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 366acb740bca32f5e191dd22309dd026c0d7ddd3..430149cada78b5deb08f3df551aff480f68ce992 100644 --- a/crates/vim/src/object.rs +++ b/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, stop_at_newline: bool, diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index fe4bc7433d57f882b9935cfd547fab6e2eb736c1..8503bffca6ec120f1103fa1dcd72281c092ac941 100644 --- a/crates/vim/src/state.rs +++ b/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, } } } diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index f4f8de2e7800732bb0a278bbc37928c58002ec7d..fdf18dfef98c151a6801f2c73336e04df2ac89bb 100644 --- a/crates/vim/src/vim.rs +++ b/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); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 91ec021c44babb126cc33fad65131f100e285443..efe2d031a6f995f7cad36f383a4be5c2ee773d56 100644 --- a/crates/zed/src/zed.rs +++ b/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" diff --git a/docs/src/helix.md b/docs/src/helix.md index ddf997d3f085007176c0448af749229a3e1a6168..467a2fac7c373612bb867cc14f4b8a7a296ea9bd 100644 --- a/docs/src/helix.md +++ b/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.