//! Motions — after Helix's `helix-core/src/movement.rs`. //! //! The defining property, and the one most easily got wrong: **word motions //! return a selection, not a point**. Helix's keymap documents `w` as "move //! next word start", but `range_to_target` returns a `Range` whose anchor is //! the pre-motion position and whose head is the target. That is why `d` after //! `w` deletes a word with no operator-pending machinery anywhere //! (MJB-HLR-007, MJB-LLR-065..067). //! //! Implemented against `RopeSlice::char_indices_at`, which yields //! `(byte_idx, char)` and supports `prev()`, so both directions are byte-native //! rather than translated from char offsets. use ropey::RopeSlice; use super::{ LINE_TYPE, grapheme::{byte_at_display_column, display_column, next_grapheme_boundary, prev_grapheme_boundary}, selection::Range, }; /// MJB-LLR-060: character classes that word motions stop between. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CharCategory { Eol, Whitespace, Word, Punctuation, } /// MJB-LLR-060 pub fn categorize_char(c: char) -> CharCategory { if c == '\n' || c == '\r' { CharCategory::Eol } else if c.is_whitespace() { CharCategory::Whitespace } else if c.is_alphanumeric() || c == '_' { CharCategory::Word } else { CharCategory::Punctuation } } /// A coarser classification backing the long-word motions `W`/`B`/`E`, which /// treat punctuation as part of the word. fn categorize_long(c: char) -> CharCategory { match categorize_char(c) { CharCategory::Punctuation => CharCategory::Word, other => other, } } /// MJB-LLR-061 pub fn is_word_boundary(a: char, b: char) -> bool { categorize_char(a) != categorize_char(b) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WordTarget { NextStart, NextEnd, PrevStart, } fn categorizer(long: bool) -> fn(char) -> CharCategory { if long { categorize_long } else { categorize_char } } /// MJB-LLR-062: one grapheme left, collapsing to a point. pub fn move_char_left(text: RopeSlice, range: Range, count: usize) -> Range { let mut pos = range.cursor(text); for _ in 0..count.max(1) { let next = prev_grapheme_boundary(text, pos); if next == pos { break; // MJB-LLR-062: no-op at offset zero } pos = next; } Range::point(pos).clamped(text) } /// MJB-LLR-063: one grapheme right, collapsing to a point. pub fn move_char_right(text: RopeSlice, range: Range, count: usize) -> Range { let mut pos = range.cursor(text); for _ in 0..count.max(1) { let next = next_grapheme_boundary(text, pos); if next == pos { break; // MJB-LLR-063: no-op at end of buffer } pos = next; } Range::point(pos).clamped(text) } /// MJB-LLR-064: vertical motion preserving the display column. pub fn move_vertically(text: RopeSlice, range: Range, count: usize, down: bool) -> Range { let cursor = range.cursor(text); let line = text.byte_to_line_idx(cursor, LINE_TYPE); let line_start = text.line_to_byte_idx(line, LINE_TYPE); let column = display_column(text.line(line, LINE_TYPE), cursor - line_start); let last_line = text.len_lines(LINE_TYPE).saturating_sub(1); let target_line = if down { line.saturating_add(count.max(1)).min(last_line) } else { line.saturating_sub(count.max(1)) }; // MJB-LLR-064: a no-op on the first or last line. if target_line == line { return range; } let target_start = text.line_to_byte_idx(target_line, LINE_TYPE); let offset = byte_at_display_column(text.line(target_line, LINE_TYPE), column); Range::point(target_start + offset).clamped(text) } /// MJB-LLR-065..069: word motion. /// /// Returns a range spanning from the pre-motion cursor to the target, so the /// traversed text ends up selected. pub fn word_move( text: RopeSlice, range: Range, count: usize, target: WordTarget, long: bool, ) -> Range { let cat = categorizer(long); // The anchor is the position the whole traversal started from and does not // move; only the head advances, once per count. Re-deriving the start from // the partial result each iteration would restart from the *cursor* — one // grapheme behind the head — so `2w` would stall inside the first gap // instead of reaching the second word. let anchor = range.cursor(text); let mut head = anchor; for _ in 0..count.max(1) { let next = match target { WordTarget::NextStart => next_word_start(text, head, cat), WordTarget::NextEnd => next_word_end(text, head, cat), WordTarget::PrevStart => prev_word_start(text, head, cat), }; if next == head { break; // MJB-LLR-069: at the buffer boundary } head = next; } if head == anchor { range } else { Range::new(anchor, head).clamped(text) } } /// Characters that separate words rather than belonging to one. fn is_separator(category: CharCategory) -> bool { matches!(category, CharCategory::Whitespace | CharCategory::Eol) } /// The char starting at byte `i`, with its start index. fn char_at(text: RopeSlice, i: usize) -> Option<(usize, char)> { (i < text.len()).then(|| text.char_indices_at(i).next())? } /// The char ending at byte `i` — the one immediately before it. fn char_before(text: RopeSlice, i: usize) -> Option<(usize, char)> { (i > 0).then(|| text.char_indices_at(i).prev())? } /// MJB-LLR-065: first character of the word after `from`. /// /// Runs out whatever category the cursor sits on, then skips separators. fn next_word_start(text: RopeSlice, from: usize, cat: fn(char) -> CharCategory) -> usize { let mut i = from; if let Some((_, c)) = char_at(text, i) { let run = cat(c); while let Some((s, ch)) = char_at(text, i) { if cat(ch) != run { break; } i = s + ch.len_utf8(); } } // MJB-LLR-068 while let Some((s, c)) = char_at(text, i) { if !is_separator(cat(c)) { break; } i = s + c.len_utf8(); } i } /// MJB-LLR-067: one past the last character of the word after `from`. /// /// Steps off the current character first so `e` always advances, even when it /// already sits on the final character of a word. fn next_word_end(text: RopeSlice, from: usize, cat: fn(char) -> CharCategory) -> usize { let mut i = from; if let Some((s, c)) = char_at(text, i) { i = s + c.len_utf8(); } // MJB-LLR-068 while let Some((s, c)) = char_at(text, i) { if !is_separator(cat(c)) { break; } i = s + c.len_utf8(); } if let Some((_, c)) = char_at(text, i) { let run = cat(c); while let Some((s, ch)) = char_at(text, i) { if cat(ch) != run { break; } i = s + ch.len_utf8(); } } i } /// MJB-LLR-066: first character of the word before `from`. fn prev_word_start(text: RopeSlice, from: usize, cat: fn(char) -> CharCategory) -> usize { let mut i = from; // MJB-LLR-068: skip separators immediately behind the cursor. while let Some((s, c)) = char_before(text, i) { if !is_separator(cat(c)) { break; } i = s; } let Some((_, c)) = char_before(text, i) else { return i; // MJB-LLR-069: nothing but separators behind us }; let run = cat(c); while let Some((s, ch)) = char_before(text, i) { if cat(ch) != run { break; } i = s; } i } // --- Goto commands (MJB-HLR-008) --- /// MJB-LLR-070 pub fn goto_file_start(text: RopeSlice) -> Range { let _ = text; Range::point(0) } /// MJB-LLR-071 pub fn goto_last_line(text: RopeSlice) -> Range { let last = last_content_line(text); Range::point(text.line_to_byte_idx(last, LINE_TYPE)).clamped(text) } /// MJB-LLR-072 pub fn goto_line_start(text: RopeSlice, range: Range) -> Range { let line = range.cursor_line(text); Range::point(text.line_to_byte_idx(line, LINE_TYPE)).clamped(text) } /// MJB-LLR-073: the last character of the line, excluding its terminator. pub fn goto_line_end(text: RopeSlice, range: Range) -> Range { let line = range.cursor_line(text); Range::point(line_end_byte(text, line)).clamped(text) } /// First non-whitespace character of the cursor's line. pub fn goto_first_non_whitespace(text: RopeSlice, range: Range) -> Range { let line = range.cursor_line(text); let start = text.line_to_byte_idx(line, LINE_TYPE); let slice = text.line(line, LINE_TYPE); let mut offset = 0; for (i, c) in slice.char_indices() { if !c.is_whitespace() || matches!(categorize_char(c), CharCategory::Eol) { offset = i; break; } offset = i + c.len_utf8(); } Range::point(start + offset).clamped(text) } /// Byte offset just past the last non-terminator character of `line`. /// /// Inspects the final bytes rather than materialising the line: LF and CR are /// single-byte ASCII and cannot occur as a continuation byte of a multi-byte /// character, so testing the trailing bytes is unambiguous. pub fn line_end_byte(text: RopeSlice, line: usize) -> usize { let start = text.line_to_byte_idx(line, LINE_TYPE); let slice = text.line(line, LINE_TYPE); let mut end = slice.len(); if end > 0 && slice.byte(end - 1) == b'\n' { end -= 1; if end > 0 && slice.byte(end - 1) == b'\r' { end -= 1; // CRLF } } else if end > 0 && slice.byte(end - 1) == b'\r' { end -= 1; // lone CR } start + end } /// The last line holding content. /// /// A buffer ending in a newline reports a trailing empty line; the cursor /// should land on the last line with text on it. pub fn last_content_line(text: RopeSlice) -> usize { let lines = text.len_lines(LINE_TYPE); if lines == 0 { return 0; } let last = lines - 1; if last > 0 && text.line(last, LINE_TYPE).len() == 0 { last - 1 } else { last } } #[cfg(test)] mod tests { use ropey::Rope; use super::*; fn r(s: &str) -> Rope { Rope::from_str(s) } #[test] fn mjb_llr_060_categories() { assert_eq!(categorize_char('a'), CharCategory::Word); assert_eq!(categorize_char('_'), CharCategory::Word); assert_eq!(categorize_char('7'), CharCategory::Word); assert_eq!(categorize_char(' '), CharCategory::Whitespace); assert_eq!(categorize_char('\n'), CharCategory::Eol); assert_eq!(categorize_char('.'), CharCategory::Punctuation); } #[test] fn mjb_llr_061_word_boundary_is_category_change() { assert!(is_word_boundary('a', ' ')); assert!(is_word_boundary('a', '.')); assert!(!is_word_boundary('a', 'b')); } #[test] fn mjb_llr_062_move_char_left_stops_at_zero() { let t = r("abc"); let s = t.slice(..); assert_eq!(move_char_left(s, Range::point(0), 1), Range::point(0)); assert_eq!(move_char_left(s, Range::point(2), 1), Range::point(1)); } #[test] fn mjb_llr_063_move_char_right_stops_at_end() { let t = r("abc"); let s = t.slice(..); assert_eq!(move_char_right(s, Range::point(3), 1), Range::point(3)); assert_eq!(move_char_right(s, Range::point(0), 1), Range::point(1)); } #[test] fn mjb_llr_063_move_char_right_skips_whole_multibyte_char() { let t = r("文a"); let s = t.slice(..); assert_eq!(move_char_right(s, Range::point(0), 1), Range::point(3)); } #[test] fn mjb_llr_064_vertical_motion_preserves_column() { let t = r("abcdef\nghijkl\n"); let s = t.slice(..); let down = move_vertically(s, Range::point(3), 1, true); assert_eq!(down.cursor(s), 7 + 3, "same column on the next line"); let up = move_vertically(s, down, 1, false); assert_eq!(up.cursor(s), 3); } #[test] fn mjb_llr_064_vertical_motion_clamps_to_short_line() { let t = r("abcdef\nxy\n"); let s = t.slice(..); let down = move_vertically(s, Range::point(5), 1, true); // Line "xy" has no column 5; clamp to its end. assert_eq!(down.cursor(s), 7 + 2); } #[test] fn mjb_llr_064_vertical_motion_is_noop_at_edges() { let t = r("abc\ndef\n"); let s = t.slice(..); let up = move_vertically(s, Range::point(1), 1, false); assert_eq!(up, Range::point(1), "no-op on the first line"); } /// The behaviour that distinguishes Helix from Vim: `w` leaves a selection. #[test] fn mjb_llr_065_next_word_start_produces_a_selection() { let t = r("hello world"); let s = t.slice(..); let got = word_move(s, Range::point(0), 1, WordTarget::NextStart, false); assert!(!got.is_empty(), "w must leave a selection, not a point"); assert_eq!(got.anchor, 0, "anchor stays at the pre-motion cursor"); assert_eq!(got.head, 6, "head lands on the next word's first char"); } #[test] fn mjb_llr_067_next_word_end_spans_the_word() { let t = r("hello world"); let s = t.slice(..); let got = word_move(s, Range::point(0), 1, WordTarget::NextEnd, false); assert_eq!(got.anchor, 0); assert_eq!(got.head, 5, "inclusive of the word's last character"); } #[test] fn mjb_llr_066_prev_word_start_spans_backward() { let t = r("hello world"); let s = t.slice(..); let got = word_move(s, Range::point(6), 1, WordTarget::PrevStart, false); assert_eq!(got.anchor, 6, "anchor stays at the pre-motion cursor"); assert_eq!(got.head, 0); } #[test] fn mjb_llr_068_word_motion_stops_at_punctuation() { let t = r("foo.bar"); let s = t.slice(..); let got = word_move(s, Range::point(0), 1, WordTarget::NextStart, false); assert_eq!(got.head, 3, "punctuation is its own category"); } #[test] fn mjb_llr_068_long_word_motion_absorbs_punctuation() { let t = r("foo.bar baz"); let s = t.slice(..); let got = word_move(s, Range::point(0), 1, WordTarget::NextStart, true); assert_eq!(got.head, 8, "W treats foo.bar as one word"); } /// MJB-LLR-065 with a count. Regression guard: when each iteration /// re-derived its start from the partial range's *cursor* — one grapheme /// behind the head — `2w` stalled inside the first gap instead of reaching /// the second word. #[test] fn mjb_llr_065_counted_next_word_start_advances_once_per_count() { let t = r("aaa bbb ccc ddd"); let s = t.slice(..); let one = word_move(s, Range::point(0), 1, WordTarget::NextStart, false); assert_eq!((one.anchor, one.head), (0, 4)); let two = word_move(s, Range::point(0), 2, WordTarget::NextStart, false); assert_eq!( (two.anchor, two.head), (0, 8), "2w must reach the third word's start, not stall in a gap" ); let three = word_move(s, Range::point(0), 3, WordTarget::NextStart, false); assert_eq!((three.anchor, three.head), (0, 12)); } #[test] fn mjb_llr_066_counted_prev_word_start_advances_once_per_count() { let t = r("aaa bbb ccc"); let s = t.slice(..); let two = word_move(s, Range::point(10), 2, WordTarget::PrevStart, false); assert_eq!(two.anchor, 10, "anchor stays at the origin"); assert_eq!(two.head, 4, "two words back"); } #[test] fn mjb_llr_067_counted_next_word_end_advances_once_per_count() { let t = r("aaa bbb ccc"); let s = t.slice(..); let two = word_move(s, Range::point(0), 2, WordTarget::NextEnd, false); assert_eq!((two.anchor, two.head), (0, 7), "end of the second word"); } /// A count larger than the remaining words must saturate, not overshoot. #[test] fn mjb_llr_069_counted_motion_saturates_at_the_buffer_end() { let t = r("aaa bbb"); let s = t.slice(..); let got = word_move(s, Range::point(0), 99, WordTarget::NextStart, false); assert!(got.head <= s.len()); assert_eq!(got.anchor, 0); } #[test] fn mjb_llr_069_word_motion_is_noop_at_boundaries() { let t = r("abc"); let s = t.slice(..); let end = Range::point(3); assert_eq!(word_move(s, end, 1, WordTarget::NextStart, false), end); let start = Range::point(0); assert_eq!(word_move(s, start, 1, WordTarget::PrevStart, false), start); } #[test] fn mjb_llr_068_word_motion_crosses_line_endings() { let t = r("foo\nbar"); let s = t.slice(..); let got = word_move(s, Range::point(0), 1, WordTarget::NextStart, false); assert_eq!(got.head, 4, "newline is skipped as a separator"); } #[test] fn mjb_llr_070_goto_file_start() { let t = r("abc\ndef"); assert_eq!(goto_file_start(t.slice(..)), Range::point(0)); } #[test] fn mjb_llr_071_goto_last_line() { let t = r("abc\ndef\n"); let s = t.slice(..); // Trailing newline must not park the cursor on the phantom line. assert_eq!(goto_last_line(s).cursor(s), 4); } #[test] fn mjb_llr_071_goto_last_line_without_trailing_newline() { let t = r("abc\ndef"); let s = t.slice(..); assert_eq!(goto_last_line(s).cursor(s), 4); } #[test] fn mjb_llr_072_goto_line_start() { let t = r("abc\ndef\n"); let s = t.slice(..); assert_eq!(goto_line_start(s, Range::point(6)).cursor(s), 4); } #[test] fn mjb_llr_073_goto_line_end_excludes_terminator() { let t = r("abc\ndef\n"); let s = t.slice(..); assert_eq!(goto_line_end(s, Range::point(0)).cursor(s), 3); } #[test] fn mjb_llr_073_goto_line_end_handles_crlf() { let t = r("abc\r\ndef"); let s = t.slice(..); assert_eq!(goto_line_end(s, Range::point(0)).cursor(s), 3); } #[test] fn goto_first_non_whitespace_skips_indent() { let t = r(" indented\n"); let s = t.slice(..); assert_eq!(goto_first_non_whitespace(s, Range::point(0)).cursor(s), 4); } #[test] fn empty_buffer_motions_are_safe() { let t = r(""); let s = t.slice(..); assert_eq!(move_char_left(s, Range::point(0), 1), Range::point(0)); assert_eq!(move_char_right(s, Range::point(0), 1), Range::point(0)); assert_eq!(goto_line_end(s, Range::point(0)).cursor(s), 0); assert_eq!(goto_last_line(s).cursor(s), 0); let w = word_move(s, Range::point(0), 1, WordTarget::NextStart, false); assert_eq!(w, Range::point(0)); } }