From 5f0cf8d407c0c26a22314cb5105c46fcb88795ec Mon Sep 17 00:00:00 2001 From: Ivan Tham Date: Sat, 25 Sep 2021 00:19:25 +0800 Subject: [PATCH] Add paragraph textobject Temporarily disable other textobject besides word and paragraph since it integrates nicely with infobox. The behavior is based on vim and kakoune but with some differences, it can select whitespace only paragraph like vim and it can select backward from the end of the file like kakoune. --- helix-core/src/textobject.rs | 164 +++++++++++++++++++++-- helix-term/src/commands.rs | 250 +++++++++++++++++++++++++++-------- helix-term/src/keymap.rs | 20 ++- 3 files changed, 366 insertions(+), 68 deletions(-) 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, },