diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 67add61bd3..e6623552f2 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", + "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" } }, { @@ -388,6 +390,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", @@ -410,13 +415,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", @@ -460,9 +458,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", @@ -531,7 +526,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 }], @@ -568,6 +563,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 7a008e3ba2..cf2d37a110 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; @@ -303,15 +303,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 { @@ -361,15 +364,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 726022021d..3040e3e633 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, SelectionEffects, ToOffset, ToPoint, movement}; use gpui::{Action, actions}; diff --git a/crates/vim/src/helix/boundary.rs b/crates/vim/src/helix/boundary.rs new file mode 100644 index 0000000000..ea00663772 --- /dev/null +++ b/crates/vim/src/helix/boundary.rs @@ -0,0 +1,744 @@ +use std::{cmp::Ordering, ops::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: DisplayPoint, + 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: DisplayPoint, + 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: DisplayPoint, + 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: DisplayPoint, + outer: bool, + ) -> Option; + + /// Whether the range inside or outside the object can have be zero characters wide. + /// If so, the trait assumes that these ranges can't be directly adjacent to each other. + fn can_be_zero_width(&self, around: bool) -> bool; + /// Whether the "ma" can exceed the "mi" range on both sides at the same time + fn surround_on_both_sides(&self) -> bool; + + /// 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: DisplayPoint, + map: &DisplaySnapshot, + outer: bool, + ) -> Option { + let mut end_search_start = if self.can_be_zero_width(outer) { + start + } else { + movement::right(map, start) + }; + let mut start_search_start = movement::right(map, start); + 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))) + { + let closing = self.close_at_end(next_start, map, outer)?; + end_search_start = movement::right(map, closing); + start_search_start = if self.can_be_zero_width(outer) { + movement::right(map, closing) + } else { + closing + }; + continue; + } else { + return Some(next_end); + } + } + } + /// The previous start since `end` (inclusive) on the same nesting level. + fn close_at_start( + &self, + end: DisplayPoint, + map: &DisplaySnapshot, + outer: bool, + ) -> Option { + let mut start_search_start = if self.can_be_zero_width(outer) { + end + } else { + movement::left(map, end) + }; + let mut end_search_start = movement::left(map, end); + loop { + let prev_start = self.previous_start(map, start_search_start, outer)?; + let maybe_prev_end = self.previous_end(map, end_search_start, outer); + if let Some(prev_end) = maybe_prev_end + && ((prev_end > prev_start) + || (prev_end == prev_start && self.can_be_zero_width(outer))) + { + let closing = self.close_at_start(prev_end, map, outer)?; + end_search_start = if self.can_be_zero_width(outer) { + movement::left(map, closing) + } else { + closing + }; + start_search_start = movement::left(map, closing); + continue; + } else { + return Some(prev_start); + } + } + } +} + +impl HelixTextObject for B { + fn range( + &self, + map: &DisplaySnapshot, + relative_to: Range, + around: bool, + ) -> Option> { + let search_start = if self.can_be_zero_width(true) { + relative_to.start + } else { + // If the objects can be directly next to each other an object start at the + // cursor (relative_to) start would not count for close_at_start, so the search + // needs to start one character to the left. + movement::right(map, relative_to.start) + }; + let min_start = self.close_at_start(search_start, map, self.surround_on_both_sides())?; + let max_end = self.close_at_end(min_start, map, self.surround_on_both_sides())?; + + if max_end < relative_to.end { + return None; + } + + if around && !self.surround_on_both_sides() { + // max_end is not yet the outer end + Some(self.around(map, min_start..max_end)) + } else if !around && self.surround_on_both_sides() { + // max_end is the outer end, but the final result should have the inner end + Some(self.inside(map, min_start..max_end)) + } else { + Some(min_start..max_end) + } + } + + fn next_range( + &self, + map: &DisplaySnapshot, + relative_to: Range, + around: bool, + ) -> Option> { + let min_start = self.next_start(map, relative_to.end, self.surround_on_both_sides())?; + let max_end = self.close_at_end(min_start, map, self.surround_on_both_sides())?; + + if around && !self.surround_on_both_sides() { + // max_end is not yet the outer end + Some(self.around(map, min_start..max_end)) + } else if !around && self.surround_on_both_sides() { + // max_end is the outer end, but the final result should have the inner end + Some(self.inside(map, min_start..max_end)) + } else { + Some(min_start..max_end) + } + } + + fn previous_range( + &self, + map: &DisplaySnapshot, + relative_to: Range, + around: bool, + ) -> Option> { + let max_end = self.previous_end(map, relative_to.start, self.surround_on_both_sides())?; + let min_start = self.close_at_start(max_end, map, self.surround_on_both_sides())?; + + if around && !self.surround_on_both_sides() { + // max_end is not yet the outer end + Some(self.around(map, min_start..max_end)) + } else if !around && self.surround_on_both_sides() { + // max_end is the outer end, but the final result should have the inner end + Some(self.inside(map, min_start..max_end)) + } else { + Some(min_start..max_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: DisplayPoint, + outer: bool, + ) -> Option { + try_find_boundary(map, from, |left, right| { + let classifier = map + .buffer_snapshot + .char_classifier_at(from.to_offset(map, Bias::Left)); + if outer { + self.is_outer_start(left, right, classifier) + } else { + self.is_inner_start(left, right, classifier) + } + }) + } + fn next_end( + &self, + map: &DisplaySnapshot, + from: DisplayPoint, + outer: bool, + ) -> Option { + try_find_boundary(map, from, |left, right| { + let classifier = map + .buffer_snapshot + .char_classifier_at(from.to_offset(map, Bias::Left)); + if outer { + self.is_outer_end(left, right, classifier) + } else { + self.is_inner_end(left, right, classifier) + } + }) + } + fn previous_start( + &self, + map: &DisplaySnapshot, + from: DisplayPoint, + outer: bool, + ) -> Option { + try_find_preceding_boundary(map, from, |left, right| { + let classifier = map + .buffer_snapshot + .char_classifier_at(from.to_offset(map, Bias::Left)); + if outer { + self.is_outer_start(left, right, classifier) + } else { + self.is_inner_start(left, right, classifier) + } + }) + } + fn previous_end( + &self, + map: &DisplaySnapshot, + from: DisplayPoint, + outer: bool, + ) -> Option { + try_find_preceding_boundary(map, from, |left, right| { + let classifier = map + .buffer_snapshot + .char_classifier_at(from.to_offset(map, Bias::Left)); + if outer { + self.is_outer_end(left, right, classifier) + } else { + self.is_inner_end(left, right, classifier) + } + }) + } + fn can_be_zero_width(&self, around: bool) -> bool { + match self { + Self::Subword { .. } | Self::Word { .. } => false, + _ => !around, + } + } + fn surround_on_both_sides(&self) -> bool { + match self { + Self::Subword { .. } | Self::Word { .. } => false, + _ => true, + } + } +} + +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: DisplayPoint, + outer: bool, + backward: bool, + boundary_kind: Boundary, + ) -> Option { + let generate_boundary_data = |left, right, point: DisplayPoint| { + let classifier = map + .buffer_snapshot + .char_classifier_at(point.to_offset(map, Bias::Left)); + 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() + } else { + boundaries.min() + } + } +} + +#[derive(PartialEq)] +enum Boundary { + Start, + End, +} + +impl BoundedObject for FuzzyBoundary { + fn next_start( + &self, + map: &DisplaySnapshot, + from: DisplayPoint, + outer: bool, + ) -> Option { + self.to_boundary(map, from, outer, false, Boundary::Start) + } + fn next_end( + &self, + map: &DisplaySnapshot, + from: DisplayPoint, + outer: bool, + ) -> Option { + self.to_boundary(map, from, outer, false, Boundary::End) + } + fn previous_start( + &self, + map: &DisplaySnapshot, + from: DisplayPoint, + outer: bool, + ) -> Option { + self.to_boundary(map, from, outer, true, Boundary::Start) + } + fn previous_end( + &self, + map: &DisplaySnapshot, + from: DisplayPoint, + outer: bool, + ) -> Option { + self.to_boundary(map, from, outer, true, Boundary::End) + } + fn can_be_zero_width(&self, _: bool) -> bool { + false + } + fn surround_on_both_sides(&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: DisplayPoint, + 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, + from: DisplayPoint, + boundary_information: impl Fn(char, char, DisplayPoint) -> Option, +) -> Option { + let mut offset = from.to_offset(map, Bias::Right); + let mut prev_ch = map + .buffer_snapshot + .reversed_chars_at(offset) + .next() + .unwrap_or('\0'); + + for ch in map.buffer_snapshot.chars_at(offset).chain(['\0']) { + let display_point = offset.to_display_point(map); + if let Some(boundary_information) = boundary_information(prev_ch, ch, display_point) { + return Some(boundary_information); + } + offset += 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: DisplayPoint, + 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, + from: DisplayPoint, + is_boundary: impl Fn(char, char, DisplayPoint) -> Option, +) -> Option { + let mut offset = from.to_offset(map, Bias::Left); + let mut prev_ch = map.buffer_snapshot.chars_at(offset).next().unwrap_or('\0'); + + for ch in map.buffer_snapshot.reversed_chars_at(offset).chain(['\0']) { + let display_point = offset.to_display_point(map); + if let Some(boundary_information) = is_boundary(ch, prev_ch, display_point) { + return Some(boundary_information); + } + offset = offset.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 0000000000..798cd7162e --- /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 0000000000..d782e8b450 --- /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 0c7b6e55a1..b8d1325a8b 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 693de9f697..394b6b1033 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 fe4bc7433d..8503bffca6 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 9da01e6f44..8e2a6c5388 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -84,6 +84,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)] @@ -222,6 +238,8 @@ actions!( PushReplaceWithRegister, /// Toggles comments. PushToggleComments, + /// Starts a match operation. + PushHelixMatch, ] ); @@ -759,6 +777,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 553444ebdb..45bbf6e280 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4356,6 +4356,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 ddf997d3f0..37eaf516c9 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 + +Text object motions like `mi|` work, even if the cursor is on the `|` itself. Also, any text object that work with `mi` or `ma` also works with `]` and `[`, so for example `](` selects the next pair of parentheses after the cursor.