summaryrefslogtreecommitdiff
path: root/helix-core/src
diff options
context:
space:
mode:
Diffstat (limited to 'helix-core/src')
-rw-r--r--helix-core/src/lib.rs1
-rw-r--r--helix-core/src/line_ending.rs5
-rw-r--r--helix-core/src/movement.rs121
-rw-r--r--helix-core/src/test.rs111
4 files changed, 238 insertions, 0 deletions
diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs
index 1f43c266..0ae68f91 100644
--- a/helix-core/src/lib.rs
+++ b/helix-core/src/lib.rs
@@ -24,6 +24,7 @@ pub mod shellwords;
mod state;
pub mod surround;
pub mod syntax;
+pub mod test;
pub mod textobject;
mod transaction;
diff --git a/helix-core/src/line_ending.rs b/helix-core/src/line_ending.rs
index 06ec2a45..f0cf3b10 100644
--- a/helix-core/src/line_ending.rs
+++ b/helix-core/src/line_ending.rs
@@ -119,6 +119,11 @@ pub fn str_is_line_ending(s: &str) -> bool {
LineEnding::from_str(s).is_some()
}
+#[inline]
+pub fn rope_is_line_ending(r: RopeSlice) -> bool {
+ r.chunks().all(str_is_line_ending)
+}
+
/// Attempts to detect what line ending the passed document uses.
pub fn auto_detect_line_ending(doc: &Rope) -> Option<LineEnding> {
// Return first matched line ending. Not all possible line endings
diff --git a/helix-core/src/movement.rs b/helix-core/src/movement.rs
index e559f1ea..21d16931 100644
--- a/helix-core/src/movement.rs
+++ b/helix-core/src/movement.rs
@@ -10,6 +10,7 @@ use crate::{
next_grapheme_boundary, nth_next_grapheme_boundary, nth_prev_grapheme_boundary,
prev_grapheme_boundary,
},
+ line_ending::{rope_is_line_ending, str_is_line_ending},
pos_at_coords,
syntax::LanguageConfiguration,
textobject::TextObject,
@@ -149,6 +150,63 @@ 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 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;
+
+ // iterate current line if first character after paragraph boundary
+ if line_to_empty && !first_char {
+ line += 1;
+ }
+ let mut lines = slice.lines_at(line);
+ lines.reverse();
+ let mut lines = lines.map(rope_is_line_ending).peekable();
+ for _ in 0..count {
+ while lines.next_if(|&e| e).is_some() {
+ line -= 1;
+ }
+ while lines.next_if(|&e| !e).is_some() {
+ line -= 1;
+ }
+ }
+
+ 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 {
+ range.cursor(slice)
+ } else {
+ range.head
+ }
+ } else {
+ range.put_cursor(slice, head, true).anchor
+ };
+ Range::new(anchor, head)
+}
+
+pub fn move_next_para(slice: RopeSlice, range: Range, count: usize, behavior: Movement) -> Range {
+ let mut line = slice.char_to_line(range.head);
+ let lines = slice.lines_at(line);
+ let mut lines = lines.map(|l| l.chunks().all(str_is_line_ending)).peekable();
+ for _ in 0..count {
+ while lines.next_if(|&e| !e).is_some() {
+ line += 1;
+ }
+ while lines.next_if(|&e| e).is_some() {
+ line += 1;
+ }
+ }
+ let anchor = if behavior == Movement::Move {
+ range.cursor(slice)
+ } else {
+ range.anchor
+ };
+ Range::new(anchor, slice.line_to_char(line))
+}
+
// ---- util ------------
#[inline]
@@ -1179,4 +1237,67 @@ 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"),
+ ("goto\nfirst\n^\n@paragraph", "@goto\nfirst\n\n^paragraph"),
+ ("goto\nsecond\n\np^a@ragraph", "goto\nsecond\n\n@pa^ragraph"),
+ (
+ "here\n\nhave\nmultiple\nparagraph\n\n\n\n\n^@",
+ "here\n\n@have\nmultiple\nparagraph\n\n\n\n\n^",
+ ),
+ ];
+
+ for (actual, expected) in tests {
+ let (s, selection) = crate::test::print(actual);
+ let text = Rope::from(s.as_str());
+ let selection =
+ selection.transform(|r| move_prev_para(text.slice(..), r, 1, Movement::Move));
+ let actual = crate::test::plain(&s, selection);
+ assert_eq!(actual, expected);
+ }
+ }
+
+ #[ignore]
+ #[test]
+ fn test_behaviour_when_moving_to_prev_paragraph_double() {}
+
+ #[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@"),
+ (
+ "a\nb\n\n^g@oto\nthird\n\nparagraph",
+ "a\nb\n\n^goto\nthird\n\n@paragraph",
+ ),
+ (
+ "a\nb\n^\n@goto\nthird\n\nparagraph",
+ "a\nb\n\n^goto\nthird\n\n@paragraph",
+ ),
+ (
+ "a\nb^\n@\ngoto\nsecond\n\nparagraph",
+ "a\nb^\n\n@goto\nsecond\n\nparagraph",
+ ),
+ (
+ "here\n\nhave\n^m@ultiple\nparagraph\n\n\n\n\n",
+ "here\n\nhave\n^multiple\nparagraph\n\n\n\n\n@",
+ ),
+ ];
+
+ for (actual, expected) in tests {
+ let (s, selection) = crate::test::print(actual);
+ let text = Rope::from(s.as_str());
+ let selection =
+ selection.transform(|r| move_next_para(text.slice(..), r, 1, Movement::Move));
+ let actual = crate::test::plain(&s, selection);
+ assert_eq!(actual, expected);
+ }
+ }
}
diff --git a/helix-core/src/test.rs b/helix-core/src/test.rs
new file mode 100644
index 00000000..983c9a57
--- /dev/null
+++ b/helix-core/src/test.rs
@@ -0,0 +1,111 @@
+//! Test helpers.
+use crate::{Range, Selection};
+use smallvec::SmallVec;
+use std::cmp::Reverse;
+
+/// Convert annotated test string to test string and selection.
+///
+/// `^` for `anchor` and `|` for head (`@` for primary), both must appear
+/// or otherwise it will panic.
+///
+/// # Examples
+///
+/// ```
+/// use helix_core::{Range, Selection, test::print};
+/// use smallvec::smallvec;
+///
+/// assert_eq!(
+/// print("^a@b|c^"),
+/// ("abc".to_owned(), Selection::new(smallvec![Range::new(0, 1), Range::new(3, 2)], 0))
+/// );
+/// ```
+///
+/// # Panics
+///
+/// Panics when missing primary or appeared more than once.
+/// Panics when missing head or anchor.
+/// Panics when head come after head or anchor come after anchor.
+pub fn print(s: &str) -> (String, Selection) {
+ let mut anchor = None;
+ let mut head = None;
+ let mut primary = None;
+ let mut ranges = SmallVec::new();
+ let mut i = 0;
+ let s = s
+ .chars()
+ .filter(|c| {
+ match c {
+ '^' if anchor != None => panic!("anchor without head {s:?}"),
+ '^' if head == None => anchor = Some(i),
+ '^' => ranges.push(Range::new(i, head.take().unwrap())),
+ '|' if head != None => panic!("head without anchor {s:?}"),
+ '|' if anchor == None => head = Some(i),
+ '|' => ranges.push(Range::new(anchor.take().unwrap(), i)),
+ '@' if primary != None => panic!("head (primary) already appeared {s:?}"),
+ '@' if head != None => panic!("head (primary) without anchor {s:?}"),
+ '@' if anchor == None => {
+ primary = Some(ranges.len());
+ head = Some(i);
+ }
+ '@' => {
+ primary = Some(ranges.len());
+ ranges.push(Range::new(anchor.take().unwrap(), i));
+ }
+ _ => {
+ i += 1;
+ return true;
+ }
+ };
+ false
+ })
+ .collect();
+ if head.is_some() {
+ panic!("missing anchor (|) {s:?}");
+ }
+ if anchor.is_some() {
+ panic!("missing head (^) {s:?}");
+ }
+ let primary = match primary {
+ Some(i) => i,
+ None => panic!("missing primary (@) {s:?}"),
+ };
+ let selection = Selection::new(ranges, primary);
+ (s, selection)
+}
+
+/// Convert test string and selection to annotated test string.
+///
+/// `^` for `anchor` and `|` for head (`@` for primary).
+///
+/// # Examples
+///
+/// ```
+/// use helix_core::{Range, Selection, test::plain};
+/// use smallvec::smallvec;
+///
+/// assert_eq!(
+/// plain("abc", Selection::new(smallvec![Range::new(0, 1), Range::new(3, 2)], 0)),
+/// "^a@b|c^".to_owned()
+/// );
+/// ```
+pub fn plain(s: &str, selection: Selection) -> String {
+ let primary = selection.primary_index();
+ let mut out = String::with_capacity(s.len() + 2 * selection.len());
+ out.push_str(s);
+ let mut insertion: Vec<_> = selection
+ .iter()
+ .enumerate()
+ .flat_map(|(i, range)| {
+ [
+ (range.anchor, '^'),
+ (range.head, if i == primary { '@' } else { '|' }),
+ ]
+ })
+ .collect();
+ // insert in reverse order
+ insertion.sort_unstable_by_key(|k| Reverse(k.0));
+ for (i, c) in insertion {
+ out.insert(i, c);
+ }
+ out
+}