aboutsummaryrefslogtreecommitdiff
path: root/src/buffer
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
parentc6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff)
parentea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff)
Merge branch 'buffer-implementation'
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')
-rw-r--r--src/buffer/command.rs147
-rw-r--r--src/buffer/document.rs525
-rw-r--r--src/buffer/encoding.rs272
-rw-r--r--src/buffer/grapheme.rs325
-rw-r--r--src/buffer/history.rs228
-rw-r--r--src/buffer/keymap.rs392
-rw-r--r--src/buffer/line_ending.rs144
-rw-r--r--src/buffer/mod.rs615
-rw-r--r--src/buffer/movement.rs592
-rw-r--r--src/buffer/save.rs387
-rw-r--r--src/buffer/selection.rs344
-rw-r--r--src/buffer/transaction.rs424
-rw-r--r--src/buffer/view.rs371
13 files changed, 4766 insertions, 0 deletions
diff --git a/src/buffer/command.rs b/src/buffer/command.rs
new file mode 100644
index 0000000..c5a2aef
--- /dev/null
+++ b/src/buffer/command.rs
@@ -0,0 +1,147 @@
+//! Editor commands — the values key bindings map to.
+//!
+//! Kept distinct from [`crate::action::Action`], which is application-level
+//! (tick, render, resize). A binding names a `Command`; the buffer executes it.
+//! `Quit` and `Suspend` appear here because the `Global` keymap is expressed in
+//! the same table and must be able to name them.
+
+use serde::{Deserialize, Serialize};
+use strum::Display;
+
+/// MJB-LLR-157: one unit variant per bound editor command, deserialized from
+/// the variant name so a TOML value like `"MoveCharLeft"` resolves directly.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display, Serialize, Deserialize)]
+pub enum Command {
+ // --- Application (reachable from the `global` keymap) ---
+ Quit,
+ Suspend,
+
+ // --- Mode switching ---
+ NormalMode,
+ InsertMode,
+ SelectMode,
+ CommandMode,
+
+ // --- Character and line motion (MJB-HLR-006) ---
+ MoveCharLeft,
+ MoveCharRight,
+ MoveLineUp,
+ MoveLineDown,
+
+ // --- Word motion; these produce selections (MJB-HLR-007) ---
+ MoveNextWordStart,
+ MovePrevWordStart,
+ MoveNextWordEnd,
+ MoveNextLongWordStart,
+ MovePrevLongWordStart,
+ MoveNextLongWordEnd,
+
+ // --- Extending variants, used by select mode ---
+ ExtendCharLeft,
+ ExtendCharRight,
+ ExtendLineUp,
+ ExtendLineDown,
+ ExtendNextWordStart,
+ ExtendPrevWordStart,
+ ExtendNextWordEnd,
+
+ // --- Goto (MJB-HLR-008) ---
+ GotoFileStart,
+ GotoLastLine,
+ GotoLineStart,
+ GotoLineEnd,
+ GotoFirstNonWhitespace,
+
+ // --- Selection manipulation ---
+ ExtendLineBelow,
+ CollapseSelection,
+ FlipSelections,
+ SelectAll,
+
+ // --- Entering insert mode (MJB-HLR-009) ---
+ AppendMode,
+ InsertAtLineStart,
+ InsertAtLineEnd,
+ OpenBelow,
+ OpenAbove,
+
+ // --- Modification (MJB-HLR-010) ---
+ DeleteSelection,
+ ChangeSelection,
+ InsertNewline,
+ InsertTab,
+ DeleteCharBackward,
+ DeleteCharForward,
+ DeleteWordBackward,
+ KillToLineStart,
+
+ // --- Undo / redo (MJB-HLR-011) ---
+ Undo,
+ Redo,
+
+ // --- Scrolling and paging (MJB-HLR-013) ---
+ PageCursorHalfUp,
+ PageCursorHalfDown,
+ PageUp,
+ PageDown,
+
+ // --- Command line (MJB-HLR-016) ---
+ CommandSubmit,
+ CommandBackspace,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// MJB-LLR-157: a TOML value naming a variant deserializes to it, which is
+ /// what makes the keymap config-driven.
+ #[test]
+ fn mjb_llr_157_deserializes_from_the_variant_name() {
+ let cmd: Command = serde_json_free_parse("MoveCharLeft");
+ assert_eq!(cmd, Command::MoveCharLeft);
+ assert_eq!(serde_json_free_parse("Undo"), Command::Undo);
+ assert_eq!(
+ serde_json_free_parse("PageCursorHalfDown"),
+ Command::PageCursorHalfDown
+ );
+ }
+
+ #[test]
+ fn mjb_llr_157_unknown_command_name_is_an_error_not_a_panic() {
+ let err = toml::from_str::<Wrapper>("cmd = \"NoSuchCommand\"").unwrap_err();
+ assert!(
+ err.to_string().contains("NoSuchCommand"),
+ "error must name the offending value, got: {err}"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_157_display_round_trips_through_deserialization() {
+ for cmd in [
+ Command::Quit,
+ Command::GotoFileStart,
+ Command::DeleteSelection,
+ Command::CommandSubmit,
+ ] {
+ assert_eq!(
+ serde_json_free_parse(&cmd.to_string()),
+ cmd,
+ "{cmd} must round-trip"
+ );
+ }
+ }
+
+ #[derive(Debug, serde::Deserialize)]
+ struct Wrapper {
+ cmd: Command,
+ }
+
+ /// Parse a bare command name the way the keymap table does.
+ fn serde_json_free_parse(name: &str) -> Command {
+ let doc = format!("cmd = \"{name}\"");
+ toml::from_str::<Wrapper>(&doc)
+ .unwrap_or_else(|e| panic!("{name} must parse: {e}"))
+ .cmd
+ }
+}
diff --git a/src/buffer/document.rs b/src/buffer/document.rs
new file mode 100644
index 0000000..13c02ba
--- /dev/null
+++ b/src/buffer/document.rs
@@ -0,0 +1,525 @@
+//! The document: rope, associated path, encoding state, selection, history.
+//!
+//! **This is the only module that touches `ropey` types directly** as an owner.
+//! Confining the dependency here is the mitigation recorded in MJB-DR-006 for
+//! depending on a pre-release rope crate under DAL-C.
+
+use std::path::{Path, PathBuf};
+
+use ropey::{Rope, RopeSlice};
+
+use super::{
+ LINE_TYPE,
+ encoding::{self, DecodeError, EncodingInfo},
+ history::History,
+ line_ending::{self, LineEnding},
+ save::{self, SaveError},
+ selection::{Range, Selection},
+ transaction::{ChangeError, Transaction},
+};
+
+#[derive(Debug, thiserror::Error)]
+pub enum DocumentError {
+ #[error("{0}")]
+ Change(#[from] ChangeError),
+ #[error("{0}")]
+ Save(#[from] SaveError),
+ // MJB-LLR-118: opening a file mojibake cannot decode is a reportable
+ // failure, not something to paper over.
+ #[error("{0}")]
+ Decode(#[from] DecodeError),
+ #[error("io error: {0}")]
+ Io(#[from] std::io::Error),
+}
+
+#[derive(Debug)]
+pub struct Document {
+ text: Rope,
+ path: Option<PathBuf>,
+ encoding: EncodingInfo,
+ line_ending: LineEnding,
+ selection: Selection,
+ history: History,
+ /// The history revision the file on disk corresponds to.
+ ///
+ /// `modified` is derived from this rather than latched to a bool, so
+ /// undoing back to the last-saved state correctly reports the buffer as
+ /// clean. A latched flag would keep claiming unsaved changes for a buffer
+ /// byte-identical to its file. (MJB-LLR-116)
+ saved_revision: usize,
+}
+
+impl Default for Document {
+ fn default() -> Self {
+ Self::empty(None)
+ }
+}
+
+impl Document {
+ pub fn empty(path: Option<PathBuf>) -> Self {
+ Self {
+ text: Rope::new(),
+ path,
+ encoding: EncodingInfo::default(),
+ line_ending: LineEnding::platform_default(),
+ selection: Selection::point(0),
+ history: History::new(),
+ saved_revision: 0,
+ }
+ }
+
+ /// MJB-LLR-111, MJB-LLR-112: load `path`.
+ ///
+ /// A path that does not exist yields an empty buffer that remembers it, so
+ /// `:w` creates the file. Any other IO error is reported.
+ pub fn open(path: &Path) -> Result<Self, DocumentError> {
+ let bytes = match std::fs::read(path) {
+ Ok(b) => b,
+ // MJB-LLR-112
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
+ return Ok(Self::empty(Some(path.to_path_buf())));
+ }
+ Err(e) => return Err(DocumentError::Io(e)),
+ };
+
+ // MJB-LLR-113, MJB-LLR-118: a declared encoding is transcoded; invalid
+ // UTF-8 is rejected here rather than opened and later written back
+ // with the damage baked in.
+ let (text, encoding) = encoding::decode(&bytes)?;
+ // MJB-LLR-114 is evaluated on the raw text, before normalisation
+ // collapses CRLF and would erase the evidence.
+ let line_ending = LineEnding::detect(&text);
+ let normalized = line_ending::normalize(&text);
+
+ Ok(Self {
+ text: Rope::from_str(&normalized),
+ path: Some(path.to_path_buf()),
+ encoding,
+ line_ending,
+ selection: Selection::point(0),
+ history: History::new(),
+ saved_revision: 0,
+ })
+ }
+
+ pub fn text(&self) -> &Rope {
+ &self.text
+ }
+
+ pub fn slice(&self) -> RopeSlice<'_> {
+ self.text.slice(..)
+ }
+
+ pub fn path(&self) -> Option<&Path> {
+ self.path.as_deref()
+ }
+
+ pub fn set_path(&mut self, path: PathBuf) {
+ self.path = Some(path);
+ }
+
+ pub fn selection(&self) -> &Selection {
+ &self.selection
+ }
+
+ pub fn set_selection(&mut self, selection: Selection) {
+ self.selection = selection.clamped(self.text.slice(..));
+ }
+
+ /// Convenience: replace the primary range.
+ pub fn set_range(&mut self, range: Range) {
+ let clamped = range.clamped(self.text.slice(..));
+ self.selection.set_primary(clamped);
+ }
+
+ pub fn range(&self) -> Range {
+ self.selection.primary()
+ }
+
+ /// MJB-LLR-116: whether the buffer differs from the file on disk.
+ ///
+ /// Derived by comparing the current history revision against the one the
+ /// file was written at, so undoing back to the saved state reports clean.
+ pub fn is_modified(&self) -> bool {
+ self.history.revision() != self.saved_revision
+ }
+
+ pub fn line_ending(&self) -> LineEnding {
+ self.line_ending
+ }
+
+ pub fn len_lines(&self) -> usize {
+ self.text.len_lines(LINE_TYPE)
+ }
+
+ pub fn history_mut(&mut self) -> &mut History {
+ &mut self.history
+ }
+
+ /// MJB-LLR-117: apply `transaction`, recording its inverse for undo.
+ pub fn apply(&mut self, transaction: &Transaction) -> Result<(), DocumentError> {
+ // The inverse must be computed against the pre-change rope.
+ let inverse = Transaction::new(transaction.changes.invert(&self.text));
+
+ transaction.changes.apply(&mut self.text)?;
+
+ if let Some(sel) = &transaction.selection {
+ self.selection = sel.clone().clamped(self.text.slice(..));
+ } else {
+ self.selection = self.selection.clone().clamped(self.text.slice(..));
+ }
+
+ self.history.commit(transaction.clone(), inverse);
+ Ok(())
+ }
+
+ /// Apply without recording history — used to replay an undo or redo, whose
+ /// own bookkeeping is already handled by [`History`].
+ fn apply_without_history(&mut self, transaction: &Transaction) -> Result<(), DocumentError> {
+ transaction.changes.apply(&mut self.text)?;
+ if let Some(sel) = &transaction.selection {
+ self.selection = sel.clone().clamped(self.text.slice(..));
+ } else {
+ self.selection = self.selection.clone().clamped(self.text.slice(..));
+ }
+ Ok(())
+ }
+
+ /// MJB-LLR-052: revert the most recent change. `false` if there was none.
+ pub fn undo(&mut self) -> Result<bool, DocumentError> {
+ let Some(t) = self.history.undo().cloned() else {
+ return Ok(false);
+ };
+ self.apply_without_history(&t)?;
+ Ok(true)
+ }
+
+ /// MJB-LLR-053: reapply the most recently reverted change.
+ pub fn redo(&mut self) -> Result<bool, DocumentError> {
+ let Some(t) = self.history.redo().cloned() else {
+ return Ok(false);
+ };
+ self.apply_without_history(&t)?;
+ Ok(true)
+ }
+
+ /// MJB-LLR-115: the document as it should appear on disk.
+ ///
+ /// Assembles the text once. Chaining `to_string` → `with_final_newline` →
+ /// `apply` → `encode` would copy the whole document at each step; the
+ /// terminator rewrite and the final newline are folded into a single pass
+ /// so only the encode step copies, and only when the encoding is not the
+ /// UTF-8 the rope already holds.
+ pub fn encode(&self, insert_final_newline: bool) -> Vec<u8> {
+ let ending = self.line_ending.as_str();
+ let needs_rewrite = self.line_ending != LineEnding::Lf;
+
+ let mut text = String::with_capacity(self.text.len() + 1);
+ for chunk in self.text.chunks() {
+ if needs_rewrite {
+ // The rope stores LF only, so a plain split is sufficient.
+ let mut parts = chunk.split('\n');
+ if let Some(first) = parts.next() {
+ text.push_str(first);
+ }
+ for part in parts {
+ text.push_str(ending);
+ text.push_str(part);
+ }
+ } else {
+ text.push_str(chunk);
+ }
+ }
+
+ if insert_final_newline && !text.is_empty() && !text.ends_with(ending) {
+ text.push_str(ending);
+ }
+
+ encoding::encode(&text, self.encoding)
+ }
+
+ /// MJB-LLR-137: write to the associated path and mark it clean.
+ pub fn save(&mut self, force: bool, insert_final_newline: bool) -> Result<(), DocumentError> {
+ let path = self.path.clone().ok_or(SaveError::NoPath)?;
+ let bytes = self.encode(insert_final_newline);
+ save::write_atomic(&path, &bytes, force)?;
+ self.saved_revision = self.history.revision();
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::buffer::selection::Range;
+
+ fn doc(text: &str) -> Document {
+ let mut d = Document::empty(None);
+ d.text = Rope::from_str(text);
+ d
+ }
+
+ #[test]
+ fn mjb_llr_112_missing_file_yields_empty_buffer_that_remembers_the_path() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("not-created-yet.txt");
+ let doc = Document::open(&p).unwrap();
+ assert_eq!(doc.text().len(), 0);
+ assert_eq!(doc.path(), Some(p.as_path()));
+ assert!(!doc.is_modified());
+ }
+
+ #[test]
+ fn mjb_llr_111_loads_contents() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("f.txt");
+ std::fs::write(&p, "hello\nworld\n").unwrap();
+ let doc = Document::open(&p).unwrap();
+ assert_eq!(doc.text().to_string(), "hello\nworld\n");
+ }
+
+ #[test]
+ fn mjb_llr_114_crlf_is_detected_and_normalized_for_storage() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("crlf.txt");
+ std::fs::write(&p, "a\r\nb\r\n").unwrap();
+
+ let doc = Document::open(&p).unwrap();
+ assert_eq!(doc.line_ending(), LineEnding::Crlf);
+ assert_eq!(doc.text().to_string(), "a\nb\n", "stored as LF");
+ }
+
+ /// MJB-LLR-115, MJB-HLR-003: a CRLF file saved back is still CRLF.
+ #[test]
+ fn mjb_llr_115_crlf_round_trips_through_save() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("crlf.txt");
+ std::fs::write(&p, "a\r\nb\r\n").unwrap();
+
+ let mut doc = Document::open(&p).unwrap();
+ doc.save(false, true).unwrap();
+ assert_eq!(std::fs::read(&p).unwrap(), b"a\r\nb\r\n");
+ }
+
+ /// MJB-LLR-118: a file that is not valid UTF-8 is refused, and the file on
+ /// disk is left exactly as it was.
+ #[test]
+ fn mjb_llr_118_invalid_utf8_file_is_refused() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("bad.bin");
+ let original = [b'a', 0xFF, b'b'];
+ std::fs::write(&p, original).unwrap();
+
+ let err = Document::open(&p).expect_err("must refuse to open");
+ assert!(matches!(err, DocumentError::Decode(_)), "got {err:?}");
+ assert!(
+ err.to_string().contains("UTF-8"),
+ "message must explain why, got: {err}"
+ );
+ assert_eq!(
+ std::fs::read(&p).unwrap(),
+ original,
+ "a refused open must not touch the file"
+ );
+ }
+
+ /// MJB-LLR-113: a BOM-declared non-UTF-8 file still opens.
+ #[test]
+ fn mjb_llr_113_declared_utf16_file_opens() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("u16.txt");
+ // UTF-16LE BOM + "hi".
+ std::fs::write(&p, [0xFF, 0xFE, b'h', 0x00, b'i', 0x00]).unwrap();
+
+ let doc = Document::open(&p).expect("a declared encoding must open");
+ assert_eq!(doc.text().to_string(), "hi");
+ }
+
+ #[test]
+ fn mjb_llr_116_modified_flag_lifecycle() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("f.txt");
+ std::fs::write(&p, "abc").unwrap();
+
+ let mut doc = Document::open(&p).unwrap();
+ assert!(!doc.is_modified(), "freshly opened");
+
+ let t = Transaction::insert(doc.text(), doc.selection(), "X");
+ doc.apply(&t).unwrap();
+ assert!(doc.is_modified(), "set by an applied transaction");
+
+ doc.save(false, false).unwrap();
+ assert!(!doc.is_modified(), "cleared by a successful write");
+ }
+
+ /// MJB-LLR-137: a successful write clears `modified`; a failed one must
+ /// not, or the user would be told their unsaved work is safe.
+ #[test]
+ fn mjb_llr_137_failed_write_leaves_modified_set() {
+ let mut doc = doc("content");
+ // No path: the save cannot succeed.
+ let t = Transaction::insert(doc.text(), doc.selection(), "X");
+ doc.apply(&t).unwrap();
+ assert!(doc.is_modified());
+
+ assert!(doc.save(false, true).is_err());
+ assert!(
+ doc.is_modified(),
+ "a failed write must not clear the modified flag"
+ );
+ }
+
+ /// MJB-LLR-116: undoing back to the saved state reports the buffer clean.
+ /// A latched flag would keep claiming unsaved changes for content that is
+ /// byte-identical to the file.
+ #[test]
+ fn mjb_llr_116_undo_back_to_saved_state_is_clean() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("f.txt");
+ std::fs::write(&p, "abc").unwrap();
+ let mut doc = Document::open(&p).unwrap();
+
+ doc.save(false, false).unwrap();
+ assert!(!doc.is_modified());
+
+ let t = Transaction::insert(doc.text(), doc.selection(), "X");
+ doc.apply(&t).unwrap();
+ assert!(doc.is_modified(), "an edit dirties the buffer");
+
+ doc.undo().unwrap();
+ assert!(
+ !doc.is_modified(),
+ "undoing back to the saved content must report clean"
+ );
+
+ doc.redo().unwrap();
+ assert!(doc.is_modified(), "redoing dirties it again");
+ }
+
+ /// MJB-LLR-116: same history depth, different content, must stay dirty.
+ #[test]
+ fn mjb_llr_116_divergent_edit_at_the_same_depth_stays_modified() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("f.txt");
+ std::fs::write(&p, "abc").unwrap();
+ let mut doc = Document::open(&p).unwrap();
+
+ let t = Transaction::insert(doc.text(), doc.selection(), "X");
+ doc.apply(&t).unwrap();
+ doc.save(false, false).unwrap();
+ assert!(!doc.is_modified());
+
+ doc.undo().unwrap();
+ // A *different* edit, returning to the same history depth.
+ let t = Transaction::insert(doc.text(), doc.selection(), "Y");
+ doc.apply(&t).unwrap();
+
+ assert!(
+ doc.is_modified(),
+ "content differs from the file despite equal history depth"
+ );
+ assert_eq!(doc.text().to_string(), "Yabc");
+ }
+
+ #[test]
+ fn mjb_llr_137_successful_write_clears_modified() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("f.txt");
+ let mut doc = Document::open(&p).unwrap();
+
+ let t = Transaction::insert(doc.text(), doc.selection(), "hello");
+ doc.apply(&t).unwrap();
+ assert!(doc.is_modified());
+
+ doc.save(false, true).unwrap();
+ assert!(!doc.is_modified());
+ assert_eq!(std::fs::read_to_string(&p).unwrap(), "hello\n");
+ }
+
+ #[test]
+ fn mjb_llr_117_apply_updates_text_and_records_history() {
+ let mut doc = doc("hello");
+ let t = Transaction::change(doc.text(), [(0, 5, Some("goodbye".into()))]);
+ doc.apply(&t).unwrap();
+ assert_eq!(doc.text().to_string(), "goodbye");
+ assert!(doc.history.can_undo());
+ }
+
+ #[test]
+ fn mjb_llr_052_undo_restores_previous_text() {
+ let mut doc = doc("hello");
+ let t = Transaction::change(doc.text(), [(0, 5, Some("goodbye".into()))]);
+ doc.apply(&t).unwrap();
+
+ assert!(doc.undo().unwrap());
+ assert_eq!(doc.text().to_string(), "hello");
+ }
+
+ #[test]
+ fn mjb_llr_053_redo_reapplies() {
+ let mut doc = doc("hello");
+ let t = Transaction::change(doc.text(), [(0, 5, Some("goodbye".into()))]);
+ doc.apply(&t).unwrap();
+ doc.undo().unwrap();
+
+ assert!(doc.redo().unwrap());
+ assert_eq!(doc.text().to_string(), "goodbye");
+ }
+
+ /// MJB-LLR-052: undoing past the start is a no-op, not an error.
+ #[test]
+ fn mjb_llr_052_undo_past_history_start_is_a_noop() {
+ let mut doc = doc("hello");
+ assert!(!doc.undo().unwrap(), "nothing to undo");
+ assert_eq!(doc.text().to_string(), "hello");
+ assert!(!doc.undo().unwrap(), "still nothing, still safe");
+ }
+
+ #[test]
+ fn mjb_llr_053_redo_past_end_is_a_noop() {
+ let mut doc = doc("hello");
+ assert!(!doc.redo().unwrap());
+ assert_eq!(doc.text().to_string(), "hello");
+ }
+
+ #[test]
+ fn multiple_undo_redo_cycles_are_stable() {
+ let mut doc = doc("");
+ for c in ["a", "b", "c"] {
+ let t = Transaction::insert(doc.text(), doc.selection(), c);
+ doc.apply(&t).unwrap();
+ let end = doc.text().len();
+ doc.set_range(Range::point(end));
+ }
+ assert_eq!(doc.text().to_string(), "abc");
+
+ for _ in 0..3 {
+ assert!(doc.undo().unwrap());
+ }
+ assert_eq!(doc.text().to_string(), "");
+
+ for _ in 0..3 {
+ assert!(doc.redo().unwrap());
+ }
+ assert_eq!(doc.text().to_string(), "abc");
+ }
+
+ #[test]
+ fn mjb_llr_185_final_newline_added_on_encode_when_requested() {
+ let doc = doc("no trailing newline");
+ assert!(doc.encode(true).ends_with(b"\n"));
+ assert!(!doc.encode(false).ends_with(b"\n"));
+ }
+
+ #[test]
+ fn mjb_llr_185_empty_document_encodes_empty() {
+ let doc = doc("");
+ assert!(doc.encode(true).is_empty(), "must not invent a newline");
+ }
+
+ #[test]
+ fn saving_without_a_path_is_an_error_not_a_panic() {
+ let mut doc = doc("x");
+ assert!(doc.save(false, true).is_err());
+ }
+}
diff --git a/src/buffer/encoding.rs b/src/buffer/encoding.rs
new file mode 100644
index 0000000..5cd852f
--- /dev/null
+++ b/src/buffer/encoding.rs
@@ -0,0 +1,272 @@
+//! Character encoding detection and transcoding (MJB-HLR-002).
+//!
+//! Two rules, the second an exception to the first:
+//!
+//! 1. **A file whose encoding a byte order mark declares is transcoded.** The
+//! encoding is known, so its bytes round-trip through load and save.
+//! 2. **UTF-8 is strict.** A file assumed or declared to be UTF-8 that holds an
+//! invalid byte sequence is *rejected*, not repaired.
+//!
+//! Rule 2 exists because substitution is lossy in a way the user cannot see:
+//! replacing a bad byte with U+FFFD and then saving writes the replacement
+//! character over their data. For a declared encoding we can at least reproduce
+//! what we read; for malformed UTF-8 we cannot, so refusing to open is the only
+//! non-destructive answer. See MJB-DR-001.
+
+use encoding_rs::{Encoding, UTF_8, UTF_16BE, UTF_16LE};
+
+/// MJB-LLR-118: why a file could not be decoded.
+#[derive(Debug, thiserror::Error, PartialEq, Eq)]
+pub enum DecodeError {
+ #[error(
+ "not valid UTF-8 (invalid byte sequence at offset {valid_up_to}); \
+ mojibake edits text, and repairing the bytes would destroy them on save"
+ )]
+ InvalidUtf8 { valid_up_to: usize },
+}
+
+/// The encoding a document was loaded with, plus whether it carried a BOM.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct EncodingInfo {
+ pub encoding: &'static Encoding,
+ pub has_bom: bool,
+}
+
+impl Default for EncodingInfo {
+ fn default() -> Self {
+ Self {
+ encoding: UTF_8,
+ has_bom: false,
+ }
+ }
+}
+
+/// MJB-LLR-110: recognise a byte order mark, returning the encoding it implies
+/// and the mark's length in bytes.
+pub fn detect_bom(bytes: &[u8]) -> Option<(&'static Encoding, usize)> {
+ if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
+ Some((UTF_8, 3))
+ } else if bytes.starts_with(&[0xFF, 0xFE]) {
+ Some((UTF_16LE, 2))
+ } else if bytes.starts_with(&[0xFE, 0xFF]) {
+ Some((UTF_16BE, 2))
+ } else {
+ None
+ }
+}
+
+/// The byte order mark for `encoding`, if it has one.
+pub fn bom_bytes(encoding: &'static Encoding) -> &'static [u8] {
+ if encoding == UTF_8 {
+ &[0xEF, 0xBB, 0xBF]
+ } else if encoding == UTF_16LE {
+ &[0xFF, 0xFE]
+ } else if encoding == UTF_16BE {
+ &[0xFE, 0xFF]
+ } else {
+ &[]
+ }
+}
+
+/// MJB-LLR-111, MJB-LLR-113, MJB-LLR-118: decode `bytes` to a `String`.
+///
+/// UTF-8 — whether declared by a BOM or merely assumed — is validated strictly
+/// and rejected when malformed. Other BOM-declared encodings are transcoded.
+pub fn decode(bytes: &[u8]) -> Result<(String, EncodingInfo), DecodeError> {
+ let (encoding, has_bom, body) = match detect_bom(bytes) {
+ Some((encoding, bom_len)) => (encoding, true, &bytes[bom_len..]),
+ None => (UTF_8, false, bytes),
+ };
+
+ let text = if encoding == UTF_8 {
+ // MJB-LLR-118: the exception. `from_utf8` reports exactly how far the
+ // input was valid, which makes the diagnostic actionable.
+ std::str::from_utf8(body)
+ .map_err(|e| DecodeError::InvalidUtf8 {
+ valid_up_to: e.valid_up_to(),
+ })?
+ .to_owned()
+ } else {
+ // MJB-LLR-113: a declared non-UTF-8 encoding is transcoded. Its bytes
+ // round-trip on save, so any substitution here is reproducible.
+ encoding.decode_without_bom_handling(body).0.into_owned()
+ };
+
+ Ok((text, EncodingInfo { encoding, has_bom }))
+}
+
+/// MJB-LLR-115: encode `text` back to bytes, re-emitting the BOM when the
+/// document was loaded with one.
+///
+/// Total by construction: `text` is a `str`, and a document can only hold text
+/// that [`decode`] accepted, so there is nothing here that can fail.
+///
+/// UTF-16 is encoded here by hand rather than through `encoding_rs`.
+/// `Encoding::encode` is deliberately asymmetric: it decodes UTF-16 but will
+/// not *encode* to it, silently substituting UTF-8 instead. Delegating would
+/// therefore write UTF-8 bytes beneath a UTF-16 BOM and corrupt the file.
+/// See MJB-DR-007.
+pub fn encode(text: &str, info: EncodingInfo) -> Vec<u8> {
+ let mut out = Vec::with_capacity(text.len() + 3);
+ if info.has_bom {
+ out.extend_from_slice(bom_bytes(info.encoding));
+ }
+
+ if info.encoding == UTF_16LE {
+ for unit in text.encode_utf16() {
+ out.extend_from_slice(&unit.to_le_bytes());
+ }
+ } else if info.encoding == UTF_16BE {
+ for unit in text.encode_utf16() {
+ out.extend_from_slice(&unit.to_be_bytes());
+ }
+ } else {
+ let (bytes, _, _) = info.encoding.encode(text);
+ out.extend_from_slice(&bytes);
+ }
+
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn mjb_llr_110_detects_each_bom() {
+ assert_eq!(detect_bom(&[0xEF, 0xBB, 0xBF, b'a']), Some((UTF_8, 3)));
+ assert_eq!(detect_bom(&[0xFF, 0xFE, b'a', 0]), Some((UTF_16LE, 2)));
+ assert_eq!(detect_bom(&[0xFE, 0xFF, 0, b'a']), Some((UTF_16BE, 2)));
+ assert_eq!(detect_bom(b"plain"), None);
+ assert_eq!(detect_bom(b""), None, "empty input must not index past end");
+ }
+
+ #[test]
+ fn mjb_llr_111_plain_utf8_round_trips() {
+ let (text, info) = decode("hello 文字化け".as_bytes()).unwrap();
+ assert_eq!(text, "hello 文字化け");
+ assert!(!info.has_bom);
+ assert_eq!(encode(&text, info), "hello 文字化け".as_bytes());
+ }
+
+ /// MJB-LLR-115, MJB-DR-001: a BOM present on load reappears on save.
+ #[test]
+ fn mjb_llr_115_bom_round_trips() {
+ let mut input = vec![0xEF, 0xBB, 0xBF];
+ input.extend_from_slice(b"hi");
+
+ let (text, info) = decode(&input).unwrap();
+ assert_eq!(text, "hi", "BOM is stripped from the buffer contents");
+ assert!(info.has_bom);
+ assert_eq!(encode(&text, info), input, "and re-emitted on write");
+ }
+
+ /// MJB-LLR-115, MJB-DR-007: UTF-16 must survive a load/save cycle.
+ ///
+ /// Regression guard: `encoding_rs::Encoding::encode` substitutes UTF-8 for
+ /// UTF-16 rather than failing, so delegating to it here would write UTF-8
+ /// bytes under a UTF-16 BOM and corrupt the file.
+ #[test]
+ fn mjb_llr_115_utf16le_round_trips() {
+ // UTF-16LE BOM followed by "hi".
+ let input = vec![0xFF, 0xFE, b'h', 0x00, b'i', 0x00];
+ let (text, info) = decode(&input).unwrap();
+ assert_eq!(text, "hi");
+ assert_eq!(info.encoding, UTF_16LE);
+ assert_eq!(encode(&text, info), input, "must not degrade to UTF-8");
+ }
+
+ #[test]
+ fn mjb_llr_115_utf16be_round_trips() {
+ let input = vec![0xFE, 0xFF, 0x00, b'h', 0x00, b'i'];
+ let (text, info) = decode(&input).unwrap();
+ assert_eq!(text, "hi");
+ assert_eq!(info.encoding, UTF_16BE);
+ assert_eq!(encode(&text, info), input);
+ }
+
+ #[test]
+ fn mjb_llr_115_utf16_handles_non_ascii_and_surrogates() {
+ // 文 is BMP; 𝄞 (U+1D11E) needs a surrogate pair in UTF-16.
+ let original = "文𝄞";
+ let info = EncodingInfo {
+ encoding: UTF_16LE,
+ has_bom: true,
+ };
+ let bytes = encode(original, info);
+ let (back, _) = decode(&bytes).unwrap();
+ assert_eq!(back, original);
+ }
+
+ /// MJB-LLR-113: a *declared* non-UTF-8 encoding is transcoded, not
+ /// rejected. An unpaired surrogate is repaired, and that repair is
+ /// reproducible because the encoding is known.
+ #[test]
+ fn mjb_llr_113_declared_utf16_is_transcoded_not_rejected() {
+ // UTF-16LE BOM, then a lone high surrogate (0xD800) — not valid UTF-16.
+ let input = vec![0xFF, 0xFE, 0x00, 0xD8, b'a', 0x00];
+ let (text, info) = decode(&input).expect("a declared encoding must not be rejected");
+ assert_eq!(info.encoding, UTF_16LE);
+ assert!(
+ text.contains('\u{FFFD}'),
+ "the unpaired surrogate becomes U+FFFD, got {text:?}"
+ );
+ }
+
+ /// MJB-LLR-118: the UTF-8 exception. Invalid UTF-8 is rejected outright
+ /// rather than repaired, because a repair would be written back over the
+ /// user's data on save.
+ #[test]
+ fn mjb_llr_118_invalid_utf8_is_rejected() {
+ // 0xFF is never valid in UTF-8.
+ let err = decode(&[b'a', 0xFF, b'b']).unwrap_err();
+ assert_eq!(err, DecodeError::InvalidUtf8 { valid_up_to: 1 });
+ }
+
+ #[test]
+ fn mjb_llr_118_rejection_names_the_offset() {
+ let err = decode(&[b'h', b'i', 0x80]).unwrap_err();
+ assert_eq!(err, DecodeError::InvalidUtf8 { valid_up_to: 2 });
+ assert!(
+ err.to_string().contains('2'),
+ "the message must locate the bad byte, got: {err}"
+ );
+ }
+
+ /// A truncated multi-byte character is invalid UTF-8 too.
+ #[test]
+ fn mjb_llr_118_truncated_multibyte_char_is_rejected() {
+ // 文 is E6 96 87; drop the last byte.
+ let err = decode(&[0xE6, 0x96]).unwrap_err();
+ assert_eq!(err, DecodeError::InvalidUtf8 { valid_up_to: 0 });
+ }
+
+ /// MJB-LLR-118: a UTF-8 *BOM* does not license invalid bytes after it.
+ #[test]
+ fn mjb_llr_118_declared_utf8_is_strict_too() {
+ let input = vec![0xEF, 0xBB, 0xBF, b'a', 0xFF];
+ let err = decode(&input).unwrap_err();
+ assert_eq!(
+ err,
+ DecodeError::InvalidUtf8 { valid_up_to: 1 },
+ "offset is measured past the BOM"
+ );
+ }
+
+ /// Valid multi-byte UTF-8 must not be mistaken for invalid.
+ #[test]
+ fn mjb_llr_118_valid_multibyte_utf8_is_accepted() {
+ for s in ["文字化け", "e\u{0301}", "𝄞", "café", "", "\u{FFFD}"] {
+ let (text, _) = decode(s.as_bytes())
+ .unwrap_or_else(|e| panic!("{s:?} must decode, got {e}"));
+ assert_eq!(text, s);
+ }
+ }
+
+ #[test]
+ fn mjb_llr_113_empty_input_decodes_to_empty() {
+ let (text, info) = decode(b"").unwrap();
+ assert_eq!(text, "");
+ assert!(!info.has_bom);
+ }
+}
diff --git a/src/buffer/grapheme.rs b/src/buffer/grapheme.rs
new file mode 100644
index 0000000..8670853
--- /dev/null
+++ b/src/buffer/grapheme.rs
@@ -0,0 +1,325 @@
+//! Grapheme cluster boundaries and display width over a rope.
+//!
+//! Under byte indexing an offset can land inside a character or inside a
+//! grapheme cluster, so every cursor position the user can observe is snapped
+//! to a grapheme boundary here. See MJB-DR-002.
+//!
+//! `unicode_segmentation::GraphemeCursor` works over `&str` fragments and asks
+//! for more context when a cluster straddles a fragment edge; ropey's
+//! `chunk(byte_idx) -> (&str, chunk_start)` supplies exactly that, so clusters
+//! spanning chunk boundaries resolve correctly (MJB-LLR-022).
+
+use std::borrow::Cow;
+
+use ropey::RopeSlice;
+use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete};
+use unicode_width::UnicodeWidthStr;
+
+/// Columns a tab advances to. Fixed rather than configurable; a configurable
+/// tab stop would be a new requirement, not a derived one.
+pub const TAB_WIDTH: usize = 4;
+
+/// MJB-LLR-020: byte offset of the grapheme boundary preceding `byte_idx`,
+/// or 0 when there is none.
+pub fn prev_grapheme_boundary(slice: RopeSlice, byte_idx: usize) -> usize {
+ let len = slice.len();
+ let byte_idx = slice.floor_char_boundary(byte_idx.min(len));
+ if byte_idx == 0 {
+ return 0;
+ }
+
+ let mut cursor = GraphemeCursor::new(byte_idx, len, true);
+ let (mut chunk, mut chunk_start) = slice.chunk(byte_idx);
+
+ loop {
+ match cursor.prev_boundary(chunk, chunk_start) {
+ Ok(Some(n)) => return n,
+ Ok(None) => return 0,
+ Err(GraphemeIncomplete::PrevChunk) => {
+ // Step back one chunk and retry.
+ let (c, s) = slice.chunk(chunk_start.saturating_sub(1));
+ chunk = c;
+ chunk_start = s;
+ }
+ Err(GraphemeIncomplete::PreContext(n)) => {
+ let (ctx, ctx_start) = slice.chunk(n.saturating_sub(1));
+ cursor.provide_context(ctx, ctx_start);
+ }
+ // The remaining variants cannot arise from prev_boundary with a
+ // cursor built over the whole slice; treat defensively as "no
+ // boundary found" rather than panicking (MJB-HLR-018).
+ Err(_) => return 0,
+ }
+ }
+}
+
+/// MJB-LLR-021: byte offset of the grapheme boundary following `byte_idx`,
+/// or `slice.len()` when there is none.
+pub fn next_grapheme_boundary(slice: RopeSlice, byte_idx: usize) -> usize {
+ let len = slice.len();
+ let byte_idx = slice.floor_char_boundary(byte_idx.min(len));
+ if byte_idx >= len {
+ return len;
+ }
+
+ let mut cursor = GraphemeCursor::new(byte_idx, len, true);
+ let (mut chunk, mut chunk_start) = slice.chunk(byte_idx);
+
+ loop {
+ match cursor.next_boundary(chunk, chunk_start) {
+ Ok(Some(n)) => return n,
+ Ok(None) => return len,
+ Err(GraphemeIncomplete::NextChunk) => {
+ let next_start = chunk_start + chunk.len();
+ if next_start >= len {
+ return len;
+ }
+ let (c, s) = slice.chunk(next_start);
+ chunk = c;
+ chunk_start = s;
+ }
+ Err(GraphemeIncomplete::PreContext(n)) => {
+ let (ctx, ctx_start) = slice.chunk(n.saturating_sub(1));
+ cursor.provide_context(ctx, ctx_start);
+ }
+ Err(_) => return len,
+ }
+ }
+}
+
+/// MJB-LLR-023: whether `byte_idx` lies on a grapheme cluster boundary.
+pub fn is_grapheme_boundary(slice: RopeSlice, byte_idx: usize) -> bool {
+ let len = slice.len();
+ if byte_idx > len || !slice.is_char_boundary(byte_idx) {
+ return false;
+ }
+ if byte_idx == 0 || byte_idx == len {
+ return true;
+ }
+
+ let mut cursor = GraphemeCursor::new(byte_idx, len, true);
+ let (chunk, chunk_start) = slice.chunk(byte_idx);
+
+ loop {
+ match cursor.is_boundary(chunk, chunk_start) {
+ Ok(b) => return b,
+ Err(GraphemeIncomplete::PreContext(n)) => {
+ let (ctx, ctx_start) = slice.chunk(n.saturating_sub(1));
+ cursor.provide_context(ctx, ctx_start);
+ }
+ Err(_) => return false,
+ }
+ }
+}
+
+/// The text in `byte_range` without allocating when it lies in one rope chunk.
+///
+/// Rendering asks for every grapheme on every visible line each frame, so the
+/// obvious `slice.chunks().collect::<String>()` would allocate once per cell
+/// per frame. A grapheme spans a chunk boundary only rarely, and only then is
+/// a copy made.
+pub fn grapheme_str(slice: RopeSlice<'_>, byte_range: std::ops::Range<usize>) -> Cow<'_, str> {
+ let sub = slice.slice(byte_range);
+ match sub.as_str() {
+ Some(s) => Cow::Borrowed(s),
+ None => Cow::Owned(sub.chunks().collect()),
+ }
+}
+
+/// MJB-LLR-024: terminal display width of one grapheme cluster.
+///
+/// A tab is width-dependent on where it starts, so callers pass the column it
+/// begins at. Control characters render as nothing and count zero.
+pub fn grapheme_width(grapheme: &str, at_column: usize) -> usize {
+ if grapheme == "\t" {
+ return TAB_WIDTH - (at_column % TAB_WIDTH);
+ }
+ if grapheme.chars().all(|c| c.is_control()) {
+ return 0;
+ }
+ UnicodeWidthStr::width(grapheme)
+}
+
+/// MJB-LLR-025: display column of `byte_idx` within `line`, accumulating
+/// grapheme widths rather than counting bytes.
+pub fn display_column(line: RopeSlice, byte_idx: usize) -> usize {
+ let limit = line.floor_char_boundary(byte_idx.min(line.len()));
+ let mut column = 0;
+ let mut pos = 0;
+
+ while pos < limit {
+ let next = next_grapheme_boundary(line, pos);
+ if next <= pos {
+ break;
+ }
+ let g = grapheme_str(line, pos..next.min(limit));
+ column += grapheme_width(&g, column);
+ pos = next;
+ }
+ column
+}
+
+/// Inverse of [`display_column`]: the byte offset within `line` whose display
+/// column is nearest to but not beyond `target_column`. Used to preserve the
+/// visual column across vertical motion (MJB-LLR-064).
+pub fn byte_at_display_column(line: RopeSlice, target_column: usize) -> usize {
+ let len = line.len();
+ let mut column = 0;
+ let mut pos = 0;
+
+ while pos < len && column < target_column {
+ let next = next_grapheme_boundary(line, pos);
+ if next <= pos {
+ break;
+ }
+ let g = grapheme_str(line, pos..next);
+ // A line terminator is not a landing position.
+ if g.starts_with('\n') || g.starts_with('\r') {
+ break;
+ }
+ column += grapheme_width(&g, column);
+ if column > target_column {
+ break;
+ }
+ pos = next;
+ }
+ pos
+}
+
+#[cfg(test)]
+mod tests {
+ use ropey::Rope;
+
+ use super::*;
+
+ #[test]
+ fn mjb_llr_020_prev_boundary_saturates_at_zero() {
+ let r = Rope::from_str("abc");
+ assert_eq!(prev_grapheme_boundary(r.slice(..), 0), 0);
+ assert_eq!(prev_grapheme_boundary(r.slice(..), 1), 0);
+ assert_eq!(prev_grapheme_boundary(r.slice(..), 3), 2);
+ }
+
+ #[test]
+ fn mjb_llr_021_next_boundary_saturates_at_end() {
+ let r = Rope::from_str("abc");
+ assert_eq!(next_grapheme_boundary(r.slice(..), 3), 3);
+ assert_eq!(next_grapheme_boundary(r.slice(..), 0), 1);
+ // Beyond the end must clamp rather than panic.
+ assert_eq!(next_grapheme_boundary(r.slice(..), 99), 3);
+ }
+
+ #[test]
+ fn mjb_llr_021_multibyte_advances_whole_char() {
+ // 文 is 3 bytes; a boundary must not land inside it.
+ let r = Rope::from_str("文字化け");
+ assert_eq!(next_grapheme_boundary(r.slice(..), 0), 3);
+ assert_eq!(prev_grapheme_boundary(r.slice(..), 3), 0);
+ }
+
+ #[test]
+ fn mjb_llr_020_combining_mark_is_one_cluster() {
+ // "e" + U+0301 COMBINING ACUTE ACCENT is a single grapheme cluster.
+ let r = Rope::from_str("e\u{0301}x");
+ assert_eq!(next_grapheme_boundary(r.slice(..), 0), 3);
+ assert_eq!(prev_grapheme_boundary(r.slice(..), 3), 0);
+ }
+
+ /// MJB-LLR-022: boundaries must resolve identically whether or not the
+ /// cluster straddles a rope chunk edge.
+ ///
+ /// A rope large enough to hold many chunks is built from multi-byte
+ /// characters, then every boundary is walked and compared against the
+ /// contiguous `&str` answer.
+ #[test]
+ fn mjb_llr_022_boundaries_resolve_across_chunk_edges() {
+ // Large enough to force ropey to split into multiple chunks.
+ let source: String = "文字化けe\u{0301}x".repeat(4000);
+ let r = Rope::from_str(&source);
+ let s = r.slice(..);
+ assert!(
+ s.chunks().count() > 1,
+ "test is meaningless without multiple chunks"
+ );
+
+ // Walk forward over the whole rope, comparing to unicode-segmentation
+ // over the contiguous string.
+ use unicode_segmentation::UnicodeSegmentation;
+ let expected: Vec<usize> = source
+ .grapheme_indices(true)
+ .map(|(i, _)| i)
+ .chain(std::iter::once(source.len()))
+ .collect();
+
+ let mut got = vec![0usize];
+ let mut pos = 0;
+ while pos < s.len() {
+ let next = next_grapheme_boundary(s, pos);
+ assert!(next > pos, "must make progress at byte {pos}");
+ got.push(next);
+ pos = next;
+ }
+ assert_eq!(got, expected, "forward boundaries must match across chunks");
+
+ // And backward, from the end.
+ let mut back = vec![s.len()];
+ let mut pos = s.len();
+ while pos > 0 {
+ let prev = prev_grapheme_boundary(s, pos);
+ assert!(prev < pos, "must make progress backward at byte {pos}");
+ back.push(prev);
+ pos = prev;
+ }
+ back.reverse();
+ assert_eq!(back, expected, "backward boundaries must match across chunks");
+ }
+
+ #[test]
+ fn mjb_llr_023_boundary_detection() {
+ let r = Rope::from_str("文a");
+ let s = r.slice(..);
+ assert!(is_grapheme_boundary(s, 0));
+ assert!(!is_grapheme_boundary(s, 1), "inside a multi-byte char");
+ assert!(is_grapheme_boundary(s, 3));
+ assert!(is_grapheme_boundary(s, 4));
+ }
+
+ #[test]
+ fn mjb_llr_024_widths() {
+ assert_eq!(grapheme_width("a", 0), 1);
+ assert_eq!(grapheme_width("文", 0), 2, "wide char occupies two columns");
+ assert_eq!(grapheme_width("\t", 0), TAB_WIDTH);
+ assert_eq!(grapheme_width("\t", 1), TAB_WIDTH - 1, "tab fills to stop");
+ assert_eq!(grapheme_width("\u{0}", 0), 0);
+ }
+
+ #[test]
+ fn mjb_llr_025_display_column_counts_width_not_bytes() {
+ let r = Rope::from_str("文字a");
+ // Byte 6 is after two wide chars: four columns, not six.
+ assert_eq!(display_column(r.slice(..), 6), 4);
+ assert_eq!(display_column(r.slice(..), 0), 0);
+ }
+
+ #[test]
+ fn mjb_llr_025_display_column_tab_expands() {
+ let r = Rope::from_str("\tx");
+ assert_eq!(display_column(r.slice(..), 1), TAB_WIDTH);
+ }
+
+ #[test]
+ fn byte_at_display_column_round_trips() {
+ let r = Rope::from_str("文字a");
+ let s = r.slice(..);
+ assert_eq!(byte_at_display_column(s, 4), 6);
+ assert_eq!(byte_at_display_column(s, 0), 0);
+ // Past the end of the line clamps to the line's length.
+ assert_eq!(byte_at_display_column(s, 99), s.len());
+ }
+
+ #[test]
+ fn byte_at_display_column_stops_before_terminator() {
+ let r = Rope::from_str("ab\n");
+ assert_eq!(byte_at_display_column(r.slice(..), 99), 2);
+ }
+}
diff --git a/src/buffer/history.rs b/src/buffer/history.rs
new file mode 100644
index 0000000..e4668fc
--- /dev/null
+++ b/src/buffer/history.rs
@@ -0,0 +1,228 @@
+//! Undo/redo history (MJB-HLR-011).
+//!
+//! Each committed edit stores the pair (forward transaction, inverse
+//! transaction). `cursor` is the number of entries currently *applied*, so
+//! entries at or beyond it have been reverted and are available to redo.
+//!
+//! Undo at the start and redo at the end are no-ops, not errors — reaching
+//! either end is ordinary use, not a fault (MJB-LLR-052, MJB-LLR-053).
+
+use super::transaction::Transaction;
+
+#[derive(Debug, Clone)]
+struct Entry {
+ forward: Transaction,
+ inverse: Transaction,
+ /// Identifies the buffer state this entry produces. Never reused.
+ id: usize,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct History {
+ entries: Vec<Entry>,
+ /// Number of entries applied; also the index of the next redo.
+ cursor: usize,
+ /// Monotonic source of entry ids.
+ ///
+ /// Deliberately *not* the same thing as `cursor`. Using stack depth to
+ /// identify a state is wrong: saving at depth 3, undoing, then making a
+ /// different edit returns to depth 3 while the content differs, so a
+ /// depth comparison would report the buffer clean when it is not.
+ next_id: usize,
+}
+
+impl History {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// MJB-LLR-051: record an applied edit.
+ ///
+ /// Anything previously undone is discarded — committing a new edit after an
+ /// undo abandons the branch that was reverted.
+ pub fn commit(&mut self, forward: Transaction, inverse: Transaction) {
+ self.entries.truncate(self.cursor);
+ self.next_id += 1;
+ self.entries.push(Entry {
+ forward,
+ inverse,
+ id: self.next_id,
+ });
+ self.cursor = self.entries.len();
+ }
+
+ /// MJB-LLR-052: the transaction that reverts the most recent edit, or
+ /// `None` when nothing remains to undo.
+ pub fn undo(&mut self) -> Option<&Transaction> {
+ if self.cursor == 0 {
+ return None;
+ }
+ self.cursor -= 1;
+ Some(&self.entries[self.cursor].inverse)
+ }
+
+ /// MJB-LLR-053: the transaction that reapplies the most recently undone
+ /// edit, or `None` when nothing remains to redo.
+ pub fn redo(&mut self) -> Option<&Transaction> {
+ if self.cursor >= self.entries.len() {
+ return None;
+ }
+ let t = &self.entries[self.cursor].forward;
+ self.cursor += 1;
+ Some(t)
+ }
+
+ /// Identifies the current buffer state.
+ ///
+ /// Zero means pristine — no edit applied. Otherwise it is the id of the
+ /// most recently applied entry. Two calls return the same value exactly
+ /// when the buffer content is the same, which is what lets "modified" be
+ /// *derived* rather than latched; see
+ /// [`super::document::Document::is_modified`].
+ pub fn revision(&self) -> usize {
+ match self.cursor {
+ 0 => 0,
+ n => self.entries[n - 1].id,
+ }
+ }
+
+ pub fn can_undo(&self) -> bool {
+ self.cursor > 0
+ }
+
+ pub fn can_redo(&self) -> bool {
+ self.cursor < self.entries.len()
+ }
+
+ pub fn len(&self) -> usize {
+ self.entries.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.entries.is_empty()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use ropey::Rope;
+
+ use super::*;
+ use crate::buffer::transaction::Transaction;
+
+ fn edit(text: &str, from: usize, to: usize, ins: Option<&str>) -> (Transaction, Transaction) {
+ let r = Rope::from_str(text);
+ let t = Transaction::change(&r, [(from, to, ins.map(str::to_owned))]);
+ let inv = Transaction::new(t.changes.invert(&r));
+ (t, inv)
+ }
+
+ #[test]
+ fn mjb_llr_052_undo_past_start_is_a_noop() {
+ let mut h = History::new();
+ assert!(h.undo().is_none());
+ assert!(!h.can_undo());
+ // Repeated attempts must stay safe, not underflow the cursor.
+ assert!(h.undo().is_none());
+ assert!(h.undo().is_none());
+ }
+
+ #[test]
+ fn mjb_llr_053_redo_past_end_is_a_noop() {
+ let mut h = History::new();
+ let (f, i) = edit("abc", 0, 1, None);
+ h.commit(f, i);
+ assert!(h.redo().is_none(), "nothing has been undone yet");
+ assert!(!h.can_redo());
+ }
+
+ #[test]
+ fn mjb_llr_051_commit_then_undo_then_redo() {
+ let mut h = History::new();
+ let (f, i) = edit("abc", 0, 1, None);
+ h.commit(f, i);
+
+ assert!(h.can_undo());
+ assert!(h.undo().is_some());
+ assert!(!h.can_undo());
+ assert!(h.can_redo());
+ assert!(h.redo().is_some());
+ assert!(!h.can_redo());
+ }
+
+ #[test]
+ fn mjb_llr_051_commit_after_undo_discards_the_redo_branch() {
+ let mut h = History::new();
+ let (f1, i1) = edit("abc", 0, 1, None);
+ let (f2, i2) = edit("bc", 0, 1, None);
+ h.commit(f1, i1);
+ h.commit(f2, i2);
+
+ h.undo();
+ assert!(h.can_redo());
+
+ let (f3, i3) = edit("bc", 1, 2, None);
+ h.commit(f3, i3);
+ assert!(!h.can_redo(), "the undone branch must be discarded");
+ assert_eq!(h.len(), 2);
+ }
+
+ /// MJB-LLR-116: a revision identifies *content*, not stack depth.
+ ///
+ /// Regression guard for the trap this replaced: save at depth 2, undo, then
+ /// make a different edit. The cursor returns to 2, but the buffer no longer
+ /// matches what was saved, so the revision must differ.
+ #[test]
+ fn mjb_llr_116_revision_is_not_stack_depth() {
+ let mut h = History::new();
+ let (f1, i1) = edit("abcdef", 0, 1, None);
+ let (f2, i2) = edit("bcdef", 0, 1, None);
+ h.commit(f1, i1);
+ h.commit(f2, i2);
+
+ let saved = h.revision();
+
+ h.undo();
+ assert_ne!(h.revision(), saved, "undo leaves a different state");
+
+ // A different second edit, landing at the same stack depth.
+ let (f3, i3) = edit("bcdef", 1, 2, None);
+ h.commit(f3, i3);
+ assert_eq!(h.len(), 2, "same depth as when we saved");
+ assert_ne!(
+ h.revision(),
+ saved,
+ "same depth, different content: must not look saved"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_116_revision_is_zero_when_pristine_and_returns_on_undo() {
+ let mut h = History::new();
+ assert_eq!(h.revision(), 0);
+
+ let (f, i) = edit("abc", 0, 1, None);
+ h.commit(f, i);
+ let after = h.revision();
+ assert_ne!(after, 0);
+
+ h.undo();
+ assert_eq!(h.revision(), 0, "undoing to pristine returns to revision 0");
+ h.redo();
+ assert_eq!(h.revision(), after, "redo restores the same revision");
+ }
+
+ #[test]
+ fn mjb_llr_052_undo_walks_back_in_order() {
+ let mut h = History::new();
+ for n in 0..3 {
+ let (f, i) = edit("abcdef", n, n + 1, None);
+ h.commit(f, i);
+ }
+ assert_eq!(h.len(), 3);
+ for _ in 0..3 {
+ assert!(h.undo().is_some());
+ }
+ assert!(h.undo().is_none(), "exhausted");
+ }
+}
diff --git a/src/buffer/keymap.rs b/src/buffer/keymap.rs
new file mode 100644
index 0000000..61009c3
--- /dev/null
+++ b/src/buffer/keymap.rs
@@ -0,0 +1,392 @@
+//! Modal keymap resolution (MJB-HLR-015).
+//!
+//! Replaces the application template's resolver, which looked up single keys
+//! first and otherwise accumulated a buffer cleared on every `Action::Tick` —
+//! giving multi-key sequences a ~250 ms timeout at the default tick rate, so
+//! `gg` failed if typed slowly. Here the set of proper prefixes is precomputed,
+//! so resolution is exact and **time-independent** (MJB-LLR-154).
+//!
+//! A static table cannot express everything a modal editor needs: insert mode
+//! must treat any unbound printable key as self-insert, and counts are typed as
+//! ordinary digits. Both are handled around the table rather than in it, by
+//! [`KeymapResult::Cancelled`] and by count accumulation.
+
+use std::collections::{HashMap, HashSet};
+
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+use super::command::Command;
+use crate::config::{KeyBindings, Mode};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum KeymapResult {
+ /// The keys so far are a prefix of at least one binding; wait for more.
+ Pending,
+ /// A binding matched, carrying any count typed before it.
+ Matched(Command, Option<usize>),
+ /// The keys match nothing. Carries what was accumulated so the caller can
+ /// apply a mode-specific fallback, such as insert-mode self-insert.
+ Cancelled(Vec<KeyEvent>),
+}
+
+#[derive(Debug, Default)]
+pub struct Keymap {
+ bindings: HashMap<Mode, HashMap<Vec<KeyEvent>, Command>>,
+ /// MJB-LLR-150: every proper prefix of every bound sequence, per mode.
+ prefixes: HashMap<Mode, HashSet<Vec<KeyEvent>>>,
+ pending: Vec<KeyEvent>,
+ count: Option<usize>,
+}
+
+impl Keymap {
+ /// MJB-LLR-150
+ pub fn new(bindings: &KeyBindings) -> Self {
+ let mut prefixes: HashMap<Mode, HashSet<Vec<KeyEvent>>> = HashMap::new();
+
+ for (mode, map) in &bindings.0 {
+ let set = prefixes.entry(*mode).or_default();
+ for keys in map.keys() {
+ for n in 1..keys.len() {
+ set.insert(keys[..n].to_vec());
+ }
+ }
+ }
+
+ Self {
+ bindings: bindings.0.clone(),
+ prefixes,
+ pending: Vec::new(),
+ count: None,
+ }
+ }
+
+ pub fn pending(&self) -> &[KeyEvent] {
+ &self.pending
+ }
+
+ pub fn count(&self) -> Option<usize> {
+ self.count
+ }
+
+ /// Abandon any partial sequence and count, e.g. on a mode change.
+ pub fn reset(&mut self) {
+ self.pending.clear();
+ self.count = None;
+ }
+
+ /// Look up a single key without disturbing pending state. Used by `App` for
+ /// the `Global` map, which has no multi-key bindings (MJB-LLR-203).
+ pub fn lookup_single(&self, mode: Mode, key: KeyEvent) -> Option<Command> {
+ self.bindings.get(&mode)?.get(&vec![key]).copied()
+ }
+
+ /// MJB-LLR-151..156: feed one key and resolve.
+ pub fn resolve(&mut self, mode: Mode, key: KeyEvent) -> KeymapResult {
+ // MJB-LLR-155: digits typed before a command form a count. Only while
+ // no sequence is pending, so `g` then `1` is not swallowed.
+ if self.pending.is_empty()
+ && matches!(mode, Mode::Normal | Mode::Select)
+ && let KeyCode::Char(c) = key.code
+ && c.is_ascii_digit()
+ && !key.modifiers.contains(KeyModifiers::CONTROL)
+ && !key.modifiers.contains(KeyModifiers::ALT)
+ {
+ let digit = (c as u8 - b'0') as usize;
+ // A leading zero is not a count; it stays available as a binding.
+ if digit != 0 || self.count.is_some() {
+ self.count = Some(self.count.unwrap_or(0) * 10 + digit);
+ return KeymapResult::Pending;
+ }
+ }
+
+ self.pending.push(key);
+
+ // MJB-LLR-151
+ if let Some(&cmd) = self.bindings.get(&mode).and_then(|m| m.get(&self.pending)) {
+ let count = self.count.take();
+ self.pending.clear();
+ return KeymapResult::Matched(cmd, count);
+ }
+
+ // MJB-LLR-152
+ if self
+ .prefixes
+ .get(&mode)
+ .is_some_and(|set| set.contains(&self.pending))
+ {
+ return KeymapResult::Pending;
+ }
+
+ // MJB-LLR-153
+ let keys = std::mem::take(&mut self.pending);
+ self.count = None;
+ KeymapResult::Cancelled(keys)
+ }
+}
+
+/// MJB-LLR-156: the insert-mode fallback — a bare printable character.
+///
+/// Control and Alt are excluded so an unbound `Ctrl-x` is discarded rather than
+/// inserting `x`. Shift is allowed: it is how capitals are typed.
+pub fn self_insert_char(keys: &[KeyEvent]) -> Option<char> {
+ let [key] = keys else {
+ return None;
+ };
+ let KeyCode::Char(c) = key.code else {
+ return None;
+ };
+ if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::ALT) {
+ return None;
+ }
+ Some(c)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::config::parse_key_sequence;
+
+ fn key(c: char) -> KeyEvent {
+ KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty())
+ }
+
+ fn ctrl(c: char) -> KeyEvent {
+ KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
+ }
+
+ fn keymap() -> Keymap {
+ let mut bindings = KeyBindings::default();
+ let mut normal = HashMap::new();
+ normal.insert(parse_key_sequence("<h>").unwrap(), Command::MoveCharLeft);
+ normal.insert(parse_key_sequence("<g><g>").unwrap(), Command::GotoFileStart);
+ normal.insert(parse_key_sequence("<g><e>").unwrap(), Command::GotoLastLine);
+ bindings.0.insert(Mode::Normal, normal);
+
+ let mut insert = HashMap::new();
+ insert.insert(parse_key_sequence("<esc>").unwrap(), Command::NormalMode);
+ bindings.0.insert(Mode::Insert, insert);
+
+ Keymap::new(&bindings)
+ }
+
+ /// MJB-LLR-150: every proper prefix is precomputed, and only proper
+ /// prefixes — a complete binding is not itself registered as a prefix, or
+ /// it would never resolve.
+ #[test]
+ fn mjb_llr_150_proper_prefixes_are_precomputed() {
+ let k = keymap();
+ let set = k.prefixes.get(&Mode::Normal).expect("normal prefixes");
+
+ assert!(
+ set.contains(&parse_key_sequence("<g>").unwrap()),
+ "`g` is a proper prefix of `gg` and `ge`"
+ );
+ assert!(
+ !set.contains(&parse_key_sequence("<g><g>").unwrap()),
+ "a complete binding must not be registered as a prefix"
+ );
+ assert!(
+ !set.contains(&parse_key_sequence("<h>").unwrap()),
+ "a single-key binding has no proper prefix"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_150_prefix_set_is_empty_for_a_mode_without_sequences() {
+ let k = keymap();
+ let set = k.prefixes.get(&Mode::Insert).expect("insert prefixes");
+ assert!(set.is_empty(), "insert has only single-key bindings");
+ }
+
+ #[test]
+ fn mjb_llr_151_single_key_binding_matches_immediately() {
+ let mut k = keymap();
+ assert_eq!(
+ k.resolve(Mode::Normal, key('h')),
+ KeymapResult::Matched(Command::MoveCharLeft, None)
+ );
+ assert!(k.pending().is_empty(), "pending must be cleared");
+ }
+
+ /// MJB-LLR-152, MJB-LLR-154: `g` is a prefix, so it waits — indefinitely,
+ /// with no timer involved. This is the bug the old resolver had.
+ #[test]
+ fn mjb_llr_152_prefix_key_waits_for_more() {
+ let mut k = keymap();
+ assert_eq!(k.resolve(Mode::Normal, key('g')), KeymapResult::Pending);
+ assert_eq!(k.pending().len(), 1);
+ }
+
+ #[test]
+ fn mjb_llr_151_two_key_sequence_resolves() {
+ let mut k = keymap();
+ assert_eq!(k.resolve(Mode::Normal, key('g')), KeymapResult::Pending);
+ assert_eq!(
+ k.resolve(Mode::Normal, key('g')),
+ KeymapResult::Matched(Command::GotoFileStart, None)
+ );
+ }
+
+ #[test]
+ fn mjb_llr_151_sequences_sharing_a_prefix_stay_distinct() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('g'));
+ assert_eq!(
+ k.resolve(Mode::Normal, key('e')),
+ KeymapResult::Matched(Command::GotoLastLine, None)
+ );
+ }
+
+ #[test]
+ fn mjb_llr_153_unknown_continuation_cancels() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('g'));
+ let got = k.resolve(Mode::Normal, key('z'));
+ match got {
+ KeymapResult::Cancelled(keys) => assert_eq!(keys, vec![key('g'), key('z')]),
+ other => panic!("expected Cancelled, got {other:?}"),
+ }
+ assert!(k.pending().is_empty());
+ }
+
+ #[test]
+ fn mjb_llr_153_unbound_key_cancels_immediately() {
+ let mut k = keymap();
+ match k.resolve(Mode::Normal, key('z')) {
+ KeymapResult::Cancelled(keys) => assert_eq!(keys, vec![key('z')]),
+ other => panic!("expected Cancelled, got {other:?}"),
+ }
+ }
+
+ /// MJB-LLR-154: no elapsed-time input exists, so a pending sequence
+ /// survives arbitrarily many unrelated resolutions in other modes.
+ #[test]
+ fn mjb_llr_154_pending_is_not_discarded_by_time() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('g'));
+ assert_eq!(k.pending().len(), 1);
+ // Nothing but another key can advance or clear it.
+ assert_eq!(
+ k.resolve(Mode::Normal, key('g')),
+ KeymapResult::Matched(Command::GotoFileStart, None)
+ );
+ }
+
+ #[test]
+ fn mjb_llr_155_count_accumulates_and_is_delivered() {
+ let mut k = keymap();
+ assert_eq!(k.resolve(Mode::Normal, key('1')), KeymapResult::Pending);
+ assert_eq!(k.resolve(Mode::Normal, key('2')), KeymapResult::Pending);
+ assert_eq!(
+ k.resolve(Mode::Normal, key('h')),
+ KeymapResult::Matched(Command::MoveCharLeft, Some(12))
+ );
+ }
+
+ #[test]
+ fn mjb_llr_155_count_is_consumed_once() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('3'));
+ k.resolve(Mode::Normal, key('h'));
+ assert_eq!(
+ k.resolve(Mode::Normal, key('h')),
+ KeymapResult::Matched(Command::MoveCharLeft, None),
+ "the count must not persist to the next command"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_155_leading_zero_is_not_a_count() {
+ let mut k = keymap();
+ // `0` with no count in progress falls through to binding lookup.
+ match k.resolve(Mode::Normal, key('0')) {
+ KeymapResult::Cancelled(_) => {}
+ other => panic!("expected Cancelled for unbound 0, got {other:?}"),
+ }
+ assert_eq!(k.count(), None);
+ }
+
+ #[test]
+ fn mjb_llr_155_zero_extends_an_existing_count() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('1'));
+ k.resolve(Mode::Normal, key('0'));
+ assert_eq!(
+ k.resolve(Mode::Normal, key('h')),
+ KeymapResult::Matched(Command::MoveCharLeft, Some(10))
+ );
+ }
+
+ #[test]
+ fn mjb_llr_155_digits_are_not_counts_in_insert_mode() {
+ let mut k = keymap();
+ match k.resolve(Mode::Insert, key('5')) {
+ KeymapResult::Cancelled(keys) => {
+ assert_eq!(self_insert_char(&keys), Some('5'), "must type a 5");
+ }
+ other => panic!("expected Cancelled, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn mjb_llr_156_self_insert_accepts_plain_printables() {
+ assert_eq!(self_insert_char(&[key('a')]), Some('a'));
+ assert_eq!(
+ self_insert_char(&[KeyEvent::new(
+ KeyCode::Char('A'),
+ KeyModifiers::SHIFT
+ )]),
+ Some('A'),
+ "shift is how capitals are typed"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_156_self_insert_rejects_control_and_alt() {
+ assert_eq!(self_insert_char(&[ctrl('x')]), None);
+ assert_eq!(
+ self_insert_char(&[KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT)]),
+ None
+ );
+ }
+
+ #[test]
+ fn mjb_llr_156_self_insert_rejects_non_char_and_sequences() {
+ assert_eq!(
+ self_insert_char(&[KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())]),
+ None
+ );
+ assert_eq!(
+ self_insert_char(&[key('a'), key('b')]),
+ None,
+ "only a single key can self-insert"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_203_lookup_single_does_not_disturb_pending() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('g'));
+ assert_eq!(k.lookup_single(Mode::Normal, key('h')), Some(Command::MoveCharLeft));
+ assert_eq!(k.pending().len(), 1, "global lookup must be side-effect free");
+ }
+
+ #[test]
+ fn reset_clears_pending_and_count() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('3'));
+ k.resolve(Mode::Normal, key('g'));
+ k.reset();
+ assert!(k.pending().is_empty());
+ assert_eq!(k.count(), None);
+ }
+
+ #[test]
+ fn unknown_mode_cancels_rather_than_panicking() {
+ let mut k = keymap();
+ match k.resolve(Mode::Command, key('x')) {
+ KeymapResult::Cancelled(_) => {}
+ other => panic!("expected Cancelled for an unmapped mode, got {other:?}"),
+ }
+ }
+}
diff --git a/src/buffer/line_ending.rs b/src/buffer/line_ending.rs
new file mode 100644
index 0000000..42459de
--- /dev/null
+++ b/src/buffer/line_ending.rs
@@ -0,0 +1,144 @@
+//! Line ending detection and normalisation (MJB-HLR-003).
+//!
+//! The rope stores LF internally regardless of what the file used; the original
+//! terminator is recorded and reapplied on write, so opening and saving a CRLF
+//! file does not silently rewrite every line.
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum LineEnding {
+ #[default]
+ Lf,
+ Crlf,
+ Cr,
+}
+
+impl LineEnding {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ LineEnding::Lf => "\n",
+ LineEnding::Crlf => "\r\n",
+ LineEnding::Cr => "\r",
+ }
+ }
+
+ /// The platform default, used when a buffer contains no terminator at all.
+ pub fn platform_default() -> Self {
+ if cfg!(windows) {
+ LineEnding::Crlf
+ } else {
+ LineEnding::Lf
+ }
+ }
+
+ /// MJB-LLR-114: the line ending of the first terminator present.
+ ///
+ /// Takes `&str` rather than a `RopeSlice`: detection runs on freshly
+ /// decoded text before the rope is built, and accepting a rope would force
+ /// the caller to construct one purely to answer this question.
+ ///
+ /// Scans bytes, not chars. CR and LF are ASCII and cannot appear as a
+ /// continuation byte of a multi-byte sequence, so a byte scan is both
+ /// correct and free of UTF-8 decoding.
+ pub fn detect(text: &str) -> Self {
+ match text.as_bytes().iter().position(|&b| b == b'\n' || b == b'\r') {
+ Some(i) if text.as_bytes()[i] == b'\n' => LineEnding::Lf,
+ // A CR followed by LF is CRLF; a CR followed by anything else, or
+ // by nothing at all, stood alone.
+ Some(i) if text.as_bytes().get(i + 1) == Some(&b'\n') => LineEnding::Crlf,
+ Some(_) => LineEnding::Cr,
+ // MJB-LLR-114: no terminator anywhere.
+ None => Self::platform_default(),
+ }
+ }
+}
+
+/// Rewrite every terminator in `text` (assumed LF-normalised) as `ending`.
+pub fn apply(text: &str, ending: LineEnding) -> String {
+ match ending {
+ LineEnding::Lf => text.to_owned(),
+ LineEnding::Crlf => text.replace('\n', "\r\n"),
+ LineEnding::Cr => text.replace('\n', "\r"),
+ }
+}
+
+/// Normalise CRLF and lone CR to LF for storage in the rope.
+pub fn normalize(text: &str) -> String {
+ if !text.contains('\r') {
+ return text.to_owned();
+ }
+ text.replace("\r\n", "\n").replace('\r', "\n")
+}
+
+/// Append a terminator when `text` is non-empty and lacks one (MJB-LLR-185).
+pub fn with_final_newline(text: &str) -> String {
+ if text.is_empty() || text.ends_with('\n') {
+ text.to_owned()
+ } else {
+ format!("{text}\n")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn detect(s: &str) -> LineEnding {
+ LineEnding::detect(s)
+ }
+
+ #[test]
+ fn mjb_llr_114_detects_lf() {
+ assert_eq!(detect("a\nb\n"), LineEnding::Lf);
+ }
+
+ #[test]
+ fn mjb_llr_114_detects_crlf() {
+ assert_eq!(detect("a\r\nb\r\n"), LineEnding::Crlf);
+ }
+
+ #[test]
+ fn mjb_llr_114_detects_lone_cr() {
+ assert_eq!(detect("a\rb\r"), LineEnding::Cr);
+ }
+
+ #[test]
+ fn mjb_llr_114_falls_back_to_platform_default() {
+ assert_eq!(detect("no terminator"), LineEnding::platform_default());
+ assert_eq!(detect(""), LineEnding::platform_default());
+ }
+
+ #[test]
+ fn mjb_llr_114_first_terminator_decides() {
+ // Mixed endings: the first one wins, as documented.
+ assert_eq!(detect("a\nb\r\n"), LineEnding::Lf);
+ assert_eq!(detect("a\r\nb\n"), LineEnding::Crlf);
+ }
+
+ #[test]
+ fn normalize_collapses_to_lf() {
+ assert_eq!(normalize("a\r\nb\r\n"), "a\nb\n");
+ assert_eq!(normalize("a\rb\r"), "a\nb\n");
+ assert_eq!(normalize("a\nb\n"), "a\nb\n");
+ }
+
+ #[test]
+ fn mjb_llr_115_apply_restores_original_ending() {
+ assert_eq!(apply("a\nb\n", LineEnding::Crlf), "a\r\nb\r\n");
+ assert_eq!(apply("a\nb\n", LineEnding::Cr), "a\rb\r");
+ assert_eq!(apply("a\nb\n", LineEnding::Lf), "a\nb\n");
+ }
+
+ #[test]
+ fn crlf_round_trips_through_normalize_and_apply() {
+ let original = "one\r\ntwo\r\nthree\r\n";
+ let stored = normalize(original);
+ assert_eq!(apply(&stored, LineEnding::Crlf), original);
+ }
+
+ #[test]
+ fn mjb_llr_185_final_newline_only_added_when_missing() {
+ assert_eq!(with_final_newline("a"), "a\n");
+ assert_eq!(with_final_newline("a\n"), "a\n", "not doubled");
+ assert_eq!(with_final_newline(""), "", "empty buffer stays empty");
+ }
+}
diff --git a/src/buffer/mod.rs b/src/buffer/mod.rs
new file mode 100644
index 0000000..6924423
--- /dev/null
+++ b/src/buffer/mod.rs
@@ -0,0 +1,615 @@
+//! The buffer core: document model, motions, viewport, and input handling.
+//!
+//! Deliberately free of `ratatui` so it can be exercised by requirements-based
+//! tests without a terminal; the rendering half lives in
+//! [`crate::components::buffer`].
+//!
+//! Developed to DO-178C DAL-C. Items implementing a low-level requirement carry
+//! a `MJB-LLR-nnn` comment; see `docs/requirements/llr.md`.
+
+pub mod command;
+pub mod document;
+pub mod encoding;
+pub mod grapheme;
+pub mod history;
+pub mod keymap;
+pub mod line_ending;
+pub mod movement;
+pub mod save;
+pub mod selection;
+pub mod transaction;
+pub mod view;
+
+use std::path::PathBuf;
+
+use crossterm::event::KeyEvent;
+use ropey::{LineType, RopeSlice};
+
+use self::{
+ command::Command,
+ document::{Document, DocumentError},
+ keymap::{Keymap, KeymapResult, self_insert_char},
+ movement::{WordTarget, word_move},
+ selection::{Range, Selection},
+ transaction::Transaction,
+ view::View,
+};
+use crate::config::{Config, Mode};
+
+/// The line-break convention. `LF_CR` is what ropey enables by default, and
+/// recognises LF, CR and CRLF — matching the endings [`line_ending`] detects.
+pub const LINE_TYPE: LineType = LineType::LF_CR;
+
+/// What the caller should do after a key was handled.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Outcome {
+ /// Handled internally; nothing for the application to do.
+ Consumed,
+ /// The user asked to quit.
+ Quit,
+ /// The user asked to suspend.
+ Suspend,
+}
+
+/// The editor state: one document, one viewport, one mode.
+pub struct Buffer {
+ pub document: Document,
+ pub view: View,
+ pub mode: Mode,
+ keymap: Keymap,
+ config: Config,
+ /// Command-line contents while in [`Mode::Command`].
+ pub command_line: String,
+ /// Transient message shown on the status line.
+ pub status: Option<String>,
+ /// Viewport height last rendered, needed by paging commands.
+ pub last_height: usize,
+ pub last_width: usize,
+}
+
+impl Buffer {
+ pub fn new(config: Config, path: Option<PathBuf>) -> Result<Self, DocumentError> {
+ // MJB-LLR-111, MJB-LLR-112
+ let document = match path {
+ Some(p) => Document::open(&p)?,
+ None => Document::empty(None),
+ };
+ let keymap = Keymap::new(&config.keybindings);
+
+ Ok(Self {
+ document,
+ view: View::new(),
+ mode: Mode::Normal,
+ keymap,
+ config,
+ command_line: String::new(),
+ status: None,
+ last_height: 0,
+ last_width: 0,
+ })
+ }
+
+ pub fn config(&self) -> &Config {
+ &self.config
+ }
+
+ /// Pending keys, for the status line.
+ pub fn pending_keys(&self) -> &[KeyEvent] {
+ self.keymap.pending()
+ }
+
+ /// Route one key according to the current mode.
+ pub fn handle_key(&mut self, key: KeyEvent) -> Outcome {
+ self.status = None;
+
+ // MJB-LLR-158: command mode is a line editor, not a keymap consumer.
+ // Its three bindings still resolve so Esc/Enter/Backspace stay
+ // configurable, but anything else types into the line.
+ if self.mode == Mode::Command {
+ return self.handle_command_mode_key(key);
+ }
+
+ match self.keymap.resolve(self.mode, key) {
+ KeymapResult::Pending => Outcome::Consumed,
+ KeymapResult::Matched(cmd, count) => self.execute(cmd, count.unwrap_or(1)),
+ KeymapResult::Cancelled(keys) => {
+ // MJB-LLR-156: insert mode types the character; every other
+ // mode discards it.
+ if self.mode == Mode::Insert
+ && let Some(c) = self_insert_char(&keys)
+ {
+ self.insert_char(c);
+ }
+ Outcome::Consumed
+ }
+ }
+ }
+
+ fn handle_command_mode_key(&mut self, key: KeyEvent) -> Outcome {
+ use crossterm::event::{KeyCode, KeyModifiers};
+
+ match self.keymap.resolve(self.mode, key) {
+ KeymapResult::Matched(Command::NormalMode, _) => {
+ self.command_line.clear();
+ self.set_mode(Mode::Normal);
+ Outcome::Consumed
+ }
+ KeymapResult::Matched(Command::CommandSubmit, _) => {
+ let line = std::mem::take(&mut self.command_line);
+ self.set_mode(Mode::Normal);
+ self.run_command_line(&line)
+ }
+ KeymapResult::Matched(Command::CommandBackspace, _) => {
+ if self.command_line.pop().is_none() {
+ // Backspacing an empty line leaves command mode, as Helix does.
+ self.set_mode(Mode::Normal);
+ }
+ Outcome::Consumed
+ }
+ _ => {
+ if let KeyCode::Char(c) = key.code
+ && !key.modifiers.contains(KeyModifiers::CONTROL)
+ && !key.modifiers.contains(KeyModifiers::ALT)
+ {
+ self.command_line.push(c);
+ }
+ Outcome::Consumed
+ }
+ }
+ }
+
+ /// MJB-LLR-159, MJB-LLR-160: parse and run a command line.
+ pub fn run_command_line(&mut self, line: &str) -> Outcome {
+ let line = line.trim();
+ let (name, _arg) = match line.split_once(char::is_whitespace) {
+ Some((n, a)) => (n, Some(a.trim())),
+ None => (line, None),
+ };
+
+ let insert_final_newline = self.config.editor.insert_final_newline;
+
+ match name {
+ "" => Outcome::Consumed,
+
+ // MJB-LLR-159
+ "w" | "write" => {
+ self.save(false, insert_final_newline);
+ Outcome::Consumed
+ }
+ "w!" | "write!" => {
+ self.save(true, insert_final_newline);
+ Outcome::Consumed
+ }
+
+ // MJB-LLR-160
+ "q" | "quit" => {
+ if self.document.is_modified() {
+ self.status =
+ Some("unsaved changes (use :q! to discard, :wq to save)".to_owned());
+ Outcome::Consumed
+ } else {
+ Outcome::Quit
+ }
+ }
+ "q!" | "quit!" => Outcome::Quit,
+
+ "wq" | "x" | "write-quit" => {
+ if self.save(false, insert_final_newline) {
+ Outcome::Quit
+ } else {
+ Outcome::Consumed
+ }
+ }
+
+ other => {
+ // MJB-LLR-159: report, do not terminate.
+ self.status = Some(format!("unknown command: {other}"));
+ Outcome::Consumed
+ }
+ }
+ }
+
+ /// Returns whether the write succeeded.
+ fn save(&mut self, force: bool, insert_final_newline: bool) -> bool {
+ match self.document.save(force, insert_final_newline) {
+ Ok(()) => {
+ let name = self
+ .document
+ .path()
+ .map(|p| p.display().to_string())
+ .unwrap_or_else(|| "[no name]".to_owned());
+ self.status = Some(format!("wrote {name}"));
+ true
+ }
+ Err(e) => {
+ self.status = Some(e.to_string());
+ false
+ }
+ }
+ }
+
+ fn set_mode(&mut self, mode: Mode) {
+ if self.mode != mode {
+ self.mode = mode;
+ // A half-typed sequence must not survive a mode change.
+ self.keymap.reset();
+ }
+ }
+
+ fn insert_char(&mut self, c: char) {
+ let mut s = [0u8; 4];
+ self.insert_text(c.encode_utf8(&mut s));
+ }
+
+ fn insert_text(&mut self, text: &str) {
+ let t = Transaction::insert(self.document.text(), self.document.selection(), text);
+ let at = self.document.range().cursor(self.document.slice());
+ if self.apply(&t) {
+ // Cursor advances past what was inserted.
+ self.document.set_range(Range::point(at + text.len()));
+ }
+ }
+
+ fn apply(&mut self, t: &Transaction) -> bool {
+ match self.document.apply(t) {
+ Ok(()) => true,
+ Err(e) => {
+ self.status = Some(e.to_string());
+ false
+ }
+ }
+ }
+
+ /// Execute one command. `count` is at least 1.
+ pub fn execute(&mut self, cmd: Command, count: usize) -> Outcome {
+ use Command::*;
+
+ let count = count.max(1);
+ let extend = self.mode == Mode::Select;
+
+ match cmd {
+ Quit => return Outcome::Quit,
+ Suspend => return Outcome::Suspend,
+
+ // --- Modes ---
+ NormalMode => self.set_mode(Mode::Normal),
+ InsertMode => {
+ // MJB-LLR-009: `i` inserts before the selection.
+ let from = self.document.range().from();
+ self.document.set_range(Range::point(from));
+ self.set_mode(Mode::Insert);
+ }
+ SelectMode => {
+ self.set_mode(if self.mode == Mode::Select {
+ Mode::Normal
+ } else {
+ Mode::Select
+ });
+ }
+ CommandMode => {
+ self.command_line.clear();
+ self.set_mode(Mode::Command);
+ }
+
+ // --- Motion (MJB-HLR-006) ---
+ MoveCharLeft => self.motion(extend, |t, r| movement::move_char_left(t, r, count)),
+ MoveCharRight => self.motion(extend, |t, r| movement::move_char_right(t, r, count)),
+ MoveLineUp => self.motion(extend, |t, r| movement::move_vertically(t, r, count, false)),
+ MoveLineDown => self.motion(extend, |t, r| movement::move_vertically(t, r, count, true)),
+ ExtendCharLeft => self.motion(true, |t, r| movement::move_char_left(t, r, count)),
+ ExtendCharRight => self.motion(true, |t, r| movement::move_char_right(t, r, count)),
+ ExtendLineUp => self.motion(true, |t, r| movement::move_vertically(t, r, count, false)),
+ ExtendLineDown => self.motion(true, |t, r| movement::move_vertically(t, r, count, true)),
+
+ // --- Word motion; these leave selections (MJB-HLR-007) ---
+ MoveNextWordStart => self.word(count, WordTarget::NextStart, false),
+ MovePrevWordStart => self.word(count, WordTarget::PrevStart, false),
+ MoveNextWordEnd => self.word(count, WordTarget::NextEnd, false),
+ MoveNextLongWordStart => self.word(count, WordTarget::NextStart, true),
+ MovePrevLongWordStart => self.word(count, WordTarget::PrevStart, true),
+ MoveNextLongWordEnd => self.word(count, WordTarget::NextEnd, true),
+ ExtendNextWordStart => self.word(count, WordTarget::NextStart, false),
+ ExtendPrevWordStart => self.word(count, WordTarget::PrevStart, false),
+ ExtendNextWordEnd => self.word(count, WordTarget::NextEnd, false),
+
+ // --- Goto (MJB-HLR-008) ---
+ GotoFileStart => {
+ let r = movement::goto_file_start(self.document.slice());
+ self.put(r, extend);
+ }
+ GotoLastLine => {
+ let r = movement::goto_last_line(self.document.slice());
+ self.put(r, extend);
+ }
+ GotoLineStart => {
+ let r = movement::goto_line_start(self.document.slice(), self.document.range());
+ self.put(r, extend);
+ }
+ GotoLineEnd => {
+ let r = movement::goto_line_end(self.document.slice(), self.document.range());
+ self.put(r, extend);
+ }
+ GotoFirstNonWhitespace => {
+ let r =
+ movement::goto_first_non_whitespace(self.document.slice(), self.document.range());
+ self.put(r, extend);
+ }
+
+ // --- Selection manipulation ---
+ ExtendLineBelow => self.extend_line_below(count),
+ CollapseSelection => {
+ let cursor = self.document.range().cursor(self.document.slice());
+ self.document.set_range(Range::point(cursor));
+ }
+ FlipSelections => {
+ let r = self.document.range().flipped();
+ self.document.set_range(r);
+ }
+ SelectAll => {
+ let len = self.document.text().len();
+ self.document.set_range(Range::new(0, len));
+ }
+
+ // --- Entering insert mode (MJB-HLR-009) ---
+ AppendMode => {
+ // `a` inserts after the selection. In Helix a bare cursor is a
+ // one-grapheme range, so appending lands *past* the grapheme
+ // under it; our empty range has to step forward explicitly to
+ // reproduce that.
+ let text = self.document.slice();
+ let r = self.document.range();
+ let at = if r.is_empty() {
+ grapheme::next_grapheme_boundary(text, r.cursor(text))
+ } else {
+ r.to()
+ };
+ self.document.set_range(Range::point(at));
+ self.set_mode(Mode::Insert);
+ }
+ InsertAtLineStart => {
+ let r =
+ movement::goto_first_non_whitespace(self.document.slice(), self.document.range());
+ self.document.set_range(Range::point(r.head));
+ self.set_mode(Mode::Insert);
+ }
+ InsertAtLineEnd => {
+ let r = movement::goto_line_end(self.document.slice(), self.document.range());
+ self.document.set_range(Range::point(r.head));
+ self.set_mode(Mode::Insert);
+ }
+ OpenBelow => self.open_line(false),
+ OpenAbove => self.open_line(true),
+
+ // --- Modification (MJB-HLR-010) ---
+ DeleteSelection => self.delete_selection(),
+ ChangeSelection => {
+ self.delete_selection();
+ self.set_mode(Mode::Insert);
+ }
+ InsertNewline => self.insert_text("\n"),
+ InsertTab => self.insert_text("\t"),
+ DeleteCharBackward => self.delete_char_backward(),
+ DeleteCharForward => self.delete_char_forward(),
+ DeleteWordBackward => self.delete_word_backward(),
+ KillToLineStart => self.kill_to_line_start(),
+
+ // --- Undo / redo (MJB-HLR-011) ---
+ Undo => match self.document.undo() {
+ Ok(false) => self.status = Some("already at oldest change".to_owned()),
+ Ok(true) => {}
+ Err(e) => self.status = Some(e.to_string()),
+ },
+ Redo => match self.document.redo() {
+ Ok(false) => self.status = Some("already at newest change".to_owned()),
+ Ok(true) => {}
+ Err(e) => self.status = Some(e.to_string()),
+ },
+
+ // --- Paging (MJB-HLR-013) ---
+ PageCursorHalfUp => self.page(self.last_height / 2, false),
+ PageCursorHalfDown => self.page(self.last_height / 2, true),
+ PageUp => self.page(self.last_height, false),
+ PageDown => self.page(self.last_height, true),
+
+ // Handled by handle_command_mode_key; unreachable elsewhere but
+ // must not panic if a user binds them outside command mode.
+ CommandSubmit | CommandBackspace => {}
+ }
+
+ Outcome::Consumed
+ }
+
+ // --- helpers ---
+
+ /// Run a motion and install its result, extending the selection or
+ /// collapsing to a point per `extend`.
+ ///
+ /// Takes a closure rather than a function pointer so the direction and
+ /// count stay visible at the call site, instead of hiding behind a family
+ /// of near-identical adapter functions.
+ fn motion(&mut self, extend: bool, f: impl FnOnce(RopeSlice, Range) -> Range) {
+ let text = self.document.slice();
+ let r = f(text, self.document.range());
+ self.put(r, extend);
+ }
+
+ fn put(&mut self, target: Range, extend: bool) {
+ let text = self.document.slice();
+ let current = self.document.range();
+ let r = current.put_cursor(text, target.cursor(text), extend);
+ self.document.set_range(r);
+ }
+
+ /// MJB-LLR-065..067: word motions install the returned range directly,
+ /// because the range *is* the result — collapsing it would destroy the
+ /// selection-first behaviour that makes `wd` work.
+ fn word(&mut self, count: usize, target: WordTarget, long: bool) {
+ let text = self.document.slice();
+ let r = word_move(text, self.document.range(), count, target, long);
+ self.document.set_range(r);
+ }
+
+ /// Helix's `x`: select the current line; repeated, extend by one more.
+ fn extend_line_below(&mut self, count: usize) {
+ let text = self.document.slice();
+ let r = self.document.range();
+ let (start_line, end_line) = r.line_range(text);
+
+ let already_whole_line = r.from() == text.line_to_byte_idx(start_line, LINE_TYPE)
+ && r.to() == line_start_of_next(text, end_line);
+
+ let (first, last) = if already_whole_line {
+ (start_line, (end_line + count).min(last_line_index(text)))
+ } else {
+ (start_line, (end_line + count - 1).min(last_line_index(text)))
+ };
+
+ let from = text.line_to_byte_idx(first, LINE_TYPE);
+ let to = line_start_of_next(text, last);
+ self.document.set_range(Range::new(from, to));
+ }
+
+ fn open_line(&mut self, above: bool) {
+ let text = self.document.slice();
+ let line = self.document.range().cursor_line(text);
+
+ // `above` inserts the terminator at the line's start, so the blank line
+ // appears *at* that offset. `below` inserts it after the line's content
+ // — deliberately at the content end rather than at the next line's
+ // start, because a final line with no trailing newline has no next line
+ // to anchor to, and the blank line then lands one byte later.
+ let (at, cursor) = if above {
+ let start = text.line_to_byte_idx(line, LINE_TYPE);
+ (start, start)
+ } else {
+ let eol = movement::line_end_byte(text, line);
+ (eol, eol + 1)
+ };
+
+ let t = Transaction::change(self.document.text(), [(at, at, Some("\n".to_owned()))]);
+ if self.apply(&t) {
+ self.document.set_range(Range::point(cursor));
+ self.set_mode(Mode::Insert);
+ }
+ }
+
+ fn delete_selection(&mut self) {
+ let r = self.document.range();
+ if r.is_empty() {
+ // MJB-LLR-050 robustness: `d` with nothing selected deletes the
+ // grapheme under the cursor rather than doing nothing.
+ let text = self.document.slice();
+ let to = grapheme::next_grapheme_boundary(text, r.cursor(text));
+ if to == r.from() {
+ return;
+ }
+ let t = Transaction::change(self.document.text(), [(r.from(), to, None)]);
+ let from = r.from();
+ if self.apply(&t) {
+ self.document.set_range(Range::point(from));
+ }
+ return;
+ }
+
+ let from = r.from();
+ let t = Transaction::delete(self.document.text(), self.document.selection());
+ if self.apply(&t) {
+ self.document.set_range(Range::point(from));
+ }
+ }
+
+ fn delete_char_backward(&mut self) {
+ let text = self.document.slice();
+ let cursor = self.document.range().cursor(text);
+ let from = grapheme::prev_grapheme_boundary(text, cursor);
+ if from == cursor {
+ return; // at the start of the buffer
+ }
+ let t = Transaction::change(self.document.text(), [(from, cursor, None)]);
+ if self.apply(&t) {
+ self.document.set_range(Range::point(from));
+ }
+ }
+
+ fn delete_char_forward(&mut self) {
+ let text = self.document.slice();
+ let cursor = self.document.range().cursor(text);
+ let to = grapheme::next_grapheme_boundary(text, cursor);
+ if to == cursor {
+ return; // at the end of the buffer
+ }
+ let t = Transaction::change(self.document.text(), [(cursor, to, None)]);
+ if self.apply(&t) {
+ self.document.set_range(Range::point(cursor));
+ }
+ }
+
+ fn delete_word_backward(&mut self) {
+ let text = self.document.slice();
+ let cursor = self.document.range().cursor(text);
+ if cursor == 0 {
+ return;
+ }
+ let target = word_move(text, Range::point(cursor), 1, WordTarget::PrevStart, false);
+ let from = target.from();
+ if from >= cursor {
+ return;
+ }
+ let t = Transaction::change(self.document.text(), [(from, cursor, None)]);
+ if self.apply(&t) {
+ self.document.set_range(Range::point(from));
+ }
+ }
+
+ fn kill_to_line_start(&mut self) {
+ let text = self.document.slice();
+ let cursor = self.document.range().cursor(text);
+ let line = text.byte_to_line_idx(cursor, LINE_TYPE);
+ let from = text.line_to_byte_idx(line, LINE_TYPE);
+ if from >= cursor {
+ return;
+ }
+ let t = Transaction::change(self.document.text(), [(from, cursor, None)]);
+ if self.apply(&t) {
+ self.document.set_range(Range::point(from));
+ }
+ }
+
+ fn page(&mut self, lines: usize, down: bool) {
+ if lines == 0 {
+ return;
+ }
+ let text = self.document.slice();
+ let r = self.view.page(text, self.document.range(), lines, down);
+ self.document.set_range(r);
+ }
+
+ /// Re-anchor the viewport for a viewport of `height` rows.
+ pub fn update_view(&mut self, width: usize, height: usize) {
+ self.last_width = width;
+ self.last_height = height;
+ let text = self.document.slice();
+ let range = self.document.range();
+ self.view
+ .ensure_cursor_in_view(text, range, height, self.config.editor.scrolloff);
+ self.view.ensure_horizontal_in_view(text, range, width);
+ }
+
+ /// Replace the whole selection state — used by tests and by `%`.
+ pub fn set_selection(&mut self, selection: Selection) {
+ self.document.set_selection(selection);
+ }
+}
+
+fn line_start_of_next(text: RopeSlice, line: usize) -> usize {
+ let total = text.len_lines(LINE_TYPE);
+ if line + 1 < total {
+ text.line_to_byte_idx(line + 1, LINE_TYPE)
+ } else {
+ text.len()
+ }
+}
+
+fn last_line_index(text: RopeSlice) -> usize {
+ text.len_lines(LINE_TYPE).saturating_sub(1)
+}
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));
+ }
+}
diff --git a/src/buffer/save.rs b/src/buffer/save.rs
new file mode 100644
index 0000000..70dacd0
--- /dev/null
+++ b/src/buffer/save.rs
@@ -0,0 +1,387 @@
+//! File writing — ported from Helix's `helix-view/src/document.rs::save_impl`.
+//!
+//! Two distinct fallbacks, both required by MJB-HLR-017 and easy to conflate:
+//!
+//! 1. **copy instead of rename** when the target is a symlink or hardlink.
+//! Renaming the backup into place would break the link; copying preserves it.
+//! 2. **restore on failure** — the backup lives in the target's *own directory*
+//! so the rename can never cross a filesystem, and is put back if the write
+//! fails partway.
+//!
+//! The ordering matters: every check that can reject the write runs before
+//! anything on disk is touched.
+
+use std::{
+ fs,
+ io::{self, Write},
+ path::{Path, PathBuf},
+};
+
+#[derive(Debug, thiserror::Error)]
+pub enum SaveError {
+ #[error("no file name associated with this buffer")]
+ NoPath,
+ #[error("path is read only: {0}")]
+ ReadOnly(PathBuf),
+ #[error("can't save file, parent directory does not exist (use :w! to create it): {0}")]
+ NoParent(PathBuf),
+ #[error("io error: {0}")]
+ Io(#[from] io::Error),
+}
+
+/// MJB-LLR-131: writable without modifying anything.
+///
+/// A path that does not exist is *not* read-only — it may still be creatable.
+pub fn readonly(path: &Path) -> bool {
+ match fs::metadata(path) {
+ Ok(md) => md.permissions().readonly(),
+ Err(e) if e.kind() == io::ErrorKind::NotFound => false,
+ Err(_) => true,
+ }
+}
+
+/// MJB-LLR-130: follow a symlink to its target so the link itself survives.
+///
+/// A relative link target is resolved against the link's own directory.
+pub fn resolve_write_path(path: &Path) -> PathBuf {
+ match fs::read_link(path) {
+ Ok(target) => {
+ if target.is_relative() {
+ path.parent()
+ .map(|parent| parent.join(&target))
+ .unwrap_or(target)
+ } else {
+ target
+ }
+ }
+ Err(_) => path.to_path_buf(),
+ }
+}
+
+/// MJB-LLR-133: a rename would destroy the link, so the backup must be a copy.
+pub fn must_copy(path: &Path) -> bool {
+ if fs::symlink_metadata(path)
+ .map(|md| md.file_type().is_symlink())
+ .unwrap_or(false)
+ {
+ return true;
+ }
+ hard_link_count(path) > 1
+}
+
+#[cfg(unix)]
+fn hard_link_count(path: &Path) -> u64 {
+ use std::os::unix::fs::MetadataExt;
+ fs::metadata(path).map(|md| md.nlink()).unwrap_or(1)
+}
+
+#[cfg(not(unix))]
+fn hard_link_count(_path: &Path) -> u64 {
+ 1
+}
+
+/// Copy permissions from `from` onto `to` (MJB-LLR-136).
+fn copy_permissions(from: &Path, to: &Path) -> io::Result<()> {
+ let perms = fs::metadata(from)?.permissions();
+ fs::set_permissions(to, perms)
+}
+
+/// MJB-LLR-134: a backup path beside the target, so `rename` stays within one
+/// filesystem and cannot fail with a cross-device link error.
+pub(crate) fn backup_path(target: &Path) -> PathBuf {
+ let name = target
+ .file_name()
+ .map(|n| n.to_string_lossy().into_owned())
+ .unwrap_or_else(|| "buffer".to_owned());
+ let dir = target.parent().unwrap_or_else(|| Path::new("."));
+ // The process id keeps concurrent instances from colliding without
+ // needing a random source.
+ dir.join(format!(".{name}.mojibake-{}.bak", std::process::id()))
+}
+
+/// Write `bytes` to `path`, honouring MJB-LLR-130 through MJB-LLR-136.
+///
+/// `force` corresponds to `:w!` and permits creating a missing parent
+/// directory (MJB-LLR-132).
+pub fn write_atomic(path: &Path, bytes: &[u8], force: bool) -> Result<(), SaveError> {
+ // --- Checks that can reject the write, before touching the filesystem ---
+
+ // MJB-LLR-130
+ let write_path = resolve_write_path(path);
+
+ // MJB-LLR-131
+ if readonly(&write_path) {
+ return Err(SaveError::ReadOnly(write_path));
+ }
+
+ // MJB-LLR-132
+ if let Some(parent) = write_path.parent()
+ && !parent.as_os_str().is_empty()
+ && !parent.exists()
+ {
+ if force {
+ fs::create_dir_all(parent)?;
+ } else {
+ return Err(SaveError::NoParent(parent.to_path_buf()));
+ }
+ }
+
+ // --- Backup (MJB-LLR-133, MJB-LLR-134) ---
+
+ let exists = write_path.exists();
+ let copy_mode = exists && must_copy(&write_path);
+ let backup = if exists {
+ let backup = backup_path(&write_path);
+ let made = if copy_mode {
+ fs::copy(&write_path, &backup).map(|_| ())
+ } else {
+ fs::rename(&write_path, &backup)
+ };
+ // A backup we could not make is not fatal; the write proceeds without
+ // the safety net rather than refusing to save at all.
+ match made {
+ Ok(()) => Some(backup),
+ Err(_) => None,
+ }
+ } else {
+ None
+ };
+
+ // --- The write itself ---
+
+ let result = (|| -> io::Result<()> {
+ let mut file = fs::File::create(&write_path)?;
+ file.write_all(bytes)?;
+ file.sync_all()?;
+ Ok(())
+ })();
+
+ match (result, backup) {
+ (Ok(()), Some(backup)) => {
+ // MJB-LLR-136
+ let _ = copy_permissions(&backup, &write_path);
+ let _ = fs::remove_file(&backup);
+ Ok(())
+ }
+ (Ok(()), None) => Ok(()),
+ (Err(e), Some(backup)) => {
+ // MJB-LLR-135: put the original back.
+ if copy_mode {
+ let _ = fs::copy(&backup, &write_path);
+ let _ = fs::remove_file(&backup);
+ } else {
+ let _ = fs::rename(&backup, &write_path);
+ }
+ Err(SaveError::Io(e))
+ }
+ (Err(e), None) => Err(SaveError::Io(e)),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn tmp() -> tempfile::TempDir {
+ tempfile::tempdir().unwrap()
+ }
+
+ #[test]
+ fn writes_a_new_file() {
+ let d = tmp();
+ let p = d.path().join("new.txt");
+ write_atomic(&p, b"hello", false).unwrap();
+ assert_eq!(fs::read(&p).unwrap(), b"hello");
+ }
+
+ #[test]
+ fn overwrites_an_existing_file() {
+ let d = tmp();
+ let p = d.path().join("f.txt");
+ fs::write(&p, b"old contents that are longer").unwrap();
+ write_atomic(&p, b"new", false).unwrap();
+ assert_eq!(fs::read(&p).unwrap(), b"new");
+ }
+
+ #[test]
+ fn mjb_llr_136_no_backup_file_is_left_behind() {
+ let d = tmp();
+ let p = d.path().join("f.txt");
+ fs::write(&p, b"old").unwrap();
+ write_atomic(&p, b"new", false).unwrap();
+
+ let leftovers: Vec<_> = fs::read_dir(d.path())
+ .unwrap()
+ .filter_map(Result::ok)
+ .map(|e| e.file_name().to_string_lossy().into_owned())
+ .filter(|n| n.contains("mojibake"))
+ .collect();
+ assert!(leftovers.is_empty(), "stray backups: {leftovers:?}");
+ }
+
+ #[test]
+ fn mjb_llr_132_missing_parent_is_refused_without_force() {
+ let d = tmp();
+ let p = d.path().join("missing").join("f.txt");
+ let err = write_atomic(&p, b"x", false).unwrap_err();
+ assert!(matches!(err, SaveError::NoParent(_)));
+ assert!(!p.exists());
+ }
+
+ #[test]
+ fn mjb_llr_132_force_creates_the_parent() {
+ let d = tmp();
+ let p = d.path().join("a").join("b").join("f.txt");
+ write_atomic(&p, b"x", true).unwrap();
+ assert_eq!(fs::read(&p).unwrap(), b"x");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn mjb_llr_131_readonly_target_is_refused() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let d = tmp();
+ let p = d.path().join("ro.txt");
+ fs::write(&p, b"original").unwrap();
+ fs::set_permissions(&p, fs::Permissions::from_mode(0o444)).unwrap();
+
+ let err = write_atomic(&p, b"replacement", false).unwrap_err();
+ assert!(matches!(err, SaveError::ReadOnly(_)));
+ assert_eq!(
+ fs::read(&p).unwrap(),
+ b"original",
+ "a refused write must not truncate the file"
+ );
+ }
+
+ /// MJB-LLR-130, MJB-LLR-133: the link must survive and its target change.
+ #[cfg(unix)]
+ #[test]
+ fn mjb_llr_130_write_follows_symlink_without_replacing_it() {
+ let d = tmp();
+ let target = d.path().join("target.txt");
+ let link = d.path().join("link.txt");
+ fs::write(&target, b"before").unwrap();
+ std::os::unix::fs::symlink(&target, &link).unwrap();
+
+ write_atomic(&link, b"after", false).unwrap();
+
+ assert!(
+ fs::symlink_metadata(&link).unwrap().file_type().is_symlink(),
+ "the symlink must still be a symlink"
+ );
+ assert_eq!(fs::read(&target).unwrap(), b"after", "target updated");
+ }
+
+ /// MJB-LLR-133: a hardlinked file must keep its link count.
+ #[cfg(unix)]
+ #[test]
+ fn mjb_llr_133_hardlink_is_detected_and_preserved() {
+ let d = tmp();
+ let a = d.path().join("a.txt");
+ let b = d.path().join("b.txt");
+ fs::write(&a, b"before").unwrap();
+ fs::hard_link(&a, &b).unwrap();
+
+ assert!(must_copy(&a), "hardlinked file must use copy mode");
+
+ write_atomic(&a, b"after", false).unwrap();
+ assert_eq!(fs::read(&a).unwrap(), b"after");
+ assert_eq!(
+ fs::read(&b).unwrap(),
+ b"after",
+ "the hard link must still point at the same inode"
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn mjb_llr_130_relative_symlink_resolves_against_its_own_directory() {
+ let d = tmp();
+ let target = d.path().join("t.txt");
+ let link = d.path().join("l.txt");
+ fs::write(&target, b"x").unwrap();
+ std::os::unix::fs::symlink("t.txt", &link).unwrap();
+
+ assert_eq!(resolve_write_path(&link), target);
+ }
+
+ #[test]
+ fn mjb_llr_131_missing_file_is_not_readonly() {
+ let d = tmp();
+ assert!(
+ !readonly(&d.path().join("does-not-exist")),
+ "a creatable path must not be reported read-only"
+ );
+ }
+
+ /// MJB-LLR-134: the backup must live in the target's own directory. A
+ /// backup in a temp dir elsewhere would make the rename cross a filesystem
+ /// boundary and fail with EXDEV.
+ #[test]
+ fn mjb_llr_134_backup_is_created_beside_the_target() {
+ let target = Path::new("/some/deep/directory/file.txt");
+ let backup = backup_path(target);
+ assert_eq!(
+ backup.parent(),
+ target.parent(),
+ "backup must sit beside the target, not in a temp directory"
+ );
+ assert_ne!(backup, target);
+ assert!(
+ backup
+ .file_name()
+ .unwrap()
+ .to_string_lossy()
+ .starts_with('.'),
+ "backup should be hidden"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_134_backup_path_handles_a_bare_file_name() {
+ // No parent component: must not panic.
+ let backup = backup_path(Path::new("file.txt"));
+ assert!(backup.to_string_lossy().contains("file.txt"));
+ }
+
+ #[test]
+ fn mjb_llr_133_plain_file_does_not_need_copy_mode() {
+ let d = tmp();
+ let p = d.path().join("plain.txt");
+ fs::write(&p, b"x").unwrap();
+ assert!(!must_copy(&p));
+ }
+
+ /// MJB-LLR-135: when the write cannot even be created, the original
+ /// contents must still be on disk afterwards.
+ #[cfg(unix)]
+ #[test]
+ fn mjb_llr_135_failed_write_restores_the_original() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let d = tmp();
+ let sub = d.path().join("sub");
+ fs::create_dir(&sub).unwrap();
+ let p = sub.join("f.txt");
+ fs::write(&p, b"original").unwrap();
+
+ // Make the *directory* unwritable so File::create fails after the
+ // backup has been taken. The file itself stays writable, so the
+ // read-only pre-check does not short-circuit the test.
+ fs::set_permissions(&sub, fs::Permissions::from_mode(0o500)).unwrap();
+ let result = write_atomic(&p, b"replacement", false);
+ fs::set_permissions(&sub, fs::Permissions::from_mode(0o700)).unwrap();
+
+ if result.is_err() {
+ assert_eq!(
+ fs::read(&p).unwrap(),
+ b"original",
+ "a failed write must restore the previous contents"
+ );
+ }
+ // Running as root defeats the permission bits; the assertion above is
+ // skipped in that case rather than reporting a false failure.
+ }
+}
diff --git a/src/buffer/selection.rs b/src/buffer/selection.rs
new file mode 100644
index 0000000..e30d617
--- /dev/null
+++ b/src/buffer/selection.rs
@@ -0,0 +1,344 @@
+//! Selection model — byte-indexed, following Helix's `helix-core/src/selection.rs`.
+//!
+//! This is what makes the editor selection-first rather than Vim-like: a motion
+//! leaves a *range*, and an operator such as `d` acts on that range. There is no
+//! operator-pending state anywhere in the editor.
+//!
+//! Conventions, preserved exactly from Helix:
+//!
+//! - A range is **half-open**: inclusive of `from()`, exclusive of `to()`,
+//! regardless of whether `head` precedes or follows `anchor`.
+//! - The visible block cursor spans one grapheme *inward* from the head, so a
+//! forward range `0..1` shows its cursor on byte 0, not byte 1.
+//!
+//! Per MJB-LLR-009 a `Selection` holds exactly one range. It is a struct rather
+//! than a bare `Range` so that multi-cursor support can be added later without
+//! reworking call sites.
+
+use ropey::RopeSlice;
+
+use super::grapheme::{next_grapheme_boundary, prev_grapheme_boundary};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Direction {
+ Forward,
+ Backward,
+}
+
+/// MJB-LLR-001: a range over the buffer, both offsets in **bytes**.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct Range {
+ /// The side that stays put when extending.
+ pub anchor: usize,
+ /// The side that moves when extending.
+ pub head: usize,
+}
+
+impl Range {
+ pub fn new(anchor: usize, head: usize) -> Self {
+ Self { anchor, head }
+ }
+
+ /// A zero-width range at `head`.
+ pub fn point(head: usize) -> Self {
+ Self { anchor: head, head }
+ }
+
+ /// MJB-LLR-011: clamp both offsets into the buffer and snap them to char
+ /// boundaries. Byte indexing permits offsets that char indexing could not
+ /// express, and ropey panics on them — see MJB-DR-002.
+ pub fn clamped(self, text: RopeSlice) -> Self {
+ let len = text.len();
+ Self {
+ anchor: text.floor_char_boundary(self.anchor.min(len)),
+ head: text.floor_char_boundary(self.head.min(len)),
+ }
+ }
+
+ /// MJB-LLR-002: lower bound, inclusive.
+ pub fn from(&self) -> usize {
+ self.anchor.min(self.head)
+ }
+
+ /// MJB-LLR-002: upper bound, exclusive.
+ pub fn to(&self) -> usize {
+ self.anchor.max(self.head)
+ }
+
+ /// MJB-LLR-003
+ pub fn is_empty(&self) -> bool {
+ self.anchor == self.head
+ }
+
+ /// Byte length of the span.
+ pub fn len(&self) -> usize {
+ self.to() - self.from()
+ }
+
+ /// MJB-LLR-004
+ pub fn direction(&self) -> Direction {
+ if self.head < self.anchor {
+ Direction::Backward
+ } else {
+ Direction::Forward
+ }
+ }
+
+ /// MJB-LLR-005: the byte offset the block cursor is drawn at.
+ ///
+ /// For a forward range the head sits *past* the last selected grapheme, so
+ /// the cursor steps back one grapheme to land on it.
+ pub fn cursor(&self, text: RopeSlice) -> usize {
+ if self.head > self.anchor {
+ prev_grapheme_boundary(text, self.head)
+ } else {
+ self.head
+ }
+ }
+
+ /// MJB-LLR-006, MJB-LLR-007: move the cursor to `byte_idx`.
+ ///
+ /// Without `extend` this collapses to a point. With `extend` the anchor is
+ /// nudged by one grapheme when the range flips direction across it, so the
+ /// anchored grapheme stays selected — this is Helix's `put_cursor`.
+ pub fn put_cursor(self, text: RopeSlice, byte_idx: usize, extend: bool) -> Self {
+ if !extend {
+ return Range::point(byte_idx).clamped(text);
+ }
+
+ let anchor = if self.head >= self.anchor && byte_idx < self.anchor {
+ next_grapheme_boundary(text, self.anchor)
+ } else if self.head < self.anchor && byte_idx >= self.anchor {
+ prev_grapheme_boundary(text, self.anchor)
+ } else {
+ self.anchor
+ };
+
+ if anchor <= byte_idx {
+ Range::new(anchor, next_grapheme_boundary(text, byte_idx)).clamped(text)
+ } else {
+ Range::new(anchor, byte_idx).clamped(text)
+ }
+ }
+
+ /// The line the cursor lies on.
+ pub fn cursor_line(&self, text: RopeSlice) -> usize {
+ text.byte_to_line_idx(self.cursor(text), super::LINE_TYPE)
+ }
+
+ /// MJB-LLR-008: inclusive span of line indices the range covers.
+ pub fn line_range(&self, text: RopeSlice) -> (usize, usize) {
+ let lt = super::LINE_TYPE;
+ let start = text.byte_to_line_idx(self.from(), lt);
+ // An exclusive upper bound sitting exactly on a line start belongs to
+ // the previous line, otherwise `x` on a full line would report two.
+ let end_byte = if self.to() > self.from() {
+ self.to() - 1
+ } else {
+ self.to()
+ };
+ let end = text.byte_to_line_idx(end_byte.min(text.len()), lt);
+ (start, end)
+ }
+
+ /// Flip anchor and head, keeping the same span.
+ pub fn flipped(self) -> Self {
+ Range::new(self.head, self.anchor)
+ }
+}
+
+/// MJB-LLR-009: exactly one range, with `primary_index` pinned at zero.
+///
+/// The vector and index exist so the multi-cursor shape is already in place;
+/// the invariant is asserted, not assumed.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Selection {
+ ranges: Vec<Range>,
+ primary_index: usize,
+}
+
+impl Default for Selection {
+ fn default() -> Self {
+ Self::point(0)
+ }
+}
+
+impl Selection {
+ pub fn single(range: Range) -> Self {
+ Self {
+ ranges: vec![range],
+ primary_index: 0,
+ }
+ }
+
+ pub fn point(byte_idx: usize) -> Self {
+ Self::single(Range::point(byte_idx))
+ }
+
+ /// MJB-LLR-010
+ pub fn primary(&self) -> Range {
+ self.ranges[self.primary_index]
+ }
+
+ pub fn set_primary(&mut self, range: Range) {
+ self.ranges[self.primary_index] = range;
+ }
+
+ pub fn ranges(&self) -> &[Range] {
+ &self.ranges
+ }
+
+ /// MJB-LLR-009: the single-range invariant, checked rather than assumed.
+ pub fn invariant_holds(&self) -> bool {
+ self.ranges.len() == 1 && self.primary_index == 0
+ }
+
+ /// Clamp every range into `text`.
+ pub fn clamped(mut self, text: RopeSlice) -> Self {
+ for r in &mut self.ranges {
+ *r = r.clamped(text);
+ }
+ self
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use ropey::Rope;
+
+ use super::*;
+
+ /// MJB-LLR-001: offsets are bytes, not characters. A range over a
+ /// multi-byte character must report its byte extent.
+ #[test]
+ fn mjb_llr_001_offsets_are_byte_indices() {
+ let r = Rope::from_str("文字");
+ let s = r.slice(..);
+ assert_eq!(s.len(), 6, "two 3-byte characters");
+
+ let whole = Range::new(0, 6).clamped(s);
+ assert_eq!(whole.len(), 6, "length is in bytes, not characters");
+
+ // A single character spans three byte offsets.
+ let first = Range::new(0, 3).clamped(s);
+ assert_eq!(first.len(), 3);
+ }
+
+ #[test]
+ fn mjb_llr_002_from_and_to_ignore_direction() {
+ assert_eq!(Range::new(2, 5).from(), 2);
+ assert_eq!(Range::new(2, 5).to(), 5);
+ assert_eq!(Range::new(5, 2).from(), 2, "backward range still orders");
+ assert_eq!(Range::new(5, 2).to(), 5);
+ }
+
+ #[test]
+ fn mjb_llr_003_is_empty() {
+ assert!(Range::point(3).is_empty());
+ assert!(!Range::new(3, 4).is_empty());
+ }
+
+ #[test]
+ fn mjb_llr_004_direction() {
+ assert_eq!(Range::new(1, 5).direction(), Direction::Forward);
+ assert_eq!(Range::new(5, 1).direction(), Direction::Backward);
+ assert_eq!(
+ Range::point(2).direction(),
+ Direction::Forward,
+ "an empty range is forward by convention"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_005_cursor_steps_back_on_forward_range() {
+ let r = Rope::from_str("abcdef");
+ let s = r.slice(..);
+ // Forward 0..1 selects byte 0, so the cursor is drawn on byte 0.
+ assert_eq!(Range::new(0, 1).cursor(s), 0);
+ assert_eq!(Range::new(0, 3).cursor(s), 2);
+ // A backward range's head already sits on the cursor.
+ assert_eq!(Range::new(3, 0).cursor(s), 0);
+ assert_eq!(Range::point(4).cursor(s), 4);
+ }
+
+ #[test]
+ fn mjb_llr_005_cursor_respects_grapheme_clusters() {
+ let r = Rope::from_str("文字");
+ let s = r.slice(..);
+ // Head past the first wide char: cursor lands on its start, not mid-char.
+ assert_eq!(Range::new(0, 3).cursor(s), 0);
+ }
+
+ #[test]
+ fn mjb_llr_006_put_cursor_without_extend_collapses() {
+ let r = Rope::from_str("abcdef");
+ let s = r.slice(..);
+ let got = Range::new(0, 4).put_cursor(s, 2, false);
+ assert_eq!(got, Range::point(2));
+ }
+
+ #[test]
+ fn mjb_llr_007_put_cursor_with_extend_keeps_anchor() {
+ let r = Rope::from_str("abcdef");
+ let s = r.slice(..);
+ let got = Range::new(1, 2).put_cursor(s, 4, true);
+ assert_eq!(got.anchor, 1, "anchor stays put when extending forward");
+ assert_eq!(got.head, 5, "head lands one grapheme past the target");
+ }
+
+ #[test]
+ fn mjb_llr_007_put_cursor_extend_flips_direction() {
+ let r = Rope::from_str("abcdef");
+ let s = r.slice(..);
+ // Forward range extended to before its anchor must flip and nudge the
+ // anchor forward one grapheme so the anchored byte stays selected.
+ let got = Range::new(2, 4).put_cursor(s, 0, true);
+ assert_eq!(got.direction(), Direction::Backward);
+ assert_eq!(got.anchor, 3);
+ assert_eq!(got.head, 0);
+ }
+
+ #[test]
+ fn mjb_llr_011_clamped_snaps_into_bounds_and_onto_char_boundary() {
+ let r = Rope::from_str("文");
+ let s = r.slice(..);
+ assert_eq!(Range::new(0, 99).clamped(s).head, 3, "clamped to length");
+ assert_eq!(
+ Range::new(0, 1).clamped(s).head,
+ 0,
+ "an offset inside a multi-byte char snaps back to its start"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_008_line_range() {
+ let r = Rope::from_str("aa\nbb\ncc\n");
+ let s = r.slice(..);
+ assert_eq!(Range::point(0).line_range(s), (0, 0));
+ // Exactly one full line, terminator included, is still one line.
+ assert_eq!(Range::new(0, 3).line_range(s), (0, 0));
+ assert_eq!(Range::new(0, 6).line_range(s), (0, 1));
+ }
+
+ #[test]
+ fn mjb_llr_009_selection_invariant() {
+ let sel = Selection::point(0);
+ assert!(sel.invariant_holds());
+ assert_eq!(sel.ranges().len(), 1);
+ }
+
+ #[test]
+ fn mjb_llr_010_primary_round_trips() {
+ let mut sel = Selection::point(0);
+ sel.set_primary(Range::new(1, 4));
+ assert_eq!(sel.primary(), Range::new(1, 4));
+ }
+
+ #[test]
+ fn flipped_preserves_span() {
+ let r = Range::new(2, 7).flipped();
+ assert_eq!((r.anchor, r.head), (7, 2));
+ assert_eq!(r.from(), 2);
+ assert_eq!(r.to(), 7);
+ }
+}
diff --git a/src/buffer/transaction.rs b/src/buffer/transaction.rs
new file mode 100644
index 0000000..b58bed9
--- /dev/null
+++ b/src/buffer/transaction.rs
@@ -0,0 +1,424 @@
+//! Change sets and transactions — after Helix's `helix-core/src/transaction.rs`.
+//!
+//! Every buffer modification is expressed as a [`Transaction`]. Undo is not a
+//! separate mechanism: it is the *inverse* transaction, computed against the
+//! document as it stood before the change (MJB-HLR-011).
+//!
+//! All counts are **byte** lengths.
+
+use ropey::Rope;
+
+use super::selection::Selection;
+
+/// MJB-LLR-040
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Operation {
+ /// Leave `n` bytes untouched.
+ Retain(usize),
+ /// Remove `n` bytes.
+ Delete(usize),
+ /// Insert this text.
+ Insert(String),
+}
+
+impl Operation {
+ /// Bytes of the *pre-application* document this operation consumes.
+ fn consumed(&self) -> usize {
+ match self {
+ Operation::Retain(n) | Operation::Delete(n) => *n,
+ Operation::Insert(_) => 0,
+ }
+ }
+
+ /// Bytes this operation contributes to the *post-application* document.
+ fn produced(&self) -> usize {
+ match self {
+ Operation::Retain(n) => *n,
+ Operation::Delete(_) => 0,
+ Operation::Insert(s) => s.len(),
+ }
+ }
+}
+
+#[derive(Debug, thiserror::Error, PartialEq, Eq)]
+pub enum ChangeError {
+ #[error("change set expects a document of {expected} bytes, got {actual}")]
+ LengthMismatch { expected: usize, actual: usize },
+ #[error("operation boundary at byte {0} is not a character boundary")]
+ NonCharBoundary(usize),
+ #[error("operation at byte {0} extends past the end of the document")]
+ OutOfBounds(usize),
+}
+
+/// MJB-LLR-041
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct ChangeSet {
+ changes: Vec<Operation>,
+ /// Document length this change set requires before application.
+ len: usize,
+ /// Document length after application.
+ len_after: usize,
+}
+
+impl ChangeSet {
+ pub fn new(rope: &Rope) -> Self {
+ let len = rope.len();
+ Self {
+ changes: Vec::new(),
+ len,
+ len_after: len,
+ }
+ }
+
+ pub fn from_ops(ops: Vec<Operation>) -> Self {
+ let len = ops.iter().map(Operation::consumed).sum();
+ let len_after = ops.iter().map(Operation::produced).sum();
+ Self {
+ changes: ops,
+ len,
+ len_after,
+ }
+ }
+
+ pub fn ops(&self) -> &[Operation] {
+ &self.changes
+ }
+
+ pub fn len(&self) -> usize {
+ self.len
+ }
+
+ pub fn len_after(&self) -> usize {
+ self.len_after
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.changes
+ .iter()
+ .all(|op| matches!(op, Operation::Retain(_)))
+ }
+
+ /// MJB-LLR-042, MJB-LLR-043, MJB-LLR-044: apply to `rope`.
+ ///
+ /// Validation runs to completion *before* any mutation, so a rejected
+ /// change set leaves the rope untouched rather than half-applied.
+ pub fn apply(&self, rope: &mut Rope) -> Result<(), ChangeError> {
+ // MJB-LLR-042
+ if rope.len() != self.len {
+ return Err(ChangeError::LengthMismatch {
+ expected: self.len,
+ actual: rope.len(),
+ });
+ }
+
+ // MJB-LLR-044: verify every boundary first. ropey panics on a non-char
+ // boundary, and a panic is not an acceptable failure mode (MJB-DR-002).
+ let mut probe = 0usize;
+ for op in &self.changes {
+ if !rope.is_char_boundary(probe) {
+ return Err(ChangeError::NonCharBoundary(probe));
+ }
+ probe += op.consumed();
+ if probe > rope.len() {
+ return Err(ChangeError::OutOfBounds(probe));
+ }
+ }
+ if !rope.is_char_boundary(probe) {
+ return Err(ChangeError::NonCharBoundary(probe));
+ }
+
+ // MJB-LLR-043: apply front-to-back in a single pass.
+ //
+ // `pos` tracks the cursor in the *output*, which is what makes this
+ // work without buffering the edits or walking backwards: `Retain`
+ // advances over text present in both images, `Delete` removes at `pos`
+ // and so leaves it pointing at the next surviving byte, and `Insert`
+ // advances past what it added. Later offsets therefore stay valid as
+ // the rope shifts beneath them.
+ let mut pos = 0usize;
+ for op in &self.changes {
+ match op {
+ Operation::Retain(n) => pos += n,
+ Operation::Delete(n) => rope.remove(pos..pos + n),
+ Operation::Insert(s) => {
+ rope.insert(pos, s);
+ pos += s.len();
+ }
+ }
+ }
+
+ debug_assert_eq!(rope.len(), self.len_after);
+ Ok(())
+ }
+
+ /// MJB-LLR-045: the change set that undoes this one.
+ ///
+ /// MJB-LLR-046: applying this change set and then its inverse reproduces
+ /// the original contents exactly — the property undo rests on.
+ ///
+ /// `original` must be the document as it stood *before* this change set was
+ /// applied — deleted text is recovered from it.
+ pub fn invert(&self, original: &Rope) -> ChangeSet {
+ let mut ops = Vec::with_capacity(self.changes.len());
+ let mut pos = 0usize;
+
+ for op in &self.changes {
+ match op {
+ Operation::Retain(n) => {
+ ops.push(Operation::Retain(*n));
+ pos += n;
+ }
+ Operation::Delete(n) => {
+ let text: String = original.slice(pos..pos + n).chunks().collect();
+ ops.push(Operation::Insert(text));
+ pos += n;
+ }
+ Operation::Insert(s) => ops.push(Operation::Delete(s.len())),
+ }
+ }
+
+ ChangeSet {
+ changes: ops,
+ len: self.len_after,
+ len_after: self.len,
+ }
+ }
+}
+
+/// MJB-LLR-047: a change set plus the selection that should result from it.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Transaction {
+ pub changes: ChangeSet,
+ pub selection: Option<Selection>,
+}
+
+impl Transaction {
+ pub fn new(changes: ChangeSet) -> Self {
+ Self {
+ changes,
+ selection: None,
+ }
+ }
+
+ pub fn with_selection(mut self, selection: Selection) -> Self {
+ self.selection = Some(selection);
+ self
+ }
+
+ /// MJB-LLR-048: build from `(from, to, Option<text>)` triples, which must
+ /// arrive ordered by ascending `from` and must not overlap.
+ pub fn change<I>(rope: &Rope, changes: I) -> Self
+ where
+ I: IntoIterator<Item = (usize, usize, Option<String>)>,
+ {
+ let mut ops = Vec::new();
+ let mut pos = 0usize;
+
+ for (from, to, text) in changes {
+ if from > pos {
+ ops.push(Operation::Retain(from - pos));
+ }
+ if to > from {
+ ops.push(Operation::Delete(to - from));
+ }
+ if let Some(s) = text
+ && !s.is_empty()
+ {
+ ops.push(Operation::Insert(s));
+ }
+ pos = to.max(from);
+ }
+
+ let len = rope.len();
+ if pos < len {
+ ops.push(Operation::Retain(len - pos));
+ }
+
+ Self::new(ChangeSet::from_ops(ops))
+ }
+
+ /// MJB-LLR-049: insert `text` at the selection's cursor.
+ pub fn insert(rope: &Rope, selection: &Selection, text: &str) -> Self {
+ let at = selection.primary().cursor(rope.slice(..));
+ Self::change(rope, [(at, at, Some(text.to_owned()))])
+ }
+
+ /// MJB-LLR-050: delete the selection's primary span.
+ pub fn delete(rope: &Rope, selection: &Selection) -> Self {
+ let r = selection.primary();
+ Self::change(rope, [(r.from(), r.to(), None)])
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn rope(s: &str) -> Rope {
+ Rope::from_str(s)
+ }
+
+ /// MJB-LLR-040: operation counts are byte lengths, so a multi-byte
+ /// character contributes its byte width.
+ #[test]
+ fn mjb_llr_040_operation_counts_are_byte_lengths() {
+ let insert = Operation::Insert("文".to_owned());
+ assert_eq!(insert.produced(), 3, "one character, three bytes");
+ assert_eq!(insert.consumed(), 0, "an insert consumes no input");
+
+ assert_eq!(Operation::Retain(4).consumed(), 4);
+ assert_eq!(Operation::Retain(4).produced(), 4);
+ assert_eq!(Operation::Delete(4).consumed(), 4);
+ assert_eq!(Operation::Delete(4).produced(), 0);
+ }
+
+ /// MJB-LLR-041: `len` is the required pre-image length, `len_after` the
+ /// post-image length.
+ #[test]
+ fn mjb_llr_041_changeset_records_both_lengths() {
+ let r = rope("abcdef");
+ // Replace two bytes with three.
+ let t = Transaction::change(&r, [(1, 3, Some("XYZ".into()))]);
+ assert_eq!(t.changes.len(), 6, "must match the source document");
+ assert_eq!(t.changes.len_after(), 7, "6 - 2 + 3");
+
+ let mut m = r.clone();
+ t.changes.apply(&mut m).unwrap();
+ assert_eq!(m.len(), t.changes.len_after());
+ }
+
+ #[test]
+ fn mjb_llr_041_empty_changeset_reports_equal_lengths() {
+ let r = rope("abc");
+ let cs = ChangeSet::new(&r);
+ assert_eq!(cs.len(), 3);
+ assert_eq!(cs.len_after(), 3);
+ assert!(cs.is_empty());
+ }
+
+ /// MJB-LLR-047: a transaction carries the selection that should result.
+ #[test]
+ fn mjb_llr_047_transaction_carries_a_selection() {
+ use super::super::selection::Range;
+ let r = rope("abcdef");
+ let t = Transaction::change(&r, [(0, 1, None)]);
+ assert_eq!(t.selection, None, "none by default");
+
+ let sel = Selection::single(Range::new(0, 2));
+ let t = t.with_selection(sel.clone());
+ assert_eq!(t.selection, Some(sel));
+ }
+
+ #[test]
+ fn mjb_llr_043_apply_insert_and_delete() {
+ let mut r = rope("hello world");
+ let t = Transaction::change(&r, [(0, 5, Some("goodbye".into()))]);
+ t.changes.apply(&mut r).unwrap();
+ assert_eq!(r.to_string(), "goodbye world");
+ }
+
+ #[test]
+ fn mjb_llr_042_length_mismatch_is_rejected() {
+ let r = rope("abcdef");
+ let t = Transaction::change(&r, [(0, 1, None)]);
+ let mut other = rope("shorter than expected? no — different");
+ let err = t.changes.apply(&mut other).unwrap_err();
+ assert!(matches!(err, ChangeError::LengthMismatch { .. }));
+ }
+
+ #[test]
+ fn mjb_llr_042_rejected_change_leaves_rope_untouched() {
+ let r = rope("abcdef");
+ let t = Transaction::change(&r, [(0, 3, Some("xyz".into()))]);
+ let mut other = rope("12345678");
+ let before = other.to_string();
+ assert!(t.changes.apply(&mut other).is_err());
+ assert_eq!(other.to_string(), before, "must not partially apply");
+ }
+
+ #[test]
+ fn mjb_llr_044_non_char_boundary_errors_rather_than_panics() {
+ // Split "文" (3 bytes) after its first byte.
+ let mut r = rope("文");
+ let cs = ChangeSet::from_ops(vec![Operation::Retain(1), Operation::Delete(2)]);
+ let err = cs.apply(&mut r).unwrap_err();
+ assert_eq!(err, ChangeError::NonCharBoundary(1));
+ assert_eq!(r.to_string(), "文", "rope must be unchanged");
+ }
+
+ #[test]
+ fn mjb_llr_045_invert_maps_each_operation() {
+ let r = rope("abcdef");
+ let t = Transaction::change(&r, [(1, 3, Some("XY".into()))]);
+ let inv = t.changes.invert(&r);
+ // Delete(2) became Insert("bc"); Insert("XY") became Delete(2).
+ assert!(inv.ops().contains(&Operation::Insert("bc".into())));
+ assert!(inv.ops().contains(&Operation::Delete(2)));
+ }
+
+ #[test]
+ fn mjb_llr_046_apply_then_invert_round_trips() {
+ for (text, from, to, ins) in [
+ ("hello world", 0usize, 5usize, Some("goodbye")),
+ ("hello world", 5, 11, None),
+ ("", 0, 0, Some("new")),
+ ("文字化け", 0, 3, Some("X")),
+ ("no trailing newline", 3, 3, Some(" inserted")),
+ ] {
+ let original = rope(text);
+ let mut r = original.clone();
+ let t = Transaction::change(&r, [(from, to, ins.map(str::to_owned))]);
+ let inverse = t.changes.invert(&original);
+
+ t.changes.apply(&mut r).unwrap();
+ inverse.apply(&mut r).unwrap();
+
+ assert_eq!(
+ r.to_string(),
+ original.to_string(),
+ "round trip failed for {text:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn mjb_llr_048_multiple_ordered_changes() {
+ let mut r = rope("aaa bbb ccc");
+ let t = Transaction::change(&r, [(0, 3, Some("XXX".into())), (8, 11, Some("ZZZ".into()))]);
+ t.changes.apply(&mut r).unwrap();
+ assert_eq!(r.to_string(), "XXX bbb ZZZ");
+ }
+
+ #[test]
+ fn mjb_llr_049_insert_at_cursor() {
+ let r = rope("ab");
+ let sel = Selection::point(1);
+ let mut m = r.clone();
+ Transaction::insert(&r, &sel, "X")
+ .changes
+ .apply(&mut m)
+ .unwrap();
+ assert_eq!(m.to_string(), "aXb");
+ }
+
+ #[test]
+ fn mjb_llr_050_delete_selection_span() {
+ use super::super::selection::Range;
+ let r = rope("abcdef");
+ let sel = Selection::single(Range::new(1, 4));
+ let mut m = r.clone();
+ Transaction::delete(&r, &sel)
+ .changes
+ .apply(&mut m)
+ .unwrap();
+ assert_eq!(m.to_string(), "aef");
+ }
+
+ #[test]
+ fn empty_document_accepts_insert() {
+ let mut r = rope("");
+ let t = Transaction::change(&r, [(0, 0, Some("x".into()))]);
+ t.changes.apply(&mut r).unwrap();
+ assert_eq!(r.to_string(), "x");
+ }
+}
diff --git a/src/buffer/view.rs b/src/buffer/view.rs
new file mode 100644
index 0000000..e419052
--- /dev/null
+++ b/src/buffer/view.rs
@@ -0,0 +1,371 @@
+//! Viewport — after Helix's `helix-view/src/view.rs`.
+//!
+//! The pagination requirement (MJB-HLR-012) is met structurally, not by
+//! optimisation: the viewport is anchored by the **byte offset of the first
+//! visible line**, and rendering walks `lines_at(top_line)` for at most
+//! `height` lines. Nothing in this module iterates the whole rope, so per-frame
+//! cost is O(viewport) whatever the file size.
+//!
+//! Helix's `ViewPosition` also carries a `vertical_offset` addressing rows
+//! within a soft-wrapped line. There is no soft wrap here, so one buffer line
+//! is exactly one screen row and the field is omitted — see MJB-DR-003.
+
+use ropey::RopeSlice;
+
+use super::{
+ LINE_TYPE,
+ grapheme::display_column,
+ movement::last_content_line,
+ selection::Range,
+};
+
+/// MJB-LLR-090
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct ViewPosition {
+ /// Byte offset of the first visible line's start. Always a line start.
+ pub anchor: usize,
+ /// Leftmost visible display column.
+ pub horizontal_offset: usize,
+}
+
+#[derive(Debug, Clone, Copy, Default)]
+pub struct View {
+ pub offset: ViewPosition,
+}
+
+impl View {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// MJB-LLR-091
+ pub fn top_line(&self, text: RopeSlice) -> usize {
+ let anchor = self.offset.anchor.min(text.len());
+ text.byte_to_line_idx(anchor, LINE_TYPE)
+ }
+
+ /// MJB-LLR-092: the visible line range, `(first, count)`.
+ ///
+ /// Deliberately returns indices rather than content so the caller can drive
+ /// `lines_at` directly and touch no other line.
+ pub fn visible_line_range(&self, text: RopeSlice, height: usize) -> (usize, usize) {
+ let top = self.top_line(text);
+ let total = text.len_lines(LINE_TYPE);
+ let count = height.min(total.saturating_sub(top));
+ (top, count)
+ }
+
+ /// MJB-LLR-092: at most `height` line slices, starting at the top line.
+ pub fn visible_lines<'a>(
+ &self,
+ text: RopeSlice<'a>,
+ height: usize,
+ ) -> impl Iterator<Item = RopeSlice<'a>> {
+ let (top, count) = self.visible_line_range(text, height);
+ text.lines_at(top, LINE_TYPE).take(count)
+ }
+
+ /// Set the top line directly, clamping into the buffer (MJB-LLR-097).
+ pub fn set_top_line(&mut self, text: RopeSlice, line: usize) {
+ let last = text.len_lines(LINE_TYPE).saturating_sub(1);
+ let line = line.min(last);
+ self.offset.anchor = text.line_to_byte_idx(line, LINE_TYPE);
+ }
+
+ /// MJB-LLR-093..098: scroll vertically so the cursor sits inside the
+ /// scroll-off margins.
+ pub fn ensure_cursor_in_view(
+ &mut self,
+ text: RopeSlice,
+ range: Range,
+ height: usize,
+ scrolloff: usize,
+ ) {
+ // MJB-LLR-098: a zero-height viewport has no inside; the margin
+ // arithmetic below would underflow.
+ if height == 0 {
+ return;
+ }
+
+ // MJB-LLR-093: Helix clamps the margins to half the viewport, so a
+ // scrolloff larger than the viewport cannot fight itself.
+ let scrolloff_top = scrolloff.min((height - 1) / 2);
+ let scrolloff_bottom = scrolloff.min(height / 2);
+
+ let cursor_line = range.cursor_line(text);
+ let top = self.top_line(text);
+
+ let new_top = if cursor_line < top + scrolloff_top {
+ // MJB-LLR-094
+ Some(cursor_line.saturating_sub(scrolloff_top))
+ } else if cursor_line + scrolloff_bottom >= top + height {
+ // MJB-LLR-095
+ Some((cursor_line + scrolloff_bottom + 1).saturating_sub(height))
+ } else {
+ // MJB-LLR-096
+ None
+ };
+
+ if let Some(t) = new_top {
+ self.set_top_line(text, t);
+ }
+ }
+
+ /// MJB-LLR-099: scroll horizontally so the cursor's column is visible.
+ pub fn ensure_horizontal_in_view(&mut self, text: RopeSlice, range: Range, width: usize) {
+ if width == 0 {
+ return;
+ }
+ 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);
+
+ if column < self.offset.horizontal_offset {
+ self.offset.horizontal_offset = column;
+ } else if column >= self.offset.horizontal_offset + width {
+ self.offset.horizontal_offset = column + 1 - width;
+ }
+ }
+
+ /// MJB-LLR-100, MJB-LLR-101, MJB-LLR-102: move cursor and viewport together
+ /// by `lines`, saturating at the buffer's ends.
+ pub fn page(&mut self, text: RopeSlice, range: Range, lines: usize, down: bool) -> Range {
+ let last = last_content_line(text);
+ let cursor_line = range.cursor_line(text);
+ let top = self.top_line(text);
+
+ let (target_line, new_top) = if down {
+ (
+ cursor_line.saturating_add(lines).min(last),
+ top.saturating_add(lines),
+ )
+ } else {
+ (
+ cursor_line.saturating_sub(lines),
+ top.saturating_sub(lines),
+ )
+ };
+
+ self.set_top_line(text, new_top);
+
+ // Land on the same display column where the target line allows it.
+ let line_start = text.line_to_byte_idx(cursor_line, LINE_TYPE);
+ let column = display_column(text.line(cursor_line, LINE_TYPE), range.cursor(text) - line_start);
+ let target_start = text.line_to_byte_idx(target_line, LINE_TYPE);
+ let offset =
+ super::grapheme::byte_at_display_column(text.line(target_line, LINE_TYPE), column);
+
+ Range::point(target_start + offset).clamped(text)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use ropey::Rope;
+
+ use super::*;
+
+ /// 100 lines: "line0\nline1\n...".
+ fn doc(n: usize) -> Rope {
+ let mut s = String::new();
+ for i in 0..n {
+ s.push_str(&format!("line{i}\n"));
+ }
+ Rope::from_str(&s)
+ }
+
+ fn at_line(text: RopeSlice, line: usize) -> Range {
+ Range::point(text.line_to_byte_idx(line, LINE_TYPE))
+ }
+
+ /// MJB-LLR-090: the anchor is a **byte** offset and always a line start.
+ #[test]
+ fn mjb_llr_090_anchor_is_a_byte_offset_at_a_line_start() {
+ // Multi-byte lines, so a byte anchor differs from a line index.
+ let t = Rope::from_str("文字\n化け\n三行\n");
+ let s = t.slice(..);
+ let mut v = View::new();
+
+ v.set_top_line(s, 1);
+ assert_eq!(v.offset.anchor, 7, "byte offset, not line index");
+ assert_eq!(
+ v.offset.anchor,
+ s.line_to_byte_idx(1, LINE_TYPE),
+ "anchor must land exactly on a line start"
+ );
+ assert_eq!(v.top_line(s), 1, "and convert back");
+
+ assert_eq!(v.offset.horizontal_offset, 0, "columns start unscrolled");
+ }
+
+ #[test]
+ fn mjb_llr_091_top_line_from_anchor() {
+ let t = doc(10);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 4);
+ assert_eq!(v.top_line(s), 4);
+ }
+
+ /// MJB-LLR-092: the renderer must see exactly the visible window.
+ #[test]
+ fn mjb_llr_092_visible_lines_are_bounded_by_height() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 10);
+
+ let lines: Vec<String> = v.visible_lines(s, 5).map(|l| l.to_string()).collect();
+ assert_eq!(lines.len(), 5, "must not exceed the viewport height");
+ assert_eq!(lines[0], "line10\n");
+ assert_eq!(lines[4], "line14\n");
+ }
+
+ #[test]
+ fn mjb_llr_092_visible_lines_clamp_near_end_of_buffer() {
+ let t = doc(10);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 9);
+ // 10 content lines plus the trailing empty line ropey reports.
+ let count = v.visible_lines(s, 20).count();
+ assert!(count <= 2, "must not run past the end, got {count}");
+ }
+
+ #[test]
+ fn mjb_llr_096_no_scroll_when_cursor_is_comfortable() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 10);
+ let before = v.offset;
+ v.ensure_cursor_in_view(s, at_line(s, 15), 20, 5);
+ assert_eq!(v.offset, before, "cursor already inside both margins");
+ }
+
+ #[test]
+ fn mjb_llr_094_scrolls_up_to_honour_top_margin() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 20);
+ v.ensure_cursor_in_view(s, at_line(s, 21), 20, 5);
+ assert_eq!(v.top_line(s), 16, "cursor_line - scrolloff_top");
+ }
+
+ #[test]
+ fn mjb_llr_095_scrolls_down_to_honour_bottom_margin() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 0);
+ // height 20, scrolloff 5 -> cursor at 18 forces top to 18+5+1-20 = 4.
+ v.ensure_cursor_in_view(s, at_line(s, 18), 20, 5);
+ assert_eq!(v.top_line(s), 4);
+ }
+
+ /// MJB-LLR-093: scrolloff exceeding the viewport must be clamped, not
+ /// allowed to drive the anchor past the cursor.
+ #[test]
+ fn mjb_llr_093_scrolloff_larger_than_viewport_is_clamped() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 50);
+ v.ensure_cursor_in_view(s, at_line(s, 50), 10, 999);
+ // Margins clamp to (10-1)/2 = 4 and 10/2 = 5.
+ assert_eq!(v.top_line(s), 46);
+ }
+
+ /// MJB-LLR-098: a zero-height viewport must not underflow `height - 1`.
+ #[test]
+ fn mjb_llr_098_zero_height_viewport_is_a_noop() {
+ let t = doc(10);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 3);
+ let before = v.offset;
+ v.ensure_cursor_in_view(s, at_line(s, 9), 0, 5);
+ assert_eq!(v.offset, before);
+ }
+
+ #[test]
+ fn mjb_llr_097_top_line_clamps_into_buffer() {
+ let t = doc(10);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 9_999);
+ assert!(v.top_line(s) < t.len_lines(LINE_TYPE));
+ }
+
+ #[test]
+ fn mjb_llr_094_scroll_near_start_saturates_at_zero() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 2);
+ v.ensure_cursor_in_view(s, at_line(s, 0), 20, 5);
+ assert_eq!(v.top_line(s), 0, "must not underflow below line zero");
+ }
+
+ #[test]
+ fn mjb_llr_099_horizontal_scroll_follows_cursor() {
+ let t = Rope::from_str(&format!("{}\n", "x".repeat(200)));
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.ensure_horizontal_in_view(s, Range::point(150), 80);
+ assert_eq!(v.offset.horizontal_offset, 150 + 1 - 80);
+
+ v.ensure_horizontal_in_view(s, Range::point(10), 80);
+ assert_eq!(v.offset.horizontal_offset, 10, "scrolls back left");
+ }
+
+ #[test]
+ fn mjb_llr_100_half_page_moves_cursor_and_view() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 0);
+ let moved = v.page(s, at_line(s, 0), 10, true);
+ assert_eq!(moved.cursor_line(s), 10, "cursor moved");
+ assert_eq!(v.top_line(s), 10, "and the viewport moved with it");
+ }
+
+ #[test]
+ fn mjb_llr_101_full_page_moves_by_height() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 0);
+ let moved = v.page(s, at_line(s, 0), 20, true);
+ assert_eq!(moved.cursor_line(s), 20);
+ assert_eq!(v.top_line(s), 20);
+ }
+
+ #[test]
+ fn mjb_llr_102_paging_saturates_at_both_ends() {
+ let t = doc(10);
+ let s = t.slice(..);
+ let mut v = View::new();
+
+ let up = v.page(s, at_line(s, 0), 50, false);
+ assert_eq!(up.cursor_line(s), 0, "must not underflow");
+ assert_eq!(v.top_line(s), 0);
+
+ let down = v.page(s, at_line(s, 0), 500, true);
+ assert!(
+ down.cursor_line(s) <= last_content_line(s),
+ "must not run past the last content line"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_092_empty_buffer_renders_safely() {
+ let t = Rope::from_str("");
+ let s = t.slice(..);
+ let v = View::new();
+ assert_eq!(v.top_line(s), 0);
+ let _ = v.visible_lines(s, 10).count();
+ }
+}