Add the ] and [ operators to helix

This commit is contained in:
fantacell 2025-07-19 15:31:58 +02:00
parent 69eaf04dad
commit 9b44bb6706
9 changed files with 504 additions and 71 deletions

View file

@ -1,3 +1,4 @@
mod boundary;
mod object;
mod select;

View file

@ -0,0 +1,184 @@
use std::{error::Error, fmt::Display};
use editor::{
DisplayPoint,
display_map::{DisplaySnapshot, ToDisplayPoint},
};
use language::{CharClassifier, CharKind};
use text::Bias;
use crate::object::Object;
#[derive(Debug)]
pub struct UnboundedErr;
impl Display for UnboundedErr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "object can't be found with simple boundary checking")
}
}
impl Error for UnboundedErr {}
impl Object {
/// Returns the beginning of the inside of the closest object after the cursor if it can easily be found. Follows helix convention;
pub fn helix_next_start(
self,
map: &DisplaySnapshot,
relative_to: DisplayPoint,
) -> Result<Option<DisplayPoint>, UnboundedErr> {
try_find_boundary(map, relative_to, |left, right| {
let classifier = map
.buffer_snapshot
.char_classifier_at(relative_to.to_point(map));
self.helix_is_start(right, left, classifier)
})
}
/// Returns the end of the inside of the closest object after the cursor if it can easily be found. Follows helix convention;
pub fn helix_next_end(
self,
map: &DisplaySnapshot,
relative_to: DisplayPoint,
) -> Result<Option<DisplayPoint>, UnboundedErr> {
try_find_boundary(map, relative_to, |left, right| {
let classifier = map
.buffer_snapshot
.char_classifier_at(relative_to.to_point(map));
self.helix_is_end(right, left, classifier)
})
}
/// Returns the beginning of the inside of the closest object before the cursor if it can easily be found. Follows helix convention;
pub fn helix_previous_start(
self,
map: &DisplaySnapshot,
relative_to: DisplayPoint,
) -> Result<Option<DisplayPoint>, UnboundedErr> {
try_find_preceding_boundary(map, relative_to, |left, right| {
let classifier = map
.buffer_snapshot
.char_classifier_at(relative_to.to_point(map));
self.helix_is_start(right, left, classifier)
})
}
/// Returns the end of the inside of the closest object before the cursor if it can easily be found. Follows helix convention;
pub fn helix_previous_end(
self,
map: &DisplaySnapshot,
relative_to: DisplayPoint,
) -> Result<Option<DisplayPoint>, UnboundedErr> {
try_find_preceding_boundary(map, relative_to, |left, right| {
let classifier = map
.buffer_snapshot
.char_classifier_at(relative_to.to_point(map));
self.helix_is_end(right, left, classifier)
})
}
fn helix_is_start(
self,
right: char,
left: char,
classifier: CharClassifier,
) -> Result<bool, UnboundedErr> {
match self {
Self::Word { ignore_punctuation } => {
let classifier = classifier.ignore_punctuation(ignore_punctuation);
Ok(is_word_start(left, right, classifier))
}
Self::Subword { ignore_punctuation } => {
todo!()
}
Self::AngleBrackets => Ok(left == '<'),
Self::BackQuotes => Ok(left == '`'),
Self::CurlyBrackets => Ok(left == '{'),
Self::DoubleQuotes => Ok(left == '"'),
Self::Parentheses => Ok(left == '('),
Self::SquareBrackets => Ok(left == '['),
Self::VerticalBars => Ok(left == '|'),
_ => Err(UnboundedErr),
}
}
fn helix_is_end(
self,
right: char,
left: char,
classifier: CharClassifier,
) -> Result<bool, UnboundedErr> {
match self {
Self::Word { ignore_punctuation } => {
let classifier = classifier.ignore_punctuation(ignore_punctuation);
Ok(is_word_end(left, right, classifier))
}
Self::Subword { ignore_punctuation } => {
todo!()
}
Self::AngleBrackets => Ok(right == '>'),
Self::BackQuotes => Ok(right == '`'),
Self::CurlyBrackets => Ok(right == '}'),
Self::DoubleQuotes => Ok(right == '"'),
Self::Parentheses => Ok(right == ')'),
Self::SquareBrackets => Ok(right == ']'),
Self::VerticalBars => Ok(right == '|'),
Self::Sentence => Ok(left == '.'),
_ => Err(UnboundedErr),
}
}
}
fn try_find_boundary(
map: &DisplaySnapshot,
from: DisplayPoint,
mut is_boundary: impl FnMut(char, char) -> Result<bool, UnboundedErr>,
) -> Result<Option<DisplayPoint>, UnboundedErr> {
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) {
if is_boundary(prev_ch, ch)? {
return Ok(Some(
map.clip_point(offset.to_display_point(map), Bias::Right),
));
}
offset += ch.len_utf8();
prev_ch = ch;
}
Ok(None)
}
fn try_find_preceding_boundary(
map: &DisplaySnapshot,
from: DisplayPoint,
mut is_boundary: impl FnMut(char, char) -> Result<bool, UnboundedErr>,
) -> Result<Option<DisplayPoint>, UnboundedErr> {
let mut offset = from.to_offset(map, Bias::Right);
let mut prev_ch = map.buffer_snapshot.chars_at(offset).next().unwrap_or('\0');
for ch in map.buffer_snapshot.reversed_chars_at(offset) {
if is_boundary(ch, prev_ch)? {
return Ok(Some(
map.clip_point(offset.to_display_point(map), Bias::Right),
));
}
offset -= ch.len_utf8();
prev_ch = ch;
}
Ok(None)
}
fn is_buffer_start(left: char) -> bool {
left == '\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
}

View file

@ -1,20 +1,16 @@
use std::ops::Range;
use std::{cmp::Ordering, ops::Range};
use editor::{
DisplayPoint,
display_map::DisplaySnapshot,
movement::{self, FindRange},
movement::{self},
};
use language::CharKind;
use text::{Bias, Selection};
use text::Selection;
use crate::{
motion::right,
object::{Object, expand_to_include_whitespace},
};
use crate::{helix::boundary::UnboundedErr, object::Object};
impl Object {
/// Returns
/// Returns the range of the object the cursor is over.
/// Follows helix convention.
pub fn helix_range(
self,
@ -23,65 +19,204 @@ impl Object {
around: bool,
) -> Option<Range<DisplayPoint>> {
let relative_to = selection.head();
match self {
Object::Word { ignore_punctuation } => {
if around {
helix_around_word(map, relative_to, ignore_punctuation)
} else {
helix_in_word(map, relative_to, ignore_punctuation)
}
if let Ok(selection) = self.current_bounded_object(map, relative_to) {
if around {
selection.map(|s| self.surround(map, s).unwrap())
} else {
selection
}
} else {
let head = selection.head();
let range = self.range(map, selection, around, None)?;
if range.start > head {
None
} else {
Some(range)
}
_ => self.range(map, selection, around, None),
}
}
}
/// Returns a range that surrounds the word `relative_to` is in.
///
/// If `relative_to` is between words, return `None`.
fn helix_in_word(
map: &DisplaySnapshot,
relative_to: DisplayPoint,
ignore_punctuation: bool,
) -> Option<Range<DisplayPoint>> {
// Use motion::right so that we consider the character under the cursor when looking for the start
let classifier = map
.buffer_snapshot
.char_classifier_at(relative_to.to_point(map))
.ignore_punctuation(ignore_punctuation);
let char = map
.buffer_chars_at(relative_to.to_offset(map, Bias::Left))
.next()?
.0;
/// Returns the range of the next object the cursor is not over.
/// Follows helix convention.
pub fn helix_next_range(
self,
map: &DisplaySnapshot,
selection: Selection<DisplayPoint>,
around: bool,
) -> Option<Range<DisplayPoint>> {
let relative_to = selection.head();
if let Ok(selection) = self.next_bounded_object(map, relative_to) {
if around {
selection.map(|s| self.surround(map, s).unwrap())
} else {
selection
}
} else {
let head = selection.head();
let range = self.range(map, selection, around, None)?;
if classifier.kind(char) == CharKind::Whitespace {
return None;
if range.start > head {
Some(range)
} else {
None
}
}
}
let start = movement::find_preceding_boundary_display_point(
map,
right(map, relative_to, 1),
movement::FindRange::SingleLine,
|left, right| classifier.kind(left) != classifier.kind(right),
);
/// Returns the range of the previous object the cursor is not over.
/// Follows helix convention.
pub fn helix_previous_range(
self,
map: &DisplaySnapshot,
selection: Selection<DisplayPoint>,
around: bool,
) -> Option<Range<DisplayPoint>> {
let relative_to = selection.head();
if let Ok(selection) = self.previous_bounded_object(map, relative_to) {
if around {
selection.map(|s| self.surround(map, s).unwrap())
} else {
selection
}
} else {
None
}
}
let end = movement::find_boundary(map, relative_to, FindRange::SingleLine, |left, right| {
classifier.kind(left) != classifier.kind(right)
});
/// Returns the range of the object the cursor is over if it can be found with simple boundary checking. Potentially none. Follows helix convention.
fn current_bounded_object(
self,
map: &DisplaySnapshot,
relative_to: DisplayPoint,
) -> Result<Option<Range<DisplayPoint>>, UnboundedErr> {
let maybe_prev_end = self.helix_previous_end(map, relative_to)?;
let Some(prev_start) = self.helix_previous_start(map, relative_to)? else {
return Ok(None);
};
let Some(next_end) = self.helix_next_end(map, movement::right(map, relative_to))? else {
return Ok(None);
};
let maybe_next_start = self.helix_next_start(map, movement::right(map, relative_to))?;
Some(start..end)
}
if let Some(next_start) = maybe_next_start {
match next_start.cmp(&next_end) {
Ordering::Less => return Ok(None),
Ordering::Equal if self.can_be_zero_width() => return Ok(None),
_ => (),
}
}
if let Some(prev_end) = maybe_prev_end {
if prev_start == prev_end && self.can_be_zero_width() {
return Ok(None);
}
debug_assert!(prev_end <= prev_start)
}
/// Returns the range of the word the cursor is over and all the whitespace on one side.
/// If there is whitespace after that is included, otherwise it's whitespace before the word if any.
fn helix_around_word(
map: &DisplaySnapshot,
relative_to: DisplayPoint,
ignore_punctuation: bool,
) -> Option<Range<DisplayPoint>> {
let word_range = helix_in_word(map, relative_to, ignore_punctuation)?;
Ok(Some(prev_start..next_end))
}
Some(expand_to_include_whitespace(map, word_range, true))
/// Returns the range of the next object the cursor is not over if it can be found with simple boundary checking. Potentially none. Follows helix convention.
fn next_bounded_object(
self,
map: &DisplaySnapshot,
relative_to: DisplayPoint,
) -> Result<Option<Range<DisplayPoint>>, UnboundedErr> {
let Some(next_start) = self.helix_next_start(map, movement::right(map, relative_to))?
else {
return Ok(None);
};
let search_start = if self.can_be_zero_width() {
next_start
} else {
movement::right(map, next_start)
};
let Some(end) = self.helix_next_end(map, search_start)? else {
return Ok(None);
};
Ok(Some(next_start..end))
}
/// Returns the previous range of the object the cursor not is over if it can be found with simple boundary checking. Potentially none. Follows helix convention.
fn previous_bounded_object(
self,
map: &DisplaySnapshot,
relative_to: DisplayPoint,
) -> Result<Option<Range<DisplayPoint>>, UnboundedErr> {
let Some(prev_end) = self.helix_previous_end(map, relative_to)? else {
return Ok(None);
};
let search_start = if self.can_be_zero_width() {
prev_end
} else {
movement::left(map, prev_end)
};
let Some(start) = self.helix_previous_start(map, search_start)? else {
return Ok(None);
};
Ok(Some(start..prev_end))
}
/// Switches from an 'mi' range to an 'ma' range. Follows helix convention.
fn surround(
self,
map: &DisplaySnapshot,
selection: Range<DisplayPoint>,
) -> Result<Range<DisplayPoint>, UnboundedErr> {
match self {
Self::Word { .. } | Self::Subword { .. } => {
let row = selection.end.row();
let line_start = DisplayPoint::new(row, 0);
let line_end = DisplayPoint::new(row, map.line_len(row));
let next_start = self
.helix_next_start(map, selection.end)
.unwrap()
.unwrap()
.min(line_end);
let prev_end = self
.helix_previous_end(map, selection.start)
.unwrap()
.unwrap()
.max(line_start);
if next_start > selection.end {
Ok(selection.start..next_start)
} else {
Ok(prev_end..selection.end)
}
}
Self::AngleBrackets
| Self::BackQuotes
| Self::CurlyBrackets
| Self::DoubleQuotes
| Self::Parentheses
| Self::SquareBrackets
| Self::VerticalBars => {
Ok(movement::left(map, selection.start)..movement::right(map, selection.end))
}
_ => Err(UnboundedErr),
}
}
const fn can_be_zero_width(&self) -> bool {
match self {
Self::AngleBrackets
| Self::AnyBrackets
| Self::AnyQuotes
| Self::BackQuotes
| Self::CurlyBrackets
| Self::DoubleQuotes
| Self::EntireFile
| Self::MiniBrackets
| Self::MiniQuotes
| Self::Parentheses
| Self::Quotes
| Self::SquareBrackets
| Self::VerticalBars => true,
_ => false,
}
}
}
#[cfg(test)]

View file

@ -4,8 +4,9 @@ use ui::{Context, Window};
use crate::{Vim, object::Object};
impl Vim {
/// Selects the text object each cursor is over.
pub fn select_object(
/// Selects the object each cursor is over.
/// Follows helix convention.
pub fn select_current_object(
&mut self,
object: Object,
around: bool,
@ -20,8 +21,55 @@ impl Vim {
return;
};
selection.set_head(range.end, SelectionGoal::None);
selection.start = range.start;
selection.set_tail_head(range.start, range.end, SelectionGoal::None);
});
});
});
}
/// Selects the next object from each cursor which the cursor is not over.
/// Follows helix convention.
pub fn select_next_object(
&mut self,
object: Object,
around: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.stop_recording(cx);
self.update_editor(window, cx, |_, editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(|map, selection| {
let Some(range) = object.helix_next_range(map, selection.clone(), around)
else {
return;
};
selection.set_tail_head(range.start, range.end, SelectionGoal::None);
});
});
});
}
/// Selects the previous object from each cursor which the cursor is not over.
/// Follows helix convention.
pub fn select_previous_object(
&mut self,
object: Object,
around: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.stop_recording(cx);
self.update_editor(window, cx, |_, editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(|map, selection| {
let Some(range) = object.helix_previous_range(map, selection.clone(), around)
else {
return;
};
selection.set_tail_head(range.start, range.end, SelectionGoal::None);
});
});
});

View file

@ -479,7 +479,13 @@ 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_object(object, around, window, cx),
Some(Operator::HelixMatch) => {
self.select_current_object(object, around, window, cx)
}
Some(Operator::SelectNext) => self.select_next_object(object, around, window, cx),
Some(Operator::SelectPrevious) => {
self.select_previous_object(object, around, window, cx)
}
_ => {
// Can't do anything for namespace operators. Ignoring
}

View file

@ -133,6 +133,8 @@ pub enum Operator {
ReplaceWithRegister,
Exchange,
HelixMatch,
SelectNext,
SelectPrevious,
}
#[derive(Default, Clone, Debug)]
@ -1026,6 +1028,8 @@ impl Operator {
Operator::ReplayRegister => "@",
Operator::ToggleComments => "gc",
Operator::HelixMatch => "helix_m",
Operator::SelectNext { .. } => "helix_]",
Operator::SelectPrevious { .. } => "helix_[",
}
}
@ -1079,7 +1083,9 @@ impl Operator {
| Operator::ChangeSurrounds { target: None }
| Operator::OppositeCase
| Operator::ToggleComments
| Operator::HelixMatch => false,
| Operator::HelixMatch
| Operator::SelectNext { .. }
| Operator::SelectPrevious { .. } => false,
}
}
@ -1103,7 +1109,9 @@ impl Operator {
| Operator::AddSurrounds { target: None }
| Operator::ChangeSurrounds { target: None }
| Operator::DeleteSurrounds
| Operator::Exchange => true,
| Operator::Exchange
| Operator::SelectNext { .. }
| Operator::SelectPrevious { .. } => true,
Operator::Yank
| Operator::Object { .. }
| Operator::FindForward { .. }

View file

@ -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)]
@ -768,6 +784,26 @@ impl Vim {
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::SelectNext, window, cx);
vim.push_operator(
Operator::Object {
around: action.around,
},
window,
cx,
)
});
Vim::action(editor, cx, |vim, action: &PushHelixPrevious, window, cx| {
vim.push_operator(Operator::SelectPrevious, window, cx);
vim.push_operator(
Operator::Object {
around: action.around,
},
window,
cx,
);
});
normal::register(editor, cx);
insert::register(editor, cx);