diff --git a/helix-core/src/textobject.rs b/helix-core/src/textobject.rs index 24f063d447dd..499953b05551 100644 --- a/helix-core/src/textobject.rs +++ b/helix-core/src/textobject.rs @@ -1,26 +1,32 @@ use std::fmt::Display; -use ropey::RopeSlice; +use ropey::{iter::Chars, RopeSlice}; use tree_sitter::{Node, QueryCursor}; -use crate::chars::{categorize_char, char_is_whitespace, CharCategory}; -use crate::graphemes::next_grapheme_boundary; +use crate::chars::{categorize_char, char_is_line_ending, char_is_whitespace, CharCategory}; +use crate::graphemes::{ + next_grapheme_boundary, nth_prev_grapheme_boundary, prev_grapheme_boundary, +}; use crate::movement::Direction; use crate::surround; use crate::syntax::LanguageConfiguration; use crate::Range; -fn find_word_boundary(slice: RopeSlice, mut pos: usize, direction: Direction, long: bool) -> usize { - use CharCategory::{Eol, Whitespace}; - - let iter = match direction { +fn chars_from_direction<'a>(slice: &'a RopeSlice, pos: usize, direction: Direction) -> Chars<'a> { + match direction { Direction::Forward => slice.chars_at(pos), Direction::Backward => { let mut iter = slice.chars_at(pos); iter.reverse(); iter } - }; + } +} + +fn find_word_boundary(slice: RopeSlice, mut pos: usize, direction: Direction, long: bool) -> usize { + use CharCategory::{Eol, Whitespace}; + + let iter = chars_from_direction(&slice, pos, direction); let mut prev_category = match direction { Direction::Forward if pos == 0 => Whitespace, @@ -49,6 +55,53 @@ fn find_word_boundary(slice: RopeSlice, mut pos: usize, direction: Direction, lo pos } +pub fn find_paragraph_boundary(slice: RopeSlice, mut pos: usize, direction: Direction) -> usize { + // if pos is at non-empty line ending or when going forward move one character left + if (!char_is_line_ending(slice.char(prev_grapheme_boundary(slice, pos))) + || direction == Direction::Forward) + && char_is_line_ending(slice.char(pos.min(slice.len_chars().saturating_sub(1)))) + { + pos = pos.saturating_sub(1); + } + + let prev_line_ending = match direction { + Direction::Forward => { + char_is_line_ending(slice.char(nth_prev_grapheme_boundary(slice, pos, 2))) + && char_is_line_ending(slice.char(prev_grapheme_boundary(slice, pos))) + } + Direction::Backward if pos == slice.len_chars() => true, + Direction::Backward => { + char_is_line_ending(slice.char(prev_grapheme_boundary(slice, pos))) + && char_is_line_ending(slice.char(pos)) + } + }; + + // keep finding for two consecutive different line ending + // have to subtract later since we take past one or more cycle + // TODO swap this to use grapheme so \r\n works + let mut found = true; + let iter = chars_from_direction(&slice, pos, direction).take_while(|&c| { + let now = prev_line_ending == char_is_line_ending(c); + let ret = found || now; // stops when both is different + found = now; + ret + }); + let count = iter.count(); + // count will be subtracted by extra whitespace due to interator + match direction { + Direction::Forward if pos + count == slice.len_chars() => slice.len_chars(), + // subtract by 1 due to extra \n when going forward + Direction::Forward if prev_line_ending => pos + count.saturating_sub(1), + Direction::Forward => pos + count, + // iterator exhausted so it should be 0 + Direction::Backward if pos.saturating_sub(count) == 0 => 0, + // subtract by 2 because it starts with \n and have 2 extra \n when going backwards + Direction::Backward if prev_line_ending => pos.saturating_sub(count.saturating_sub(2)), + // subtract by 1 due to extra \n when going backward + Direction::Backward => pos.saturating_sub(count.saturating_sub(1)), + } +} + #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub enum TextObject { Around, @@ -107,6 +160,44 @@ pub fn textobject_word( } } +pub fn textobject_paragraph( + slice: RopeSlice, + range: Range, + textobject: TextObject, + _count: usize, +) -> Range { + let pos = range.cursor(slice); + + let paragraph_start = find_paragraph_boundary(slice, pos, Direction::Backward); + let paragraph_end = match slice.get_char(pos) { + Some(_) => find_paragraph_boundary(slice, pos + 1, Direction::Forward), + None => pos, + }; + + match textobject { + TextObject::Inside => Range::new(paragraph_start, paragraph_end), + TextObject::Around => Range::new( + // if it is at the end of the document and only matches newlines, + // it search backward one step + if slice.get_char(paragraph_start.saturating_sub(1)).is_some() + && slice.get_char(paragraph_end).is_none() + { + find_paragraph_boundary( + slice, + paragraph_start.saturating_sub(1), + Direction::Backward, + ) + } else { + paragraph_start + }, + match slice.get_char(paragraph_end) { + Some(_) => find_paragraph_boundary(slice, paragraph_end + 1, Direction::Forward), + None => paragraph_end, + }, + ), + } +} + pub fn textobject_surround( slice: RopeSlice, range: Range, @@ -281,6 +372,63 @@ mod test { } } + #[test] + fn test_textobject_paragraph() { + // (text, [(cursor position, textobject, final range), ...]) + let tests = &[ + ("\n", vec![(0, Inside, (0, 1)), (0, Around, (0, 1))]), + ( + "p1\np1\n\np2\np2\n\n", + vec![ + (0, Inside, (0, 6)), + (0, Around, (0, 7)), + (1, Inside, (0, 6)), + (1, Around, (0, 7)), + (2, Inside, (0, 6)), + (2, Around, (0, 7)), + (3, Inside, (0, 6)), + (3, Around, (0, 7)), + (4, Inside, (0, 6)), + (4, Around, (0, 7)), + (5, Inside, (0, 6)), + (5, Around, (0, 7)), + (6, Inside, (6, 7)), + (6, Around, (6, 13)), + (7, Inside, (7, 13)), + (7, Around, (7, 14)), + (8, Inside, (7, 13)), + (8, Around, (7, 14)), + (9, Inside, (7, 13)), + (9, Around, (7, 14)), + (10, Inside, (7, 13)), + (10, Around, (7, 14)), + (11, Inside, (7, 13)), + (11, Around, (7, 14)), + (12, Inside, (7, 13)), + (12, Around, (7, 14)), + (13, Inside, (13, 14)), + (13, Around, (7, 14)), + ], + ), + ]; + + for (sample, scenario) in tests { + let doc = Rope::from(*sample); + let slice = doc.slice(..); + for &case in scenario { + let (pos, objtype, expected_range) = case; + let result = textobject_paragraph(slice, Range::point(pos), objtype, 1); + assert_eq!( + result, + expected_range.into(), + "\nCase failed: {:?} - {:?}", + sample, + case, + ); + } + } + } + #[test] fn test_textobject_surround() { // (text, [(cursor position, textobject, final range, count), ...]) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 8c0a005cc816..d109c1fc833d 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -1,4 +1,5 @@ use helix_core::{ + chars::char_is_line_ending, comment, coords_at_pos, find_first_non_whitespace_char, find_root, graphemes, history::UndoKind, indent, @@ -9,8 +10,10 @@ use helix_core::{ object, pos_at_coords, regex::{self, Regex, RegexBuilder}, register::Register, - search, selection, surround, textobject, LineEnding, Position, Range, Rope, RopeGraphemes, - RopeSlice, Selection, SmallVec, Tendril, Transaction, + search, selection, surround, + textobject::{self, TextObject}, + LineEnding, Position, Range, Rope, RopeGraphemes, RopeSlice, Selection, SmallVec, Tendril, + Transaction, }; use helix_view::{ @@ -267,6 +270,8 @@ impl Command { goto_last_diag, "Goto last diagnostic", goto_next_diag, "Goto next diagnostic", goto_prev_diag, "Goto previous diagnostic", + goto_prev_para, "Goto previous paragraph", + goto_next_para, "Goto next paragraph", goto_line_start, "Goto line start", goto_line_end, "Goto line end", goto_next_buffer, "Goto next buffer", @@ -340,8 +345,18 @@ impl Command { surround_add, "Surround add", surround_replace, "Surround replace", surround_delete, "Surround delete", - select_textobject_around, "Select around object", - select_textobject_inner, "Select inside object", + select_textobject_around_word, "Word", + select_textobject_inner_word, "Word", + select_textobject_around_big_word, "WORD", + select_textobject_inner_big_word, "WORD", + select_textobject_around_paragraph, "Paragraph", + select_textobject_inner_paragraph, "Paragraph", + select_textobject_around_class, "Class", + select_textobject_inner_class, "Class", + select_textobject_around_function, "Function", + select_textobject_inner_function, "Function", + select_textobject_around_parameter, "Parameter", + select_textobject_inner_parameter, "Parameter", shell_pipe, "Pipe selections through shell command", shell_pipe_to, "Pipe selections into shell command, ignoring command output", shell_insert_output, "Insert output of shell command before each selection", @@ -3683,6 +3698,74 @@ fn goto_prev_diag(cx: &mut Context) { goto_pos(editor, pos); } +fn goto_prev_para(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let selection = doc.selection(view.id).clone().transform(|range| { + let mut anchor = if range.is_empty() { + range.anchor + } else { + range.head + }; + + // skip whitespace paragraph + let mut head = range.head.saturating_sub(1); + head = textobject::find_paragraph_boundary(text, head, Direction::Backward); + // if find_paragraph_boundary returns the same head as anchor + // on the first search means the cursor is already on the paragraph + // boundary so we want to move find the previous boundary again + // but do this only for a non-whitespace paragraph boundary + if head == range.anchor && !char_is_line_ending(text.char(head)) { + head = head.saturating_sub(1); + head = textobject::find_paragraph_boundary(text, head, Direction::Backward); + // ignore the anchor that failed first paragraph search + anchor = anchor.saturating_sub(1); + } + // skip non-whitespace paragraph to make sure our head will be + // on non-whitespace paragraph on first boundary search + if char_is_line_ending(text.char(head)) { + head = head.saturating_sub(1); + head = textobject::find_paragraph_boundary(text, head, Direction::Backward); + } + + for _ in 1..count { + // skip whitespace paragraph + head = head.saturating_sub(1); + head = textobject::find_paragraph_boundary(text, head, Direction::Backward); + + // skip non-whitespace paragraph + head = head.saturating_sub(1); + head = textobject::find_paragraph_boundary(text, head, Direction::Backward); + } + Range::new(anchor, head) + }); + doc.set_selection(view.id, selection); +} + +fn goto_next_para(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let selection = doc.selection(view.id).clone().transform(|range| { + let anchor = if range.is_empty() { + range.anchor + } else { + range.head.saturating_sub(1) + }; + + // todo!("reimmplement this using find_paragraph_boundary"); + let pos = textobject::textobject_paragraph(text, range, TextObject::Around, count).head; + let head = if char_is_line_ending(text.char(pos.saturating_sub(2))) { + pos + } else { + (pos + 1).min(text.len_chars().saturating_sub(1)) + }; + Range::new(anchor, head) + }); + doc.set_selection(view.id, selection); +} + fn signature_help(cx: &mut Context) { let (view, doc) = current!(cx.editor); @@ -4973,68 +5056,119 @@ fn scroll_down(cx: &mut Context) { scroll(cx, cx.count(), Direction::Forward); } -fn select_textobject_around(cx: &mut Context) { - select_textobject(cx, textobject::TextObject::Around); +fn select_textobject_around_word(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let selection = doc.selection(view.id).clone().transform(|range| { + textobject::textobject_word(text, range, TextObject::Around, count, false) + }); + doc.set_selection(view.id, selection); } -fn select_textobject_inner(cx: &mut Context) { - select_textobject(cx, textobject::TextObject::Inside); +fn select_textobject_inner_word(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let selection = doc.selection(view.id).clone().transform(|range| { + textobject::textobject_word(text, range, TextObject::Inside, count, false) + }); + doc.set_selection(view.id, selection); } -fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { +fn select_textobject_around_big_word(cx: &mut Context) { let count = cx.count(); - cx.on_next_key(move |cx, event| { - if let Some(ch) = event.char() { - let textobject = move |editor: &mut Editor| { - let (view, doc) = current!(editor); - let text = doc.text().slice(..); - - let textobject_treesitter = |obj_name: &str, range: Range| -> Range { - let (lang_config, syntax) = match doc.language_config().zip(doc.syntax()) { - Some(t) => t, - None => return range, - }; - textobject::textobject_treesitter( - text, - range, - objtype, - obj_name, - syntax.tree().root_node(), - lang_config, - count, - ) - }; + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let selection = doc.selection(view.id).clone().transform(|range| { + textobject::textobject_word(text, range, TextObject::Around, count, true) + }); + doc.set_selection(view.id, selection); +} - let selection = doc.selection(view.id).clone().transform(|range| { - match ch { - 'w' => textobject::textobject_word(text, range, objtype, count, false), - 'W' => textobject::textobject_word(text, range, objtype, count, true), - 'c' => textobject_treesitter("class", range), - 'f' => textobject_treesitter("function", range), - 'p' => textobject_treesitter("parameter", range), - 'm' => { - let ch = text.char(range.cursor(text)); - if !ch.is_ascii_alphanumeric() { - textobject::textobject_surround(text, range, objtype, ch, count) - } else { - range - } - } - // TODO: cancel new ranges if inconsistent surround matches across lines - ch if !ch.is_ascii_alphanumeric() => { - textobject::textobject_surround(text, range, objtype, ch, count) - } - _ => range, - } - }); - doc.set_selection(view.id, selection); - }; - textobject(&mut cx.editor); - cx.editor.last_motion = Some(Motion(Box::new(textobject))); - } - }) +fn select_textobject_inner_big_word(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let selection = doc.selection(view.id).clone().transform(|range| { + textobject::textobject_word(text, range, TextObject::Inside, count, true) + }); + doc.set_selection(view.id, selection); +} + +fn select_textobject_around_paragraph(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let selection = doc.selection(view.id).clone().transform(|range| { + textobject::textobject_paragraph(text, range, TextObject::Around, count) + }); + doc.set_selection(view.id, selection); +} + +fn select_textobject_inner_paragraph(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let selection = doc.selection(view.id).clone().transform(|range| { + textobject::textobject_paragraph(text, range, TextObject::Inside, count) + }); + doc.set_selection(view.id, selection); +} + +// textobject selection helper +fn select_textobject_treesitter(cx: &mut Context, objtype: TextObject, obj_name: &str) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let (lang_config, syntax) = match doc.language_config().zip(doc.syntax()) { + Some(t) => t, + None => return, + }; + let selection = doc.selection(view.id).clone().transform(|range| { + textobject::textobject_treesitter( + text, + range, + objtype, + obj_name, + syntax.tree().root_node(), + lang_config, + count, + ) + }); + doc.set_selection(view.id, selection); } +fn select_textobject_around_class(cx: &mut Context) { + select_textobject_treesitter(cx, TextObject::Around, "class"); +} + +fn select_textobject_inner_class(cx: &mut Context) { + select_textobject_treesitter(cx, TextObject::Inside, "class"); +} + +fn select_textobject_around_function(cx: &mut Context) { + select_textobject_treesitter(cx, TextObject::Around, "function"); +} + +fn select_textobject_inner_function(cx: &mut Context) { + select_textobject_treesitter(cx, TextObject::Inside, "function"); +} + +fn select_textobject_around_parameter(cx: &mut Context) { + select_textobject_treesitter(cx, TextObject::Around, "parameter"); +} + +fn select_textobject_inner_parameter(cx: &mut Context) { + select_textobject_treesitter(cx, TextObject::Inside, "parameter"); +} + +// TODO: add textobject for other types like parenthesis +// TODO: cancel new ranges if inconsistent surround matches across lines +// ch if !ch.is_ascii_alphanumeric() => { +// textobject::textobject_surround(text, range, objtype, ch, count) +// } + fn surround_add(cx: &mut Context) { cx.on_next_key(move |cx, event| { if let Some(ch) = event.char() { diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index 010714dcb4db..7c1d1dfd58f5 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -560,17 +560,33 @@ impl Default for Keymaps { "s" => surround_add, "r" => surround_replace, "d" => surround_delete, - "a" => select_textobject_around, - "i" => select_textobject_inner, + "a" => { "Match around" + "w" => select_textobject_around_word, + "W" => select_textobject_around_big_word, + "p" => select_textobject_around_paragraph, + "c" => select_textobject_around_class, + "f" => select_textobject_around_function, + "a" => select_textobject_around_parameter, + }, + "i" => { "Match inner" + "w" => select_textobject_inner_word, + "W" => select_textobject_inner_big_word, + "p" => select_textobject_inner_paragraph, + "c" => select_textobject_inner_class, + "f" => select_textobject_inner_function, + "a" => select_textobject_inner_parameter, + }, }, "[" => { "Left bracket" "d" => goto_prev_diag, "D" => goto_first_diag, + "p" => goto_prev_para, "space" => add_newline_above, }, "]" => { "Right bracket" "d" => goto_next_diag, "D" => goto_last_diag, + "p" => goto_next_para, "space" => add_newline_below, },