From e8649c9f1fd86bf2fcdacf561fd5b3cda6099d1f 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 | 249 +++++++++++++++++++++++++++-------- helix-term/src/keymap.rs | 20 ++- 3 files changed, 365 insertions(+), 68 deletions(-) diff --git a/helix-core/src/textobject.rs b/helix-core/src/textobject.rs index 24f063d447dde..fb680ade1a457 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 } +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 d5a48c5f74f76..293fe34cdf456 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, indent, indent::IndentStyle, line_ending::{get_line_ending_of_str, line_end_char_index, str_is_line_ending}, @@ -7,8 +8,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::{ @@ -263,6 +266,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", @@ -333,8 +338,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", @@ -3544,6 +3559,73 @@ 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| { + // get paragraph twice because textobject_paragraph select + // current paragraph but we want previous paragraph + let currange = textobject::textobject_paragraph(text, range, TextObject::Around, count); + // as textobject_paragraph will select previous paragraph when + // reached eof we need to ignore getting previous paragraph + let pos = if range.head == currange.anchor + || range.anchor == currange.anchor + || range.head == currange.head + { + textobject::textobject_paragraph( + text, + Range::point(currange.anchor.saturating_sub(1)), + TextObject::Around, + count, + ) + .anchor + } else { + currange.anchor + }; + let anchor = if range.is_empty() { + range.anchor + } else if char_is_line_ending(text.char(range.head)) { + graphemes::next_grapheme_boundary(text, range.head) + } else { + range.head + }; + let head = if char_is_line_ending(text.char(pos.saturating_sub(2))) { + graphemes::prev_grapheme_boundary(text, pos) + } else { + pos + }; + 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(..); + // Not sure if we want to behave like kakoune here to directly select + // till next paragraph with characters or like vim which selects + // till next paragraph or whitespace, currently it selects till next + // paragraph with whitespace like vim but it can select a whole paragraph + // of whitespace. + let selection = doc.selection(view.id).clone().transform(|range| { + let anchor = if range.is_empty() { + range.anchor + } else { + range.head.saturating_sub(1) + }; + 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); @@ -4797,68 +4879,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 e3e0199571449..b5b13789f338b 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -559,17 +559,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, },