mod horizontal;
mod paragraph;
mod vertical;
mod word;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Motion {
Left,
Down,
Up,
Right,
WordForward(WordKind),
WordBackward(WordKind),
WordEnd(WordEndMotion),
Column(ColumnMotion),
Paragraph(ParagraphDirection),
Page(PageDirection),
LineAddress(LineAddress),
CharSearch(CharSearch),
RepeatCharSearch,
RepeatCharSearchReversed,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WordKind {
Normal,
Big,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WordEndMotion {
Forward(WordKind),
Backward(WordKind),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ColumnMotion {
LineStart,
FirstNonBlank,
LineEnd,
ScreenColumn,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ParagraphDirection {
Backward,
Forward,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PageDirection {
Backward,
Forward,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LineAddress {
FirstNonBlank,
LastNonBlank,
Number(std::num::NonZeroUsize),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CharSearchDirection {
Backward,
Forward,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CharSearchPlacement {
OnMatch,
BeforeMatch,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CharSearch {
pub target: char,
pub direction: CharSearchDirection,
pub placement: CharSearchPlacement,
}
#[must_use]
pub fn apply_motion(text: &str, byte_index: usize, motion: Motion) -> usize {
let moved = match motion {
Motion::Left => horizontal::previous_char_boundary(text, byte_index),
Motion::Down => {
vertical::vertical_move(text, byte_index, vertical::VerticalDirection::Down)
}
Motion::Up => vertical::vertical_move(text, byte_index, vertical::VerticalDirection::Up),
Motion::Right => horizontal::next_char_boundary(text, byte_index),
Motion::WordForward(WordKind::Normal) => word::next_word_start(text, byte_index),
Motion::WordForward(WordKind::Big) => word::next_big_word_start(text, byte_index),
Motion::WordBackward(WordKind::Normal) => word::previous_word_start(text, byte_index),
Motion::WordBackward(WordKind::Big) => word::previous_big_word_start(text, byte_index),
Motion::WordEnd(WordEndMotion::Forward(WordKind::Normal)) => {
word::next_word_end(text, byte_index)
}
Motion::WordEnd(WordEndMotion::Forward(WordKind::Big)) => {
word::next_big_word_end(text, byte_index)
}
Motion::WordEnd(WordEndMotion::Backward(WordKind::Normal)) => {
word::previous_word_end(text, byte_index)
}
Motion::WordEnd(WordEndMotion::Backward(WordKind::Big)) => {
word::previous_big_word_end(text, byte_index)
}
Motion::Column(ColumnMotion::LineStart) => horizontal::line_start(text, byte_index),
Motion::Column(ColumnMotion::FirstNonBlank) => {
horizontal::first_non_blank(text, byte_index)
}
Motion::Column(ColumnMotion::LineEnd) => horizontal::line_end(text, byte_index),
Motion::Column(ColumnMotion::ScreenColumn) => {
horizontal::screen_column(text, byte_index, 1)
}
Motion::Paragraph(ParagraphDirection::Forward) => {
paragraph::next_paragraph_start(text, byte_index)
}
Motion::Paragraph(ParagraphDirection::Backward) => {
paragraph::previous_paragraph_start(text, byte_index)
}
Motion::Page(PageDirection::Forward) => {
apply_page_motion(text, byte_index, PageDirection::Forward, DEFAULT_PAGE_LINES)
}
Motion::Page(PageDirection::Backward) => apply_page_motion(
text,
byte_index,
PageDirection::Backward,
DEFAULT_PAGE_LINES,
),
Motion::LineAddress(LineAddress::FirstNonBlank) => first_line_start(text),
Motion::LineAddress(LineAddress::LastNonBlank) => last_line_start(text),
Motion::LineAddress(LineAddress::Number(line_number)) => {
numbered_line_start(text, line_number)
}
Motion::CharSearch(search) => horizontal::char_search(text, byte_index, search),
Motion::RepeatCharSearch | Motion::RepeatCharSearchReversed => byte_index,
};
clamp_to_cursor_position(text, moved)
}
#[must_use]
pub fn apply_screen_column_motion(text: &str, byte_index: usize, column: usize) -> usize {
clamp_to_cursor_position(text, horizontal::screen_column(text, byte_index, column))
}
const DEFAULT_PAGE_LINES: usize = 20;
#[must_use]
pub fn apply_page_motion(
text: &str,
byte_index: usize,
direction: PageDirection,
page_lines: usize,
) -> usize {
let vertical_direction = match direction {
PageDirection::Backward => vertical::VerticalDirection::Up,
PageDirection::Forward => vertical::VerticalDirection::Down,
};
let moved = (0..page_lines).fold(byte_index, |index, _line| {
vertical::vertical_move(text, index, vertical_direction)
});
clamp_to_cursor_position(text, moved)
}
#[must_use]
pub fn character_column(text: &str, byte_index: usize) -> usize {
vertical::character_column(text, byte_index)
}
#[must_use]
pub fn apply_vertical_motion_to_column(
text: &str,
byte_index: usize,
motion: Motion,
column: usize,
) -> usize {
let vertical_direction = match motion {
Motion::Up => vertical::VerticalDirection::Up,
Motion::Down => vertical::VerticalDirection::Down,
_ => return clamp_to_cursor_position(text, byte_index),
};
clamp_to_cursor_position(
text,
vertical::vertical_move_to_column(text, byte_index, vertical_direction, column),
)
}
#[must_use]
pub fn first_line_start(text: &str) -> usize {
first_non_blank_or_line_start(text, 0, text.find('\n').unwrap_or(text.len()))
}
#[must_use]
pub fn last_line_start(text: &str) -> usize {
let line_start = text
.trim_end_matches('\n')
.rfind('\n')
.map_or(0, |newline_index| newline_index + '\n'.len_utf8());
let line_end = text[line_start..]
.find('\n')
.map_or(text.len(), |newline_offset| line_start + newline_offset);
first_non_blank_or_line_start(text, line_start, line_end)
}
#[must_use]
pub fn numbered_line_start(text: &str, line_number: std::num::NonZeroUsize) -> usize {
let target_line = line_number.get().saturating_sub(1);
let mut line_start = 0;
for _line in 0..target_line {
match text[line_start..].find('\n') {
Some(offset) => line_start += offset + '\n'.len_utf8(),
None => return last_line_start(text),
}
}
let line_end = text[line_start..]
.find('\n')
.map_or(text.len(), |newline_offset| line_start + newline_offset);
first_non_blank_or_line_start(text, line_start, line_end)
}
fn first_non_blank_or_line_start(text: &str, line_start: usize, line_end: usize) -> usize {
text[line_start..line_end]
.char_indices()
.find_map(|(offset, character)| (!character.is_whitespace()).then_some(line_start + offset))
.unwrap_or(line_start)
}
#[must_use]
pub fn clamp_to_boundary(text: &str, index: usize) -> usize {
let mut boundary = text.len().min(index);
while !text.is_char_boundary(boundary) {
boundary -= 1;
}
boundary
}
#[must_use]
pub fn clamp_to_cursor_position(text: &str, index: usize) -> usize {
let index = clamp_to_boundary(text, index);
if text.is_empty() || is_cursor_position(text, index) {
return index;
}
previous_cursor_position(text, index)
.or_else(|| next_cursor_position(text, index))
.unwrap_or(index)
}
#[must_use]
pub fn is_cursor_position(text: &str, index: usize) -> bool {
if !text.is_char_boundary(index) {
return false;
}
match text[index.min(text.len())..].chars().next() {
Some(character) if character != '\n' => true,
Some('\n') => is_empty_line_start(text, index),
Some(_) => false,
None => index == 0 || text[..index].ends_with('\n'),
}
}
fn previous_cursor_position(text: &str, index: usize) -> Option<usize> {
text[..index]
.char_indices()
.rev()
.find_map(|(byte_index, character)| {
(character != '\n' || is_empty_line_start(text, byte_index)).then_some(byte_index)
})
}
fn next_cursor_position(text: &str, index: usize) -> Option<usize> {
text[index..]
.char_indices()
.find_map(|(offset, character)| {
let byte_index = index + offset;
(character != '\n' || is_empty_line_start(text, byte_index)).then_some(byte_index)
})
.or_else(|| (text.ends_with('\n')).then_some(text.len()))
}
fn is_empty_line_start(text: &str, index: usize) -> bool {
let at_line_start = index == 0 || text[..index].ends_with('\n');
let at_line_end = text[index.min(text.len())..]
.chars()
.next()
.is_none_or(|character| character == '\n');
at_line_start && at_line_end
}
#[cfg(test)]
mod tests {
use super::{
CharSearch, CharSearchDirection, CharSearchPlacement, LineAddress, Motion, PageDirection,
ParagraphDirection, WordEndMotion, WordKind, apply_motion, clamp_to_boundary,
clamp_to_cursor_position, is_cursor_position,
};
use proptest::prelude::*;
fn text_with_visible_cells() -> impl Strategy<Value = String> {
prop::collection::vec(
prop_oneof![
Just('\n'),
any::<char>().prop_filter("character must not be a newline", |character| {
*character != '\n'
}),
],
1..64,
)
.prop_filter("text must contain a visible cursor cell", |characters| {
characters.iter().any(|character| *character != '\n')
})
.prop_map(|characters| characters.into_iter().collect())
}
proptest! {
#[test]
fn motions_always_return_utf8_boundaries(
text in "\\PC*",
index in any::<usize>(),
motion in prop_oneof![
Just(Motion::Left),
Just(Motion::Down),
Just(Motion::Up),
Just(Motion::Right),
Just(Motion::WordForward(WordKind::Normal)),
Just(Motion::WordBackward(WordKind::Normal)),
Just(Motion::WordEnd(WordEndMotion::Forward(WordKind::Normal))),
Just(Motion::WordEnd(WordEndMotion::Backward(WordKind::Normal))),
Just(Motion::Paragraph(ParagraphDirection::Forward)),
Just(Motion::Paragraph(ParagraphDirection::Backward)),
Just(Motion::Page(PageDirection::Forward)),
Just(Motion::Page(PageDirection::Backward)),
Just(Motion::LineAddress(LineAddress::FirstNonBlank)),
Just(Motion::LineAddress(LineAddress::LastNonBlank)),
any::<char>().prop_map(|target| Motion::CharSearch(CharSearch {
target,
direction: CharSearchDirection::Forward,
placement: CharSearchPlacement::OnMatch,
})),
],
) {
let moved = apply_motion(&text, index, motion);
prop_assert!(moved <= text.len());
prop_assert!(text.is_char_boundary(moved));
}
#[test]
fn motions_always_land_on_cursor_cells_when_one_exists(
text in text_with_visible_cells(),
index in any::<usize>(),
motion in prop_oneof![
Just(Motion::Left),
Just(Motion::Down),
Just(Motion::Up),
Just(Motion::Right),
Just(Motion::WordForward(WordKind::Normal)),
Just(Motion::WordBackward(WordKind::Normal)),
Just(Motion::WordEnd(WordEndMotion::Forward(WordKind::Normal))),
Just(Motion::WordEnd(WordEndMotion::Backward(WordKind::Normal))),
Just(Motion::Paragraph(ParagraphDirection::Forward)),
Just(Motion::Paragraph(ParagraphDirection::Backward)),
Just(Motion::Page(PageDirection::Forward)),
Just(Motion::Page(PageDirection::Backward)),
Just(Motion::LineAddress(LineAddress::FirstNonBlank)),
Just(Motion::LineAddress(LineAddress::LastNonBlank)),
any::<char>().prop_map(|target| Motion::CharSearch(CharSearch {
target,
direction: CharSearchDirection::Forward,
placement: CharSearchPlacement::OnMatch,
})),
],
) {
let moved = apply_motion(&text, index, motion);
prop_assert!(is_cursor_position(&text, moved));
}
#[test]
fn clamping_is_idempotent(text in "\\PC*", index in any::<usize>()) {
let once = clamp_to_boundary(&text, index);
let twice = clamp_to_boundary(&text, once);
prop_assert_eq!(once, twice);
prop_assert!(text.is_char_boundary(once));
}
#[test]
fn horizontal_motion_is_bounded_by_neighboring_boundaries(
text in "\\PC*",
index in any::<usize>(),
) {
let clamped = clamp_to_cursor_position(&text, index);
let left = apply_motion(&text, index, Motion::Left);
let right = apply_motion(&text, index, Motion::Right);
prop_assert!(left <= clamped);
prop_assert!(right >= clamped);
prop_assert!(right <= text.len());
}
}
#[test]
fn paragraph_motion_crosses_blank_line_separated_paragraphs() {
let text = "one\nstill one\n\n two\n\n\nλambda";
assert_eq!(
apply_motion(text, 0, Motion::Paragraph(ParagraphDirection::Forward)),
"one\nstill one\n\n".len()
);
assert_eq!(
apply_motion(
text,
"one\nstill one\n\n two".len(),
Motion::Paragraph(ParagraphDirection::Forward)
),
"one\nstill one\n\n two\n\n\n".len()
);
assert_eq!(
apply_motion(
text,
"one\nstill one\n\n two".len(),
Motion::Paragraph(ParagraphDirection::Backward)
),
0
);
}
#[test]
fn paragraph_motion_stays_bounded_at_document_edges() {
let text = "one\n\n two";
assert_eq!(
apply_motion(text, 0, Motion::Paragraph(ParagraphDirection::Backward)),
0
);
assert_eq!(
apply_motion(
text,
"one\n\n ".len(),
Motion::Paragraph(ParagraphDirection::Forward)
),
"one\n\n two".len() - "o".len()
);
}
#[test]
fn paragraph_forward_without_later_blank_line_moves_to_file_end() {
let text = "one\ntwo\nthree";
assert_eq!(
apply_motion(text, 0, Motion::Paragraph(ParagraphDirection::Forward)),
"one\ntwo\nthre".len()
);
assert_eq!(
apply_motion(
text,
"one\nt".len(),
Motion::Paragraph(ParagraphDirection::Forward)
),
"one\ntwo\nthre".len()
);
}
#[test]
fn lowercase_word_motions_treat_punctuation_as_words() {
let text = "foo.bar baz";
assert_eq!(
apply_motion(text, 0, Motion::WordForward(WordKind::Normal)),
"foo".len()
);
assert_eq!(
apply_motion(text, "foo".len(), Motion::WordForward(WordKind::Normal)),
"foo.".len()
);
assert_eq!(
apply_motion(
text,
"foo.bar".len(),
Motion::WordBackward(WordKind::Normal)
),
"foo.".len()
);
assert_eq!(
apply_motion(
text,
0,
Motion::WordEnd(WordEndMotion::Forward(WordKind::Normal))
),
"fo".len()
);
assert_eq!(
apply_motion(
text,
"fo".len(),
Motion::WordEnd(WordEndMotion::Forward(WordKind::Normal))
),
"foo".len()
);
assert_eq!(
apply_motion(
text,
"foo.".len(),
Motion::WordEnd(WordEndMotion::Backward(WordKind::Normal))
),
"foo".len()
);
}
#[test]
fn uppercase_word_motions_use_whitespace_delimited_words() {
let text = "foo.bar baz";
assert_eq!(
apply_motion(text, 0, Motion::WordForward(WordKind::Big)),
"foo.bar ".len()
);
assert_eq!(
apply_motion(
text,
"foo.bar baz".len(),
Motion::WordBackward(WordKind::Big)
),
"foo.bar ".len()
);
assert_eq!(
apply_motion(
text,
0,
Motion::WordEnd(WordEndMotion::Forward(WordKind::Big))
),
"foo.ba".len()
);
assert_eq!(
apply_motion(
text,
"foo.ba".len(),
Motion::WordEnd(WordEndMotion::Forward(WordKind::Big))
),
"foo.bar baz".len() - 1
);
assert_eq!(
apply_motion(
text,
"foo.bar baz".len() - 1,
Motion::WordEnd(WordEndMotion::Backward(WordKind::Big))
),
"foo.ba".len()
);
}
#[test]
fn empty_lines_are_valid_cursor_cells() {
let text = "one\n\nthree\n";
assert!(is_cursor_position(text, "one\n".len()));
assert!(is_cursor_position(text, text.len()));
assert_eq!(apply_motion(text, "one".len(), Motion::Down), "one\n".len());
assert_eq!(
apply_motion(text, "one\n".len(), Motion::Down),
"one\n\n".len()
);
}
#[test]
fn file_edge_motions_match_first_non_blank_line_cells() {
let text = " one\n\ntwo\n three";
assert_eq!(
apply_motion(
text,
"two".len(),
Motion::LineAddress(LineAddress::FirstNonBlank)
),
2
);
assert_eq!(
apply_motion(
text,
"two".len(),
Motion::LineAddress(LineAddress::LastNonBlank)
),
" one\n\ntwo\n ".len()
);
}
#[test]
fn numbered_line_addresses_are_one_based_and_clamp_to_file_end() {
let text = " one\n\ntwo\n three";
assert_eq!(
apply_motion(
text,
0,
Motion::LineAddress(LineAddress::Number(std::num::NonZeroUsize::new(3).unwrap()))
),
" one\n\n".len()
);
assert_eq!(
apply_motion(
text,
0,
Motion::LineAddress(LineAddress::Number(
std::num::NonZeroUsize::new(100).unwrap()
))
),
" one\n\ntwo\n ".len()
);
}
#[test]
fn page_motions_move_by_default_page_lines_and_clamp_at_edges() {
let text = "00\n01\n02\n03\n04\n05\n06\n07\n08\n09\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n21";
assert_eq!(
apply_motion(text, 0, Motion::Page(PageDirection::Forward)),
"00\n01\n02\n03\n04\n05\n06\n07\n08\n09\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n"
.len()
);
assert_eq!(
apply_motion(text, text.len(), Motion::Page(PageDirection::Backward)),
"00\n0".len()
);
}
#[test]
fn char_search_matches_vim_find_and_till_shapes() {
let text = "abc def ghi";
assert_eq!(
apply_motion(
text,
0,
Motion::CharSearch(CharSearch {
target: 'd',
direction: CharSearchDirection::Forward,
placement: CharSearchPlacement::OnMatch,
})
),
"abc ".len()
);
assert_eq!(
apply_motion(
text,
"abc ".len(),
Motion::CharSearch(CharSearch {
target: 'g',
direction: CharSearchDirection::Forward,
placement: CharSearchPlacement::BeforeMatch,
})
),
"abc def ".len() - 1
);
assert_eq!(
apply_motion(
text,
"abc def ".len(),
Motion::CharSearch(CharSearch {
target: 'c',
direction: CharSearchDirection::Backward,
placement: CharSearchPlacement::OnMatch,
})
),
"ab".len()
);
assert_eq!(
apply_motion(
text,
"abc def ".len(),
Motion::CharSearch(CharSearch {
target: 'c',
direction: CharSearchDirection::Backward,
placement: CharSearchPlacement::BeforeMatch,
})
),
"abc".len()
);
}
#[test]
fn char_search_stays_on_current_line_and_noops_without_match() {
let text = "abc\nabc";
assert_eq!(
apply_motion(
text,
0,
Motion::CharSearch(CharSearch {
target: 'a',
direction: CharSearchDirection::Forward,
placement: CharSearchPlacement::OnMatch,
})
),
0
);
assert_eq!(
apply_motion(
text,
"abc\n".len(),
Motion::CharSearch(CharSearch {
target: 'c',
direction: CharSearchDirection::Backward,
placement: CharSearchPlacement::OnMatch,
})
),
"abc\n".len()
);
}
}