summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--book/src/keymap.md2
-rw-r--r--helix-core/src/movement.rs23
-rw-r--r--helix-core/src/test.rs3
-rw-r--r--helix-core/src/textobject.rs147
-rw-r--r--helix-term/src/commands.rs1
5 files changed, 162 insertions, 14 deletions
diff --git a/book/src/keymap.md b/book/src/keymap.md
index e588ac92..2fbc7b3f 100644
--- a/book/src/keymap.md
+++ b/book/src/keymap.md
@@ -277,6 +277,8 @@ Mappings in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaire
| `[a` | Go to previous argument/parameter (**TS**) | `goto_prev_parameter` |
| `]o` | Go to next comment (**TS**) | `goto_next_comment` |
| `[o` | Go to previous comment (**TS**) | `goto_prev_comment` |
+| `]p` | Go to next paragraph | `goto_next_paragraph` |
+| `[p` | Go to previous paragraph | `goto_prev_paragraph` |
| `[space` | Add newline above | `add_newline_above` |
| `]space` | Add newline below | `add_newline_below` |
diff --git a/helix-core/src/movement.rs b/helix-core/src/movement.rs
index dec6eeb9..970aff88 100644
--- a/helix-core/src/movement.rs
+++ b/helix-core/src/movement.rs
@@ -153,12 +153,12 @@ fn word_move(slice: RopeSlice, range: Range, count: usize, target: WordMotionTar
pub fn move_prev_para(slice: RopeSlice, range: Range, count: usize, behavior: Movement) -> Range {
let mut line = range.cursor_line(slice);
let first_char = slice.line_to_char(line) == range.cursor(slice);
+ let prev_line_empty = rope_is_line_ending(slice.line(line.saturating_sub(1)));
let curr_line_empty = rope_is_line_ending(slice.line(line));
- let last_line_empty = rope_is_line_ending(slice.line(line.saturating_sub(1)));
- let line_to_empty = last_line_empty && !curr_line_empty;
+ let prev_empty_to_line = prev_line_empty && !curr_line_empty;
- // iterate current line if first character after paragraph boundary
- if line_to_empty && !first_char {
+ // skip character before paragraph boundary
+ if prev_empty_to_line && !first_char {
line += 1;
}
let mut lines = slice.lines_at(line);
@@ -176,7 +176,7 @@ pub fn move_prev_para(slice: RopeSlice, range: Range, count: usize, behavior: Mo
let head = slice.line_to_char(line);
let anchor = if behavior == Movement::Move {
// exclude first character after paragraph boundary
- if line_to_empty && first_char {
+ if prev_empty_to_line && first_char {
range.cursor(slice)
} else {
range.head
@@ -193,13 +193,12 @@ pub fn move_next_para(slice: RopeSlice, range: Range, count: usize, behavior: Mo
prev_grapheme_boundary(slice, slice.line_to_char(line + 1)) == range.cursor(slice);
let curr_line_empty = rope_is_line_ending(slice.line(line));
let next_line_empty = rope_is_line_ending(slice.line(line.saturating_sub(1)));
- let empty_to_line = curr_line_empty && !next_line_empty;
+ let curr_empty_to_line = curr_line_empty && !next_line_empty;
- // iterate current line if first character after paragraph boundary
- if empty_to_line && last_char {
+ // skip character after paragraph boundary
+ if curr_empty_to_line && last_char {
line += 1;
}
-
let mut lines = slice.lines_at(line).map(rope_is_line_ending).peekable();
for _ in 0..count {
while lines.next_if(|&e| !e).is_some() {
@@ -211,7 +210,7 @@ pub fn move_next_para(slice: RopeSlice, range: Range, count: usize, behavior: Mo
}
let head = slice.line_to_char(line);
let anchor = if behavior == Movement::Move {
- if empty_to_line && last_char {
+ if curr_empty_to_line && last_char {
range.head
} else {
range.cursor(slice)
@@ -1256,7 +1255,7 @@ mod test {
#[test]
fn test_behaviour_when_moving_to_prev_paragraph_single() {
let tests = [
- ("^@", "@^"),
+ ("^@", "^@"),
("^s@tart at\nfirst char\n", "@s^tart at\nfirst char\n"),
("start at\nlast char^\n@", "@start at\nlast char\n^"),
("goto\nfirst\n\n^p@aragraph", "@goto\nfirst\n\n^paragraph"),
@@ -1315,7 +1314,7 @@ mod test {
#[test]
fn test_behaviour_when_moving_to_next_paragraph_single() {
let tests = [
- ("^@", "@^"),
+ ("^@", "^@"),
("^s@tart at\nfirst char\n", "^start at\nfirst char\n@"),
("start at\nlast char^\n@", "start at\nlast char^\n@"),
(
diff --git a/helix-core/src/test.rs b/helix-core/src/test.rs
index 983c9a57..da4f8fac 100644
--- a/helix-core/src/test.rs
+++ b/helix-core/src/test.rs
@@ -97,8 +97,9 @@ pub fn plain(s: &str, selection: Selection) -> String {
.enumerate()
.flat_map(|(i, range)| {
[
- (range.anchor, '^'),
+ // sort like this before reversed so anchor < head later
(range.head, if i == primary { '@' } else { '|' }),
+ (range.anchor, '^'),
]
})
.collect();
diff --git a/helix-core/src/textobject.rs b/helix-core/src/textobject.rs
index 5a55a6f1..fb6b7142 100644
--- a/helix-core/src/textobject.rs
+++ b/helix-core/src/textobject.rs
@@ -4,7 +4,8 @@ use ropey::RopeSlice;
use tree_sitter::{Node, QueryCursor};
use crate::chars::{categorize_char, char_is_whitespace, CharCategory};
-use crate::graphemes::next_grapheme_boundary;
+use crate::graphemes::{next_grapheme_boundary, prev_grapheme_boundary};
+use crate::line_ending::rope_is_line_ending;
use crate::movement::Direction;
use crate::surround;
use crate::syntax::LanguageConfiguration;
@@ -111,6 +112,71 @@ pub fn textobject_word(
}
}
+pub fn textobject_para(
+ slice: RopeSlice,
+ range: Range,
+ textobject: TextObject,
+ count: usize,
+) -> Range {
+ let mut line = range.cursor_line(slice);
+ let prev_line_empty = rope_is_line_ending(slice.line(line.saturating_sub(1)));
+ let curr_line_empty = rope_is_line_ending(slice.line(line));
+ let next_line_empty = rope_is_line_ending(slice.line(line.saturating_sub(1)));
+ let last_char =
+ prev_grapheme_boundary(slice, slice.line_to_char(line + 1)) == range.cursor(slice);
+ let prev_empty_to_line = prev_line_empty && !curr_line_empty;
+ let curr_empty_to_line = curr_line_empty && !next_line_empty;
+
+ // skip character before paragraph boundary
+ let mut line_back = line; // line but backwards
+ if prev_empty_to_line || curr_empty_to_line {
+ line_back += 1;
+ }
+ let mut lines = slice.lines_at(line_back);
+ // do not include current paragraph on paragraph end (include next)
+ if !(curr_empty_to_line && last_char) {
+ lines.reverse();
+ let mut lines = lines.map(rope_is_line_ending).peekable();
+ while lines.next_if(|&e| e).is_some() {
+ line_back -= 1;
+ }
+ while lines.next_if(|&e| !e).is_some() {
+ line_back -= 1;
+ }
+ }
+
+ // skip character after paragraph boundary
+ if curr_empty_to_line && last_char {
+ line += 1;
+ }
+ let mut lines = slice.lines_at(line).map(rope_is_line_ending).peekable();
+ for _ in 0..count - 1 {
+ while lines.next_if(|&e| !e).is_some() {
+ line += 1;
+ }
+ while lines.next_if(|&e| e).is_some() {
+ line += 1;
+ }
+ }
+ while lines.next_if(|&e| !e).is_some() {
+ line += 1;
+ }
+ // handle last whitespaces part separately depending on textobject
+ match textobject {
+ TextObject::Around => {
+ while lines.next_if(|&e| e).is_some() {
+ line += 1;
+ }
+ }
+ TextObject::Inside => {}
+ TextObject::Movement => unreachable!(),
+ }
+
+ let anchor = slice.line_to_char(line_back);
+ let head = slice.line_to_char(line);
+ Range::new(anchor, head)
+}
+
pub fn textobject_surround(
slice: RopeSlice,
range: Range,
@@ -289,6 +355,85 @@ mod test {
}
#[test]
+ fn test_textobject_paragraph_inside_single() {
+ let tests = [
+ ("^@", "^@"),
+ ("firs^t@\n\nparagraph\n\n", "^first\n@\nparagraph\n\n"),
+ ("second\n\npa^r@agraph\n\n", "second\n\n^paragraph\n@\n"),
+ ("^f@irst char\n\n", "^first char\n@\n"),
+ ("last char\n^\n@", "last char\n\n^@"),
+ (
+ "empty to line\n^\n@paragraph boundary\n\n",
+ "empty to line\n\n^paragraph boundary\n@\n",
+ ),
+ (
+ "line to empty\n\n^p@aragraph boundary\n\n",
+ "line to empty\n\n^paragraph boundary\n@\n",
+ ),
+ ];
+
+ for (before, expected) in tests {
+ let (s, selection) = crate::test::print(before);
+ let text = Rope::from(s.as_str());
+ let selection =
+ selection.transform(|r| textobject_para(text.slice(..), r, TextObject::Inside, 1));
+ let actual = crate::test::plain(&s, selection);
+ assert_eq!(actual, expected, "\nbefore: `{before:?}`");
+ }
+ }
+
+ #[test]
+ fn test_textobject_paragraph_inside_double() {
+ let tests = [
+ (
+ "last two\n\n^p@aragraph\n\nwithout whitespaces\n\n",
+ "last two\n\n^paragraph\n\nwithout whitespaces\n@\n",
+ ),
+ (
+ "last two\n^\n@paragraph\n\nwithout whitespaces\n\n",
+ "last two\n\n^paragraph\n\nwithout whitespaces\n@\n",
+ ),
+ ];
+
+ for (before, expected) in tests {
+ let (s, selection) = crate::test::print(before);
+ let text = Rope::from(s.as_str());
+ let selection =
+ selection.transform(|r| textobject_para(text.slice(..), r, TextObject::Inside, 2));
+ let actual = crate::test::plain(&s, selection);
+ assert_eq!(actual, expected, "\nbefore: `{before:?}`");
+ }
+ }
+
+ #[test]
+ fn test_textobject_paragraph_around_single() {
+ let tests = [
+ ("^@", "^@"),
+ ("firs^t@\n\nparagraph\n\n", "^first\n\n@paragraph\n\n"),
+ ("second\n\npa^r@agraph\n\n", "second\n\n^paragraph\n\n@"),
+ ("^f@irst char\n\n", "^first char\n\n@"),
+ ("last char\n^\n@", "last char\n\n^@"),
+ (
+ "empty to line\n^\n@paragraph boundary\n\n",
+ "empty to line\n\n^paragraph boundary\n\n@",
+ ),
+ (
+ "line to empty\n\n^p@aragraph boundary\n\n",
+ "line to empty\n\n^paragraph boundary\n\n@",
+ ),
+ ];
+
+ for (before, expected) in tests {
+ let (s, selection) = crate::test::print(before);
+ let text = Rope::from(s.as_str());
+ let selection =
+ selection.transform(|r| textobject_para(text.slice(..), r, TextObject::Around, 1));
+ let actual = crate::test::plain(&s, selection);
+ assert_eq!(actual, expected, "\nbefore: `{before:?}`");
+ }
+ }
+
+ #[test]
fn test_textobject_surround() {
// (text, [(cursor position, textobject, final range, surround char, count), ...])
let tests = &[
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index beb564ad..ed3b45ae 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -3991,6 +3991,7 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
'f' => textobject_treesitter("function", range),
'a' => textobject_treesitter("parameter", range),
'o' => textobject_treesitter("comment", range),
+ 'p' => textobject::textobject_para(text, range, objtype, count),
'm' => {
let ch = text.char(range.cursor(text));
if !ch.is_ascii_alphanumeric() {