use std::ops::Range;
use crate::document::{next_grapheme_offset, Buffer};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FindKind {
Forward, Backward, ForwardTill, BackwardTill, }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Motion {
Left, Right, WordForward, WordEnd, WordBackward, CurrentWordEnd, CurrentBigWordEnd, BigWordForward, BigWordEnd, BigWordBackward, LineStart, LineFirstNonBlank, LineEnd, DocStart, DocEnd, GoToLine(u32), FindChar(char, FindKind), ParagraphForward, ParagraphBackward, MatchingPair, }
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OpRange {
Chars(Range<usize>),
Lines { first: usize, last: usize },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Class {
Blank,
Word,
Punct,
}
pub(crate) fn class(c: char, big: bool) -> Class {
if c.is_whitespace() {
Class::Blank
} else if big || c.is_alphanumeric() || c == '_' {
Class::Word
} else {
Class::Punct
}
}
fn char_at(buf: &Buffer, offset: usize) -> char {
buf.rope().char(offset)
}
pub fn resolve_motion(motion: Motion, count: u32, cursor: usize, buf: &Buffer) -> usize {
let count = count.max(1);
match motion {
Motion::Left => step_horizontal(buf, cursor, count, false),
Motion::Right => step_horizontal(buf, cursor, count, true),
Motion::WordForward => word_forward(buf, cursor, count, false),
Motion::WordEnd => word_end(buf, cursor, count, false),
Motion::WordBackward => word_backward(buf, cursor, count, false),
Motion::CurrentWordEnd => current_word_end(buf, cursor, count, false),
Motion::CurrentBigWordEnd => current_word_end(buf, cursor, count, true),
Motion::BigWordForward => word_forward(buf, cursor, count, true),
Motion::BigWordEnd => word_end(buf, cursor, count, true),
Motion::BigWordBackward => word_backward(buf, cursor, count, true),
Motion::LineStart => line_start(buf, cursor),
Motion::LineFirstNonBlank => first_non_blank(buf, buf.char_to_line(cursor)),
Motion::LineEnd => line_end(buf, cursor),
Motion::DocStart => first_non_blank(buf, 0),
Motion::DocEnd => first_non_blank(buf, last_content_line(buf)),
Motion::GoToLine(n) => first_non_blank(buf, goto_line_index(buf, n)),
Motion::FindChar(c, kind) => {
find_char(buf, cursor, c, kind, count, false)
}
Motion::ParagraphForward => paragraph(buf, cursor, count, true),
Motion::ParagraphBackward => paragraph(buf, cursor, count, false),
Motion::MatchingPair => matching_pair(buf, cursor),
}
}
pub fn resolve_motion_range(motion: Motion, count: u32, cursor: usize, buf: &Buffer) -> OpRange {
match motion {
Motion::DocStart => OpRange::Lines {
first: 0,
last: buf.char_to_line(cursor),
},
Motion::DocEnd => OpRange::Lines {
first: buf.char_to_line(cursor),
last: last_content_line(buf),
},
Motion::GoToLine(n) => {
let line = buf.char_to_line(cursor);
let target = goto_line_index(buf, n);
OpRange::Lines {
first: line.min(target),
last: line.max(target),
}
}
_ => {
let mut target = resolve_motion(motion, count, cursor, buf);
if matches!(motion, Motion::WordForward | Motion::BigWordForward)
&& buf.char_to_line(target) > buf.char_to_line(cursor)
{
target = line_end(buf, cursor);
}
let lo = cursor.min(target);
let mut hi = cursor.max(target);
let inclusive = match motion {
Motion::WordEnd
| Motion::BigWordEnd
| Motion::CurrentWordEnd
| Motion::CurrentBigWordEnd => true,
Motion::FindChar(_, FindKind::Forward | FindKind::ForwardTill) => target > cursor,
Motion::MatchingPair => target != cursor,
_ => false,
};
if inclusive {
hi = next_grapheme_offset(buf, hi).min(buf.len_chars());
}
OpRange::Chars(lo..hi)
}
}
}
pub fn vertical_line_range(buf: &Buffer, cursor: usize, count: u32, down: bool) -> OpRange {
let line = buf.char_to_line(cursor);
let last = buf.line_count().saturating_sub(1);
let step = count.max(1) as usize;
let (first, last_line) = if down {
(line, (line + step).min(last))
} else {
(line.saturating_sub(step), line)
};
OpRange::Lines {
first,
last: last_line,
}
}
pub fn doubled_line_range(buf: &Buffer, cursor: usize, count: u32) -> OpRange {
let line = buf.char_to_line(cursor);
let last = buf.line_count().saturating_sub(1);
let span = count.max(1) as usize;
OpRange::Lines {
first: line,
last: (line + span - 1).min(last),
}
}
fn step_horizontal(buf: &Buffer, cursor: usize, count: u32, forward: bool) -> usize {
let mut pos = cursor;
if forward {
let bound = line_end(buf, cursor);
for _ in 0..count {
if pos >= bound {
break;
}
pos = next_grapheme_offset(buf, pos);
}
} else {
let bound = line_start(buf, cursor);
for _ in 0..count {
if pos <= bound {
break;
}
pos = crate::document::prev_grapheme_offset(buf, pos);
}
}
pos
}
fn word_forward(buf: &Buffer, mut pos: usize, count: u32, big: bool) -> usize {
let len = buf.len_chars();
for _ in 0..count {
if pos >= len {
break;
}
let start = class(char_at(buf, pos), big);
if start != Class::Blank {
while pos < len && class(char_at(buf, pos), big) == start {
pos += 1;
}
}
while pos < len && class(char_at(buf, pos), big) == Class::Blank {
pos += 1;
}
}
pos
}
fn word_end(buf: &Buffer, mut pos: usize, count: u32, big: bool) -> usize {
let len = buf.len_chars();
if len == 0 {
return 0;
}
for _ in 0..count {
if pos + 1 >= len {
return len.saturating_sub(1);
}
pos += 1;
while pos < len && class(char_at(buf, pos), big) == Class::Blank {
pos += 1;
}
if pos >= len {
return len.saturating_sub(1);
}
let cls = class(char_at(buf, pos), big);
while pos + 1 < len && class(char_at(buf, pos + 1), big) == cls {
pos += 1;
}
}
pos
}
fn current_word_end(buf: &Buffer, cursor: usize, count: u32, big: bool) -> usize {
let len = buf.len_chars();
if cursor >= len {
return cursor;
}
let mut pos = cursor;
let cls = class(char_at(buf, pos), big);
if cls != Class::Blank {
while pos + 1 < len && class(char_at(buf, pos + 1), big) == cls {
pos += 1;
}
}
if count > 1 {
pos = word_end(buf, pos, count - 1, big);
}
pos
}
fn word_backward(buf: &Buffer, mut pos: usize, count: u32, big: bool) -> usize {
for _ in 0..count {
if pos == 0 {
break;
}
pos -= 1;
while pos > 0 && class(char_at(buf, pos), big) == Class::Blank {
pos -= 1;
}
if class(char_at(buf, pos), big) == Class::Blank {
break;
}
let cls = class(char_at(buf, pos), big);
while pos > 0 && class(char_at(buf, pos - 1), big) == cls {
pos -= 1;
}
}
pos
}
fn find_char(
buf: &Buffer,
cursor: usize,
target: char,
kind: FindKind,
count: u32,
skip_adjacent: bool,
) -> usize {
let count = count.max(1);
let line = buf.char_to_line(cursor);
let lstart = buf.line_to_char(line);
let lend = line_end_offset(buf, line);
let forward = matches!(kind, FindKind::Forward | FindKind::ForwardTill);
let till = matches!(kind, FindKind::ForwardTill | FindKind::BackwardTill);
let mut hits = 0;
let found = if forward {
let mut pos = None;
let mut i = cursor + 1;
if skip_adjacent && till && i < lend && char_at(buf, i) == target {
i += 1;
}
while i < lend {
if char_at(buf, i) == target {
hits += 1;
if hits == count {
pos = Some(i);
break;
}
}
i += 1;
}
pos
} else {
let mut pos = None;
let mut i = cursor;
if skip_adjacent && till && i > lstart && char_at(buf, i - 1) == target {
i -= 1;
}
while i > lstart {
i -= 1;
if char_at(buf, i) == target {
hits += 1;
if hits == count {
pos = Some(i);
break;
}
}
}
pos
};
match (found, kind) {
(Some(p), FindKind::Forward | FindKind::Backward) => p,
(Some(p), FindKind::ForwardTill) => p.saturating_sub(1).max(cursor),
(Some(p), FindKind::BackwardTill) => (p + 1).min(cursor),
(None, _) => cursor,
}
}
pub fn resolve_find_repeat(
buf: &Buffer,
cursor: usize,
target: char,
kind: FindKind,
count: u32,
) -> usize {
find_char(
buf, cursor, target, kind, count, true,
)
}
fn is_blank_line(buf: &Buffer, line: usize) -> bool {
buf.line(line)
.map(|s| s.trim_end_matches('\n').is_empty())
.unwrap_or(true)
}
fn paragraph(buf: &Buffer, cursor: usize, count: u32, forward: bool) -> usize {
let count = count.max(1);
let last = buf.line_count().saturating_sub(1);
let mut line = buf.char_to_line(cursor);
for _ in 0..count {
if forward {
if line >= last {
return buf.len_chars();
}
let mut l = line + 1;
while l < last && !is_blank_line(buf, l) {
l += 1;
}
if is_blank_line(buf, l) {
line = l;
} else {
return buf.len_chars();
}
} else {
if line == 0 {
return 0;
}
let mut l = line - 1;
while l > 0 && !is_blank_line(buf, l) {
l -= 1;
}
line = l;
}
}
buf.line_to_char(line)
}
fn matching_pair(buf: &Buffer, cursor: usize) -> usize {
let len = buf.len_chars();
let line = buf.char_to_line(cursor);
let lend = line_end_offset(buf, line);
let mut p = cursor;
while p < lend && !is_bracket(char_at(buf, p)) {
p += 1;
}
if p >= lend {
return cursor;
}
let (open, close, forward) = match char_at(buf, p) {
'(' => ('(', ')', true),
')' => ('(', ')', false),
'[' => ('[', ']', true),
']' => ('[', ']', false),
'{' => ('{', '}', true),
'}' => ('{', '}', false),
_ => return cursor,
};
let mut depth = 0i32;
if forward {
let mut i = p;
while i < len {
let ch = char_at(buf, i);
if ch == open {
depth += 1;
} else if ch == close {
depth -= 1;
if depth == 0 {
return i;
}
}
i += 1;
}
} else {
let mut i = p;
loop {
let ch = char_at(buf, i);
if ch == close {
depth += 1;
} else if ch == open {
depth -= 1;
if depth == 0 {
return i;
}
}
if i == 0 {
break;
}
i -= 1;
}
}
cursor
}
fn is_bracket(c: char) -> bool {
matches!(c, '(' | ')' | '[' | ']' | '{' | '}')
}
fn line_start(buf: &Buffer, pos: usize) -> usize {
buf.line_to_char(buf.char_to_line(pos))
}
fn line_end(buf: &Buffer, pos: usize) -> usize {
line_end_offset(buf, buf.char_to_line(pos))
}
pub fn line_end_offset(buf: &Buffer, line: usize) -> usize {
let start = buf.line_to_char(line);
let content_len = buf
.line(line)
.map(|s| s.trim_end_matches('\n').chars().count())
.unwrap_or(0);
start + content_len
}
pub fn first_non_blank(buf: &Buffer, line: usize) -> usize {
let len = buf.len_chars();
let mut pos = buf.line_to_char(line.min(buf.line_count().saturating_sub(1)));
while pos < len {
let c = char_at(buf, pos);
if c == '\n' || !c.is_whitespace() {
break;
}
pos += 1;
}
pos
}
fn goto_line_index(buf: &Buffer, n: u32) -> usize {
(n.saturating_sub(1) as usize).min(last_content_line(buf))
}
fn last_content_line(buf: &Buffer) -> usize {
let len = buf.len_chars();
let mut last = buf.char_to_line(len);
if last > 0 && buf.line_to_char(last) == len {
last -= 1;
}
last
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::Buffer;
fn buf(s: &str) -> Buffer {
Buffer::from_str(s)
}
fn m(motion: Motion, cursor: usize, text: &str) -> usize {
resolve_motion(motion, 1, cursor, &buf(text))
}
#[test]
fn word_forward_crosses_punctuation_runs() {
let t = "foo.bar baz";
assert_eq!(m(Motion::WordForward, 0, t), 3); assert_eq!(m(Motion::WordForward, 3, t), 4); assert_eq!(m(Motion::WordForward, 4, t), 8); }
#[test]
fn big_word_forward_ignores_punctuation() {
let t = "foo.bar baz";
assert_eq!(m(Motion::BigWordForward, 0, t), 8); }
#[test]
fn word_end_lands_on_last_char() {
let t = "foo bar";
assert_eq!(m(Motion::WordEnd, 0, t), 2);
assert_eq!(m(Motion::WordEnd, 2, t), 6);
}
#[test]
fn word_backward_lands_on_word_start() {
let t = "foo bar baz";
assert_eq!(m(Motion::WordBackward, 10, t), 8);
assert_eq!(m(Motion::WordBackward, 8, t), 4);
}
#[test]
fn line_motions() {
let t = " hello world\nsecond";
assert_eq!(m(Motion::LineStart, 5, t), 0);
assert_eq!(m(Motion::LineFirstNonBlank, 5, t), 2);
assert_eq!(m(Motion::LineEnd, 0, t), 13);
}
#[test]
fn doc_motions() {
let t = " first\nmiddle\nlast";
assert_eq!(m(Motion::DocStart, 15, t), 2);
assert_eq!(m(Motion::DocEnd, 0, t), 15);
}
#[test]
fn doc_end_skips_a_trailing_empty_line() {
let t = "alpha\nbeta\n";
assert_eq!(m(Motion::DocEnd, 0, t), 6); }
#[test]
fn goto_line_lands_on_the_first_non_blank_of_that_line() {
let t = "alpha\nbravo\n charlie\ndelta";
assert_eq!(m(Motion::GoToLine(3), 0, t), t.find("charlie").unwrap());
assert_eq!(m(Motion::GoToLine(1), 20, t), 0);
}
#[test]
fn goto_line_clamps_past_the_end() {
let t = "alpha\nbeta\n";
assert_eq!(m(Motion::GoToLine(999), 0, t), m(Motion::DocEnd, 0, t));
}
#[test]
fn word_forward_crosses_a_newline() {
let t = "foo\nbar";
assert_eq!(m(Motion::WordForward, 0, t), 4);
}
#[test]
fn range_word_forward_is_exclusive() {
let b = buf("foo bar");
assert_eq!(
resolve_motion_range(Motion::WordForward, 1, 0, &b),
OpRange::Chars(0..4)
);
}
#[test]
fn range_word_end_is_inclusive() {
let b = buf("foo bar");
assert_eq!(
resolve_motion_range(Motion::WordEnd, 1, 0, &b),
OpRange::Chars(0..3)
);
}
#[test]
fn range_word_forward_clamps_at_line_end() {
let b = buf("foo\nbar");
assert_eq!(
resolve_motion_range(Motion::WordForward, 1, 0, &b),
OpRange::Chars(0..3)
);
}
#[test]
fn range_line_end_stops_before_newline() {
let b = buf("abc\ndef");
assert_eq!(
resolve_motion_range(Motion::LineEnd, 1, 0, &b),
OpRange::Chars(0..3)
);
}
#[test]
fn range_doc_start_and_end_are_linewise() {
let b = buf("a\nb\nc\nd");
let cursor = b.line_to_char(2); assert_eq!(
resolve_motion_range(Motion::DocStart, 1, cursor, &b),
OpRange::Lines { first: 0, last: 2 }
);
assert_eq!(
resolve_motion_range(Motion::DocEnd, 1, cursor, &b),
OpRange::Lines { first: 2, last: 3 }
);
}
#[test]
fn range_goto_line_is_linewise_in_both_directions() {
let b = buf("a\nb\nc\nd");
let cursor = b.line_to_char(2); assert_eq!(
resolve_motion_range(Motion::GoToLine(4), 1, cursor, &b),
OpRange::Lines { first: 2, last: 3 }
);
assert_eq!(
resolve_motion_range(Motion::GoToLine(1), 1, cursor, &b),
OpRange::Lines { first: 0, last: 2 }
);
}
#[test]
fn doubled_range_spans_count_lines_from_cursor() {
let b = buf("a\nb\nc\nd");
assert_eq!(
doubled_line_range(&b, 0, 2),
OpRange::Lines { first: 0, last: 1 }
);
let last = b.line_to_char(3);
assert_eq!(
doubled_line_range(&b, last, 5),
OpRange::Lines { first: 3, last: 3 }
);
}
#[test]
fn vertical_range_walks_up_and_down() {
let b = buf("a\nb\nc\nd");
let mid = b.line_to_char(2); assert_eq!(
vertical_line_range(&b, mid, 1, true),
OpRange::Lines { first: 2, last: 3 }
);
assert_eq!(
vertical_line_range(&b, mid, 2, false),
OpRange::Lines { first: 0, last: 2 }
);
}
#[test]
fn horizontal_step_clamps_to_the_line() {
let b = buf("abc\ndef");
assert_eq!(resolve_motion(Motion::Right, 9, 0, &b), 3);
assert_eq!(resolve_motion(Motion::Left, 9, 2, &b), 0);
}
#[test]
fn find_forward_lands_on_the_char() {
let t = "foo.bar.baz";
assert_eq!(m(Motion::FindChar('.', FindKind::Forward), 0, t), 3);
assert_eq!(
resolve_motion(Motion::FindChar('.', FindKind::Forward), 2, 0, &buf(t)),
7
);
}
#[test]
fn find_till_stops_one_short() {
let t = "abcXdef";
assert_eq!(m(Motion::FindChar('X', FindKind::ForwardTill), 0, t), 2);
}
#[test]
fn find_repeat_skips_an_adjacent_till_match() {
let b = buf("abc-d-e-f");
assert_eq!(
resolve_motion(Motion::FindChar('-', FindKind::ForwardTill), 1, 2, &b),
2,
"fresh till is stuck on the adjacent match"
);
assert_eq!(
resolve_find_repeat(&b, 2, '-', FindKind::ForwardTill, 1),
4,
"the repeat skips it"
);
assert_eq!(
resolve_find_repeat(&b, 6, '-', FindKind::BackwardTill, 1),
4
);
assert_eq!(
resolve_find_repeat(&b, 0, '-', FindKind::Forward, 1),
resolve_motion(Motion::FindChar('-', FindKind::Forward), 1, 0, &b)
);
}
#[test]
fn find_backward_and_till() {
let t = "a.b.c";
assert_eq!(m(Motion::FindChar('.', FindKind::Backward), 4, t), 3);
assert_eq!(m(Motion::FindChar('.', FindKind::BackwardTill), 4, t), 4);
let u = "a..xc";
assert_eq!(m(Motion::FindChar('.', FindKind::BackwardTill), 4, u), 3);
}
#[test]
fn find_misses_leave_the_cursor() {
let t = "abc\nzzz";
assert_eq!(m(Motion::FindChar('z', FindKind::Forward), 0, t), 0);
}
#[test]
fn paragraph_forward_and_backward() {
let t = "a\nb\n\nc\nd";
assert_eq!(m(Motion::ParagraphForward, 0, t), 4);
assert_eq!(m(Motion::ParagraphBackward, 6, t), 4);
assert_eq!(m(Motion::ParagraphForward, 6, t), buf(t).len_chars());
}
#[test]
fn matching_pair_jumps_each_bracket_type() {
let t = "(a[b]{c})";
assert_eq!(m(Motion::MatchingPair, 0, t), 8); assert_eq!(m(Motion::MatchingPair, 8, t), 0); assert_eq!(m(Motion::MatchingPair, 2, t), 4); assert_eq!(m(Motion::MatchingPair, 5, t), 7); }
#[test]
fn matching_pair_scans_forward_to_the_first_bracket() {
let t = "x = (1 + 2)";
assert_eq!(m(Motion::MatchingPair, 0, t), 10);
}
#[test]
fn matching_pair_respects_nesting() {
let t = "((()))";
assert_eq!(m(Motion::MatchingPair, 0, t), 5);
assert_eq!(m(Motion::MatchingPair, 1, t), 4);
assert_eq!(m(Motion::MatchingPair, 2, t), 3);
}
#[test]
fn range_forward_find_is_inclusive() {
let b = buf("ab.cd");
assert_eq!(
resolve_motion_range(Motion::FindChar('.', FindKind::Forward), 1, 0, &b),
OpRange::Chars(0..3)
);
}
#[test]
fn range_matching_pair_is_inclusive_both_ends() {
let b = buf("(abc)");
assert_eq!(
resolve_motion_range(Motion::MatchingPair, 1, 0, &b),
OpRange::Chars(0..5)
);
assert_eq!(
resolve_motion_range(Motion::MatchingPair, 1, 4, &b),
OpRange::Chars(0..5)
);
}
}