use crate::{
core_editor::{
graphemes::{next_grapheme_boundary, prev_grapheme_boundary},
line, recohere,
word::{self, categorize_char, CharClass},
CaretGeometry, Cursor,
},
enums::{Direction, MotionTarget, WordEdge},
FindStop, WordKind,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ResolvedMotion {
pub(crate) head: usize,
pub(crate) op_end: usize,
}
pub(crate) fn operator_span(
buf: &str,
origin: usize,
target: MotionTarget,
geometry: CaretGeometry,
) -> Cursor {
recohere(
buf,
Cursor::new(origin, resolve_motion(buf, origin, target, geometry).op_end),
)
}
pub(crate) fn resolve_motion(
buf: &str,
origin: usize,
target: MotionTarget,
geometry: CaretGeometry,
) -> ResolvedMotion {
let block = geometry.is_inclusive();
let span = |head: usize, inclusive: bool| ResolvedMotion {
head,
op_end: if inclusive {
next_grapheme_boundary(buf, head)
} else {
head
},
};
match target {
MotionTarget::Grapheme(Direction::Forward) => {
span(next_grapheme_boundary(buf, origin), false)
}
MotionTarget::Grapheme(Direction::Backward) => {
span(prev_grapheme_boundary(buf, origin), false)
}
MotionTarget::Word {
kind,
edge,
direction,
} => {
let forward = direction == Direction::Forward;
let on_grapheme = block && forward && edge == WordEdge::End;
let head = if on_grapheme {
let probe = next_grapheme_boundary(buf, origin);
prev_grapheme_boundary(buf, word::locate_word(buf, probe, kind, edge, direction))
} else {
word::locate_word(buf, origin, kind, edge, direction)
};
span(head, on_grapheme)
}
MotionTarget::Position(n) => span(n.min(buf.len()), false),
MotionTarget::BufferEdge(Direction::Backward) => span(0, false),
MotionTarget::BufferEdge(Direction::Forward) => span(buf.len(), false),
#[cfg(feature = "helix")]
MotionTarget::LineStartNonBlank => {
span(line::first_non_blank(buf, origin).unwrap_or(origin), false)
}
MotionTarget::LineEdge(Direction::Backward) => {
span(line::start_of_line(buf, origin), false)
}
MotionTarget::LineEdge(Direction::Forward) => span(line::end_of_line(buf, origin), false),
MotionTarget::Line(Direction::Forward) => {
let head = line::start_of_next_line(buf, origin).unwrap_or(origin);
span(head, false)
}
MotionTarget::Line(Direction::Backward) => {
let line_start = line::start_of_line(buf, origin);
let head = if line_start == 0 {
origin
} else {
line::start_of_line(buf, line_start - 1)
};
span(head, false)
}
MotionTarget::Find {
ch,
direction,
stop,
} => {
let hit = find_char(buf, origin, ch, direction, stop);
let inclusive = hit.is_some() && direction == Direction::Forward;
span(hit.unwrap_or(origin), inclusive)
}
}
}
pub(crate) fn resolve_selection(
buf: &str,
origin: usize,
target: MotionTarget,
geometry: CaretGeometry,
) -> Cursor {
let m = resolve_motion(buf, origin, target, geometry);
if m.head == origin {
return Cursor::point(origin);
}
if m.head < origin {
let anchor = if word_boundary_at_origin(buf, origin, target, Direction::Backward) {
origin
} else {
next_grapheme_boundary(buf, origin)
};
return Cursor::new(anchor, m.head);
}
let next = next_grapheme_boundary(buf, origin);
let on_boundary = word_boundary_at_origin(buf, origin, target, Direction::Forward);
let (anchor, movement) = match target {
MotionTarget::Word {
edge: WordEdge::Start,
..
} if on_boundary => (next, resolve_motion(buf, next, target, geometry)),
MotionTarget::Word {
edge: WordEdge::End,
..
} if on_boundary => (next, m),
_ => (origin, m),
};
Cursor::new(anchor, movement.op_end.max(anchor))
}
fn word_boundary_at_origin(
buf: &str,
origin: usize,
target: MotionTarget,
direction: Direction,
) -> bool {
let MotionTarget::Word { kind, edge, .. } = target else {
return false;
};
let Some(a) = buf[origin..].chars().next() else {
return false;
};
let b = match direction {
Direction::Forward => buf[next_grapheme_boundary(buf, origin)..].chars().next(),
Direction::Backward => buf[prev_grapheme_boundary(buf, origin)..].chars().next(),
};
let Some(b) = b else {
return false;
};
let is_boundary = match kind {
WordKind::Word => word::is_word_boundary(a, b),
WordKind::LongWord => word::is_long_word_boundary(a, b),
WordKind::Unicode => return false,
};
let class_ok = match (edge, direction) {
(WordEdge::Start, Direction::Forward) => {
matches!(categorize_char(b), CharClass::Word | CharClass::Punctuation)
}
(WordEdge::End, Direction::Forward) | (WordEdge::Start, Direction::Backward) => {
matches!(categorize_char(a), CharClass::Word | CharClass::Punctuation)
}
(WordEdge::End, Direction::Backward) => {
matches!(categorize_char(b), CharClass::Word | CharClass::Punctuation)
}
};
is_boundary && class_ok
}
fn find_char(
buf: &str,
origin: usize,
ch: char,
direction: Direction,
stop: FindStop,
) -> Option<usize> {
let hit = match direction {
Direction::Forward => {
let start = next_grapheme_boundary(buf, origin);
buf[start..].find(ch).map(|rel| start + rel)
}
Direction::Backward => buf[..origin].rfind(ch),
}?;
Some(match (direction, stop) {
(_, FindStop::On) => hit,
(Direction::Forward, FindStop::Before) => prev_grapheme_boundary(buf, hit),
(Direction::Backward, FindStop::Before) => next_grapheme_boundary(buf, hit),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::WordKind;
use rstest::rstest;
fn word(edge: WordEdge, direction: Direction) -> MotionTarget {
MotionTarget::Word {
kind: WordKind::Word,
edge,
direction,
}
}
#[test]
fn resolve_motion_marks_forward_word_end_inclusive() {
let m = resolve_motion(
"foo bar",
0,
word(WordEdge::End, Direction::Forward),
CaretGeometry::Block,
);
assert_eq!(m, ResolvedMotion { head: 2, op_end: 3 });
let m = resolve_motion(
"foo bar",
0,
word(WordEdge::Start, Direction::Forward),
CaretGeometry::Block,
);
assert_eq!(m.op_end, m.head);
let m = resolve_motion(
"foo bar",
7,
word(WordEdge::End, Direction::Backward),
CaretGeometry::Block,
);
assert_eq!(m.op_end, m.head);
}
#[cfg(feature = "helix")]
#[test]
fn resolve_motion_line_start_non_blank_lands_on_the_indent_end() {
for origin in [0, 4, 6] {
for geometry in [CaretGeometry::Block, CaretGeometry::Bar] {
let m =
resolve_motion(" foo", origin, MotionTarget::LineStartNonBlank, geometry);
assert_eq!(
m,
ResolvedMotion { head: 4, op_end: 4 },
"origin {origin}, {geometry:?}"
);
}
}
}
#[cfg(feature = "helix")]
#[test]
fn resolve_motion_stays_put_for_line_start_non_blank_with_nowhere_to_go() {
for (buf, origin) in [(" \nfoo", 1), ("foo\n\nbar", 4), (" \r\nfoo", 2)] {
let m = resolve_motion(
buf,
origin,
MotionTarget::LineStartNonBlank,
CaretGeometry::Block,
);
assert_eq!(
m,
ResolvedMotion {
head: origin,
op_end: origin
},
"buf {buf:?} at {origin}"
);
}
}
#[test]
fn resolve_motion_handles_line_and_buffer_edges() {
let buf = "ab\ncd\nef";
assert_eq!(
resolve_motion(
buf,
4,
MotionTarget::LineEdge(Direction::Backward),
CaretGeometry::Block
)
.head,
3
);
assert_eq!(
resolve_motion(
buf,
4,
MotionTarget::LineEdge(Direction::Forward),
CaretGeometry::Block
)
.head,
5
);
assert_eq!(
resolve_motion(
buf,
4,
MotionTarget::BufferEdge(Direction::Backward),
CaretGeometry::Block
)
.head,
0
);
assert_eq!(
resolve_motion(
buf,
4,
MotionTarget::BufferEdge(Direction::Forward),
CaretGeometry::Block
)
.head,
8
);
}
use crate::enums::FindStop;
fn find(ch: char, direction: Direction, stop: FindStop) -> MotionTarget {
MotionTarget::Find {
ch,
direction,
stop,
}
}
#[test]
fn resolve_motion_find_forward_on_lands_on_char() {
assert_eq!(
resolve_motion(
"foo bar",
0,
find('b', Direction::Forward, FindStop::On),
CaretGeometry::Block
),
ResolvedMotion { head: 4, op_end: 5 } );
}
#[test]
fn resolve_motion_find_forward_before_stops_short() {
assert_eq!(
resolve_motion(
"foo bar",
0,
find('b', Direction::Forward, FindStop::Before),
CaretGeometry::Block
),
ResolvedMotion { head: 3, op_end: 4 } );
}
#[test]
fn resolve_motion_find_backward_on_lands_on_char() {
assert_eq!(
resolve_motion(
"foo bar",
6,
find('f', Direction::Backward, FindStop::On),
CaretGeometry::Block
),
ResolvedMotion { head: 0, op_end: 0 } );
}
#[test]
fn resolve_motion_find_backward_before_stops_short() {
assert_eq!(
resolve_motion(
"foo bar",
6,
find('f', Direction::Backward, FindStop::Before),
CaretGeometry::Block
),
ResolvedMotion { head: 1, op_end: 1 } );
}
#[test]
fn resolve_motion_find_searches_strictly_past_origin() {
assert_eq!(
resolve_motion(
"foo bar",
4,
find('b', Direction::Forward, FindStop::On),
CaretGeometry::Block
)
.head,
4
);
}
#[test]
fn resolve_motion_find_before_replay_from_landing_spot_is_stuck() {
let t = find('x', Direction::Forward, FindStop::Before);
assert_eq!(resolve_motion("axbxc", 0, t, CaretGeometry::Block).head, 0);
assert_eq!(resolve_motion("axbxc", 2, t, CaretGeometry::Block).head, 2);
}
#[test]
fn resolve_motion_find_absent_char_stays_put() {
assert_eq!(
resolve_motion(
"foo bar",
3,
find('z', Direction::Forward, FindStop::On),
CaretGeometry::Block
),
ResolvedMotion { head: 3, op_end: 3 } );
}
#[test]
fn resolve_motion_find_before_respects_grapheme_boundaries() {
assert_eq!(
resolve_motion(
"a→b",
0,
find('b', Direction::Forward, FindStop::Before),
CaretGeometry::Block
)
.head,
1
);
assert_eq!(
resolve_motion(
"a→b",
4,
find('a', Direction::Backward, FindStop::Before),
CaretGeometry::Block
)
.head,
1
);
}
#[test]
fn resolve_motion_find_backward_finds_adjacent_char() {
assert_eq!(
resolve_motion(
"fab",
2,
find('a', Direction::Backward, FindStop::On),
CaretGeometry::Block
)
.head,
1
);
}
#[test]
fn resolve_motion_find_backward_searches_strictly_before_origin() {
assert_eq!(
resolve_motion(
"bab",
0,
find('b', Direction::Backward, FindStop::On),
CaretGeometry::Block
)
.head,
0
);
}
#[test]
fn resolve_motion_line_edge_forward_stops_at_newline() {
assert_eq!(
resolve_motion(
"ab\ncd",
0,
MotionTarget::LineEdge(Direction::Forward),
CaretGeometry::Block
),
ResolvedMotion { head: 2, op_end: 2 } );
}
#[test]
fn resolve_motion_line_edge_forward_stops_before_crlf() {
assert_eq!(
resolve_motion(
"ab\r\ncd",
0,
MotionTarget::LineEdge(Direction::Forward),
CaretGeometry::Block
)
.head,
2
);
}
#[test]
fn resolve_motion_line_edge_backward_stops_at_line_start() {
assert_eq!(
resolve_motion(
"ab\ncd",
4,
MotionTarget::LineEdge(Direction::Backward),
CaretGeometry::Block
)
.head,
3
);
}
#[test]
fn resolve_motion_buffer_edge_spans_whole_buffer() {
assert_eq!(
resolve_motion(
"ab\ncd",
0,
MotionTarget::BufferEdge(Direction::Forward),
CaretGeometry::Block
)
.head,
5
);
assert_eq!(
resolve_motion(
"ab\ncd",
4,
MotionTarget::BufferEdge(Direction::Backward),
CaretGeometry::Block
)
.head,
0
);
}
#[test]
fn resolve_motion_line_targets_the_adjacent_line() {
let buf = "ab\ncd\nef"; assert_eq!(
resolve_motion(
buf,
4,
MotionTarget::Line(Direction::Forward),
CaretGeometry::Block
)
.head,
6
);
assert_eq!(
resolve_motion(
buf,
4,
MotionTarget::Line(Direction::Backward),
CaretGeometry::Block
)
.head,
0
);
assert_eq!(
resolve_motion(
buf,
7,
MotionTarget::Line(Direction::Forward),
CaretGeometry::Block
)
.head,
7
);
assert_eq!(
resolve_motion(
buf,
1,
MotionTarget::Line(Direction::Backward),
CaretGeometry::Block
)
.head,
1
);
}
#[rstest]
#[case("abc def ghi", 0, 2)]
#[case("abc-def ghi", 0, 2)]
#[case("abc.def ghi", 0, 6)]
#[case("abc", 1, 2)]
#[case("abc", 2, 2)]
#[case("abc def", 2, 6)]
fn locate_unicode_word_right_end(
#[case] input: &str,
#[case] position: usize,
#[case] expected: usize,
) {
let head = resolve_motion(
input,
position,
MotionTarget::Word {
kind: WordKind::Unicode,
edge: WordEdge::End,
direction: Direction::Forward,
},
CaretGeometry::Block,
)
.head;
assert_eq!(head, expected);
}
#[rstest]
#[case("abc def ghi", 0, 2)]
#[case("abc-def ghi", 0, 6)]
#[case("abc-def ghi", 5, 6)]
#[case("abc-def ghi", 6, 10)]
#[case("abc.def ghi", 0, 6)]
#[case("abc", 1, 2)]
#[case("abc", 2, 2)]
#[case("abc def", 2, 6)]
#[case("abc-def", 6, 6)]
fn locate_long_word_right_end(
#[case] input: &str,
#[case] position: usize,
#[case] expected: usize,
) {
let head = resolve_motion(
input,
position,
MotionTarget::Word {
kind: WordKind::LongWord,
edge: WordEdge::End,
direction: Direction::Forward,
},
CaretGeometry::Block,
)
.head;
assert_eq!(head, expected);
}
#[rstest]
#[case("", 0, 0)] #[case("word", 0, 3)] #[case("word and another one", 0, 3)]
#[case("word and another one", 3, 7)] #[case("word and another one", 4, 7)] #[case("word\nline two", 0, 3)] #[case("word\nline two", 3, 8)] #[case("weirdö characters", 0, 5)] #[case("weirdö characters", 5, 17)] #[case("weirdö", 0, 5)] #[case("weirdö", 5, 5)] #[case("word😇 with emoji", 0, 3)] #[case("word😇 with emoji", 3, 4)] #[case("😇", 0, 0)] fn locate_unicode_word_right_end_multibyte(
#[case] input: &str,
#[case] in_location: usize,
#[case] expected: usize,
) {
let head = resolve_motion(
input,
in_location,
MotionTarget::Word {
kind: WordKind::Unicode,
edge: WordEdge::End,
direction: Direction::Forward,
},
CaretGeometry::Block,
)
.head;
assert_eq!(head, expected);
}
fn selection(buf: &str, origin: usize, target: MotionTarget) -> Cursor {
resolve_selection(buf, origin, target, CaretGeometry::Block)
}
#[test]
fn resolve_selection_word_start_selects_through_the_gap() {
let sel = selection("foo bar", 0, word(WordEdge::Start, Direction::Forward));
assert_eq!((sel.anchor(), sel.head()), (0, 4));
assert_eq!(sel.caret("foo bar"), 3);
}
#[test]
fn resolve_selection_word_start_hops_when_cursor_touches_the_boundary() {
let sel = selection("foo bar baz", 3, word(WordEdge::Start, Direction::Forward));
assert_eq!((sel.anchor(), sel.head()), (4, 8));
}
#[test]
fn resolve_selection_word_start_from_space_before_last_word() {
let sel = selection("a b", 1, word(WordEdge::Start, Direction::Forward));
assert_eq!((sel.anchor(), sel.head()), (2, 3));
}
#[test]
fn resolve_selection_word_end_includes_cursor_unless_on_word_end() {
let sel = selection("foo bar", 0, word(WordEdge::End, Direction::Forward));
assert_eq!((sel.anchor(), sel.head()), (0, 3));
let sel = selection("foo bar", 2, word(WordEdge::End, Direction::Forward));
assert_eq!((sel.anchor(), sel.head()), (3, 7));
}
#[test]
fn resolve_selection_word_back_excludes_cursor_on_word_start() {
let sel = selection("foo bar", 4, word(WordEdge::Start, Direction::Backward));
assert_eq!((sel.anchor(), sel.head()), (4, 0));
assert_eq!(sel.caret("foo bar"), 0);
}
#[test]
fn resolve_selection_word_back_includes_cursor_mid_word() {
let sel = selection("foo bar", 5, word(WordEdge::Start, Direction::Backward));
assert_eq!((sel.anchor(), sel.head()), (6, 4));
}
#[test]
fn resolve_selection_find_spans_origin_to_hit() {
let sel = selection("foo bar", 0, find('b', Direction::Forward, FindStop::On));
assert_eq!((sel.anchor(), sel.head()), (0, 5));
assert_eq!(sel.caret("foo bar"), 4);
let sel = selection(
"foo bar",
0,
find('b', Direction::Forward, FindStop::Before),
);
assert_eq!((sel.anchor(), sel.head()), (0, 4));
let sel = selection("foo bar", 6, find('f', Direction::Backward, FindStop::On));
assert_eq!((sel.anchor(), sel.head()), (7, 0));
}
#[test]
fn resolve_selection_at_buffer_end_covers_the_tail() {
let sel = selection("foo", 2, word(WordEdge::Start, Direction::Forward));
assert_eq!((sel.anchor(), sel.head()), (2, 3));
let sel = selection("foo bar", 3, find('z', Direction::Forward, FindStop::On));
assert!(sel.is_empty());
}
}