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