aboutsummaryrefslogtreecommitdiff
path: root/src/buffer/movement.rs
diff options
context:
space:
mode:
authorrottedfm <rottedfm@proton.me>2026-08-19 11:24:55 -0400
committerrottedfm <rottedfm@proton.me>2026-08-19 11:24:55 -0400
commit8e16347b0eb329e84892af8ece36886324c95f62 (patch)
treebe1267972b5de2f1ae592577dfabce67f1fe6e87 /src/buffer/movement.rs
parentc6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff)
parentea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff)
Merge branch 'buffer-implementation'HEADmain
Establishes the first working baseline: moji <file> opens a file into a ropey rope and edits it with Helix selection-first modal editing, under a DO-178C DAL-C requirements and traceability process. Prior to this, main tracked four files and src/main.rs was still println!("Hello, world!") — there was no buildable state to build on. Verified on a fresh clone of the branch with no untracked files: cargo build; clippy --all-targets -D warnings clean; 294 tests passing; scripts/check-trace.sh reports 98/98 requirements traced in both directions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/buffer/movement.rs')
-rw-r--r--src/buffer/movement.rs592
1 files changed, 592 insertions, 0 deletions
diff --git a/src/buffer/movement.rs b/src/buffer/movement.rs
new file mode 100644
index 0000000..8dc0ff7
--- /dev/null
+++ b/src/buffer/movement.rs
@@ -0,0 +1,592 @@
+//! 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));
+ }
+}