//! 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, 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) -> 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 { 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 { 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 { 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 { 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()); } }