diff options
| author | rottedfm <rottedfm@proton.me> | 2026-08-19 11:24:55 -0400 |
|---|---|---|
| committer | rottedfm <rottedfm@proton.me> | 2026-08-19 11:24:55 -0400 |
| commit | 8e16347b0eb329e84892af8ece36886324c95f62 (patch) | |
| tree | be1267972b5de2f1ae592577dfabce67f1fe6e87 /src/buffer/movement.rs | |
| parent | c6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff) | |
| parent | ea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff) | |
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.rs | 592 |
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)); + } +} |
