diff options
| author | rottedfm <rottedfm@proton.me> | 2026-08-19 11:24:55 -0400 |
|---|---|---|
| committer | rottedfm <rottedfm@proton.me> | 2026-08-19 11:24:55 -0400 |
| commit | 8e16347b0eb329e84892af8ece36886324c95f62 (patch) | |
| tree | be1267972b5de2f1ae592577dfabce67f1fe6e87 /src/buffer/document.rs | |
| parent | c6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff) | |
| parent | ea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff) | |
Establishes the first working baseline: moji <file> opens a file into a
ropey rope and edits it with Helix selection-first modal editing, under a
DO-178C DAL-C requirements and traceability process.
Prior to this, main tracked four files and src/main.rs was still
println!("Hello, world!") — there was no buildable state to build on.
Verified on a fresh clone of the branch with no untracked files:
cargo build; clippy --all-targets -D warnings clean; 294 tests passing;
scripts/check-trace.sh reports 98/98 requirements traced in both
directions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/buffer/document.rs')
| -rw-r--r-- | src/buffer/document.rs | 525 |
1 files changed, 525 insertions, 0 deletions
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()); + } +} |
