Skip to main content

lattice_core/
document.rs

1//! Document: a buffer plus the metadata needed for the rest of the editor.
2//!
3//! Phase 0 only wires the editing-relevant fields (buffer, version, undo
4//! stack, selections, optional path). The full §5.1 metadata set --
5//! `language`, `syntax`, `diagnostics`, `rendering_profile`, `encoding`,
6//! `line_ending` -- is added when each subsystem comes online. Major /
7//! minor modes (the `mode-architecture.md` mode system) live on `modes`,
8//! starting empty and populated by the `lattice_mode::ModeRegistry`
9//! when M.3 lands the per-buffer-kind major modes.
10
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use std::sync::atomic::{AtomicU64, Ordering};
14
15use lattice_protocol::edit::{Edit, EditKind};
16use lattice_protocol::ids::DocumentId;
17use lattice_protocol::position::Range;
18use lattice_protocol::selection::{Selection, SelectionSet};
19
20use crate::buffer::{AppliedEdit, Buffer, transform_position};
21use crate::error::{CoreError, CoreResult};
22use crate::undo::{UndoEntry, UndoStack};
23
24/// An editable document: a [`Buffer`] plus the bookkeeping every edit needs
25/// — a process-unique [`DocumentId`], an optional file path, version
26/// counters, the [`SelectionSet`], the [`UndoStack`] and dirty tracking.
27///
28/// All text mutation goes through [`Document::apply_edit`] /
29/// [`Document::apply_edit_batch`] (or [`Document::undo`] /
30/// [`Document::redo`]); each one records its inverse for undo, carries the
31/// selections across the change, and bumps both [`Document::version`] and
32/// [`Document::text_version`]. Positions are the protocol's
33/// `(line, byte-within-line)` pairs, both 0-based — see [`Buffer`].
34///
35/// A `Document` has no interior mutability and no locking; the editor keeps
36/// it behind a single writer (the core actor). It knows nothing about
37/// syntax, modes, LSP or rendering.
38///
39/// # Examples
40///
41/// Edit, undo, redo and dirty tracking:
42///
43/// ```
44/// use lattice_core::Document;
45/// use lattice_core::protocol::edit::Edit;
46/// use lattice_core::protocol::position::Position;
47///
48/// # fn main() -> lattice_core::CoreResult<()> {
49/// let mut doc = Document::from_text("hello");
50/// assert!(!doc.dirty());
51///
52/// doc.apply_edit(Edit::insert(Position::new(0, 5), " world"))?;
53/// assert_eq!(doc.text(), "hello world");
54/// assert!(doc.dirty());
55///
56/// doc.undo()?;
57/// assert_eq!(doc.text(), "hello");
58/// assert!(!doc.dirty()); // back at the loaded state
59///
60/// doc.redo()?;
61/// assert_eq!(doc.text(), "hello world");
62/// # Ok(())
63/// # }
64/// ```
65///
66/// Coalescing a whole insert session into one undo step:
67///
68/// ```
69/// use lattice_core::Document;
70/// use lattice_core::protocol::edit::Edit;
71/// use lattice_core::protocol::position::Position;
72///
73/// # fn main() -> lattice_core::CoreResult<()> {
74/// let mut doc = Document::empty();
75/// doc.begin_undo_group(); // `i`
76/// doc.apply_edit(Edit::insert(Position::new(0, 0), "a"))?;
77/// doc.apply_edit(Edit::insert(Position::new(0, 1), "b"))?;
78/// doc.apply_edit(Edit::insert(Position::new(0, 2), "c"))?;
79/// doc.end_undo_group(); // `<Esc>`
80///
81/// doc.undo()?; // one `u` removes the whole session
82/// assert_eq!(doc.text(), "");
83/// # Ok(())
84/// # }
85/// ```
86#[derive(Debug)]
87pub struct Document {
88    id: DocumentId,
89    /// `Arc<PathBuf>` so every reader that needs to *carry* the path — a
90    /// published `DocumentSnapshot`, a grammar dispatch context (OM.6b) —
91    /// takes an Arc bump rather than a heap allocation. Motions dispatch on
92    /// every `j`, so a `PathBuf` clone there would be an allocation on the
93    /// keystroke path bought for one plugin feature (paramount #1).
94    /// `path()` still hands out `Option<&Path>`, so callers are unaffected.
95    path: Option<Arc<PathBuf>>,
96    buffer: Buffer,
97    version: u64,
98    /// Bumps only on text-mutating operations (edits, undo, redo). Selection
99    /// changes do not bump this. Used by callers (e.g., the syntax cache)
100    /// to decide whether to reparse.
101    text_version: u64,
102    selections: SelectionSet,
103    undo: UndoStack,
104    /// Undo-stack depth at which the buffer last matched its on-disk state
105    /// (or its initial-load state, for a fresh `open` / `from_text`). The
106    /// document is dirty iff the current `undo.undo_depth()` differs from
107    /// this. `None` means no clean state is reachable -- typically because
108    /// an `apply_edit` cleared a redo entry that contained the saved state,
109    /// so we can no longer undo back to disk parity.
110    clean_position: Option<usize>,
111    /// Undo-coalescing group state. While `coalescing` is true, an
112    /// `apply_edit` / `apply_edit_batch` folds into the most recent undo
113    /// entry (via [`UndoStack::amend_top`]) instead of pushing a fresh
114    /// one, so a whole vim insert session (`i` .. `<Esc>`, backspaces
115    /// included) is a single undo unit. `group_has_entry` tracks whether
116    /// the open group has already pushed its initial entry: the first
117    /// edit in the group `push`es (creating the entry to fold into), and
118    /// every edit after it amends. Both reset on
119    /// [`Self::begin_undo_group`] / [`Self::end_undo_group`].
120    coalescing: bool,
121    group_has_entry: bool,
122    // M.4 follow-up: `modes: ActiveModes` removed. The canonical
123    // active-modes map is `App.active_modes: HashMap<BufferId,
124    // ActiveModes>` (M.2.1) -- per-buffer, not per-document, and
125    // lives on the App because mode resolution requires
126    // `lattice-config` access. Removing the field here breaks the
127    // `lattice-core -> lattice-mode` dep, which lets `lattice-mode`
128    // gain a dep on `lattice-config` for typed-option contributions
129    // without forming a cycle. See `docs/dev/architecture/mode-architecture.md`.
130}
131
132/// Builder for a [`Document`] with a path and/or initial content.
133///
134/// Content precedence: a buffer from [`Self::with_buffer`] wins over text
135/// from [`Self::with_text`]; with neither, the document is empty. Nothing
136/// here touches the filesystem — [`Document::open`] reads, the builder only
137/// assembles. Every built document gets a fresh [`DocumentId`], starts at
138/// version 0, and is clean.
139///
140/// # Examples
141///
142/// ```
143/// use lattice_core::DocumentBuilder;
144///
145/// let doc = DocumentBuilder::default()
146///     .with_path("/tmp/notes.txt")
147///     .with_text("draft\n")
148///     .build();
149/// assert_eq!(doc.text(), "draft\n");
150/// assert_eq!(doc.path().and_then(|p| p.to_str()), Some("/tmp/notes.txt"));
151/// assert!(!doc.dirty());
152/// ```
153#[derive(Debug, Default)]
154pub struct DocumentBuilder {
155    path: Option<Arc<PathBuf>>,
156    initial_text: Option<String>,
157    /// K.4.11.perf-fix (2026-06-02): pre-built Buffer for callers
158    /// that already hold a Rope and want to skip the
159    /// `String → Buffer::from_text` round-trip. Wins over
160    /// `initial_text` when both are set (no caller sets both;
161    /// the precedence is documented for clarity).
162    prebuilt_buffer: Option<Buffer>,
163}
164
165impl DocumentBuilder {
166    /// Set the file path the document will report and [`Document::save`]
167    /// will write to. Not checked for existence.
168    pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
169        self.path = Some(Arc::new(path.into()));
170        self
171    }
172
173    /// Set the initial text. Ignored if [`Self::with_buffer`] is also set.
174    pub fn with_text(mut self, text: impl Into<String>) -> Self {
175        self.initial_text = Some(text.into());
176        self
177    }
178
179    /// Build the Document around a
180    /// pre-existing Buffer instead of going through a String.
181    /// Used by callers that already hold a Buffer (typically from
182    /// `DocumentSnapshot.buffer.clone()` — `Buffer::clone` is
183    /// `Rope::clone` which is Arc-backed and O(1)) and want to
184    /// avoid the `Buffer → as_string() → from_text(&str)` round-
185    /// trip. K.4.11's `MultibufferDocumentHandle::dispatch_with_cancel`
186    /// is the primary consumer: every multibuffer keystroke ran
187    /// the round-trip, allocating O(composed_size) bytes on the
188    /// App thread per motion. After this fix the per-keystroke
189    /// cost is one Arc bump.
190    ///
191    /// Slice: K.4.11.perf-fix (2026-06-02).
192    pub fn with_buffer(mut self, buffer: Buffer) -> Self {
193        self.prebuilt_buffer = Some(buffer);
194        self
195    }
196
197    /// Build the document: fresh [`DocumentId`], version 0, a single cursor
198    /// at the origin, an empty undo stack, and clean (the initial content
199    /// counts as the saved state).
200    pub fn build(self) -> Document {
201        let buffer = match (self.prebuilt_buffer, self.initial_text) {
202            // K.4.11.perf-fix: prebuilt buffer wins. Callers go
203            // through `with_buffer` when they already have a Rope
204            // and want to skip the String round-trip.
205            (Some(b), _) => b,
206            (None, Some(t)) => Buffer::from_text(&t),
207            (None, None) => Buffer::empty(),
208        };
209        Document {
210            id: next_document_id(),
211            path: self.path,
212            buffer,
213            version: 0,
214            text_version: 0,
215            selections: SelectionSet::default(),
216            undo: UndoStack::new(),
217            // A freshly built document is "clean" at undo depth 0 -- the
218            // initial buffer (whether empty, from_text, or just-loaded
219            // from disk) is by definition the saved state.
220            clean_position: Some(0),
221            // No undo group open at construction; the first edit pushes.
222            coalescing: false,
223            group_has_entry: false,
224        }
225    }
226}
227
228fn next_document_id() -> DocumentId {
229    static NEXT: AtomicU64 = AtomicU64::new(1);
230    DocumentId::new(NEXT.fetch_add(1, Ordering::Relaxed))
231}
232
233impl Document {
234    /// An empty, pathless, clean document.
235    pub fn empty() -> Self {
236        DocumentBuilder::default().build()
237    }
238
239    /// A pathless document holding `text`. It starts clean: `text` is
240    /// treated as the saved state.
241    pub fn from_text(text: impl Into<String>) -> Self {
242        DocumentBuilder::default().with_text(text).build()
243    }
244
245    /// Construct a Document around
246    /// a pre-built Buffer. Sister of [`Self::from_text`] for
247    /// callers that already hold a Rope-backed Buffer and want
248    /// to avoid the `as_string() → from_text(&str)` round-trip.
249    /// See [`DocumentBuilder::with_buffer`] for the rationale +
250    /// the K.4.11 consumer.
251    ///
252    /// Slice: K.4.11.perf-fix (2026-06-02).
253    pub fn from_buffer(buffer: Buffer) -> Self {
254        DocumentBuilder::default().with_buffer(buffer).build()
255    }
256
257    /// Read `path` from disk into a new clean document whose path is set.
258    ///
259    /// Blocking I/O — never call it on the UI thread.
260    ///
261    /// # Errors
262    ///
263    /// [`CoreError::Io`] if the file cannot be read, including when it is
264    /// not valid UTF-8. A missing file is an error here; use
265    /// [`Self::open_or_new`] for vim's `:e newfile` behaviour.
266    pub fn open(path: impl AsRef<Path>) -> CoreResult<Self> {
267        let path = path.as_ref().to_path_buf();
268        let text = std::fs::read_to_string(&path)?;
269        Ok(DocumentBuilder::default()
270            .with_path(path)
271            .with_text(text)
272            .build())
273    }
274
275    /// Open `path`, or — when nothing is there yet — start an empty,
276    /// unmodified document that will create it on the first save.
277    ///
278    /// vim's `:e newfile` (checked in 9.2: an empty buffer, `&modified` 0, and
279    /// `:w` creates the file). Only `NotFound` becomes a new document; any
280    /// other read failure — permissions, a non-UTF-8 file — is still an
281    /// error, because opening an empty buffer over an unreadable file would
282    /// let the first save overwrite it.
283    ///
284    /// Returns whether the document is new, so a caller can tell a fresh file
285    /// from an existing one without a second `stat`.
286    pub fn open_or_new(path: impl AsRef<Path>) -> CoreResult<(Self, bool)> {
287        let path = path.as_ref();
288        match Self::open(path) {
289            Ok(doc) => Ok((doc, false)),
290            Err(crate::CoreError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok((
291                DocumentBuilder::default()
292                    .with_path(path.to_path_buf())
293                    .build(),
294                true,
295            )),
296            Err(e) => Err(e),
297        }
298    }
299
300    /// The process-unique id assigned at construction. Never reused within
301    /// a process; not stable across restarts.
302    pub fn id(&self) -> DocumentId {
303        self.id
304    }
305
306    /// The file this document is backed by, or `None` for a scratch /
307    /// synthetic document that has never been saved.
308    pub fn path(&self) -> Option<&Path> {
309        self.path.as_deref().map(PathBuf::as_path)
310    }
311
312    /// Adopt a path that is already shared. Does NOT touch the filesystem —
313    /// [`Document::save_as`] is the one that writes.
314    ///
315    /// For a throwaway document assembled around an existing buffer, where the
316    /// path came from the snapshot that buffer was cloned from (the host's
317    /// plugin-action gate, OM.6b). An Arc bump, so it costs the same as not
318    /// carrying the path at all.
319    pub fn set_path_shared(&mut self, path: Arc<PathBuf>) {
320        self.path = Some(path);
321    }
322
323    /// The path as a shared handle, for readers that must *carry* it out of
324    /// the borrow — a published `DocumentSnapshot`, a grammar dispatch
325    /// context. An Arc bump, never an allocation. Prefer [`Document::path`]
326    /// when a borrow will do.
327    pub fn path_shared(&self) -> Option<Arc<PathBuf>> {
328        self.path.clone()
329    }
330
331    /// Monotonic change counter: bumps on every edit, batch, undo, redo
332    /// **and** [`Self::set_selections`]. Use it to detect "anything changed";
333    /// use [`Self::text_version`] when only text matters.
334    pub fn version(&self) -> u64 {
335        self.version
336    }
337
338    /// Monotonic counter that bumps only when the *text* changes (edits,
339    /// batches, undo, redo) — not on selection changes or saves. The syntax
340    /// cache keys reparses on it.
341    pub fn text_version(&self) -> u64 {
342        self.text_version
343    }
344
345    /// Whether the text differs from the last saved (or initially loaded)
346    /// state, as judged by undo depth.
347    ///
348    /// Undoing back to the saved state makes the document clean again.
349    /// Once an edit discards the redo entries that led back to the saved
350    /// state, no clean state is reachable and the document stays dirty
351    /// until the next [`Self::save`] / [`Self::save_as`].
352    pub fn dirty(&self) -> bool {
353        match self.clean_position {
354            Some(k) => k != self.undo.undo_depth(),
355            // No reachable clean state -- the saved depth was lost when
356            // an apply_edit cleared the redo stack. Document is dirty.
357            None => true,
358        }
359    }
360
361    /// The underlying text. Read-only: mutation must go through the
362    /// document so undo, versions and selections stay consistent.
363    pub fn buffer(&self) -> &Buffer {
364        &self.buffer
365    }
366
367    /// The current selections (a single cursor at the origin for a fresh
368    /// document). Edits transform them automatically.
369    pub fn selections(&self) -> &SelectionSet {
370        &self.selections
371    }
372
373    /// Replace the selections. Bumps [`Self::version`] but not
374    /// [`Self::text_version`]. Positions are not validated against the
375    /// buffer.
376    pub fn set_selections(&mut self, selections: SelectionSet) {
377        self.selections = selections;
378        self.version += 1;
379    }
380
381    /// The whole text as a `String` — an `O(n)` allocation; see
382    /// [`Buffer::as_string`] for the cheaper alternatives.
383    pub fn text(&self) -> String {
384        self.buffer.as_string()
385    }
386
387    /// Apply an edit, push its inverse onto the undo stack, bump the version.
388    /// Returns a structural description of what changed (suitable for
389    /// `Event::DocumentChanged`). Transforms selections across the edit
390    /// so the caret survives owner writes (§4 of owner-write-caret.md).
391    ///
392    /// Inside an open [`Self::begin_undo_group`] the inverse folds into the
393    /// group's entry instead of pushing a new one. Pushing a new entry
394    /// clears the redo stack.
395    ///
396    /// # Errors
397    ///
398    /// Those of [`Buffer::apply_edit`]; the document is unchanged on error.
399    pub fn apply_edit(&mut self, edit: Edit) -> CoreResult<AppliedEdit> {
400        let applied = self.buffer.apply_edit(&edit)?;
401        self.transform_selections(&applied);
402        let inverse = inverse_edit(&applied);
403        self.record_inverses(vec![inverse]);
404        self.version += 1;
405        self.text_version += 1;
406        Ok(applied)
407    }
408
409    /// Apply a batch of edits as a single undoable unit. Edits are applied in
410    /// order; undo reverts them all. Transforms selections across each edit
411    /// so the caret survives owner writes (§4 of owner-write-caret.md).
412    ///
413    /// Each edit's range is in the coordinates produced by the edits before
414    /// it, not the pre-batch text. Versions bump once for the whole batch.
415    ///
416    /// # Errors
417    ///
418    /// Those of [`Buffer::apply_edit`]. **Not atomic:** edits before the
419    /// failing one stay applied, and — because the undo entry is recorded
420    /// only after the loop — they are not on the undo stack and versions do
421    /// not bump. Callers must hand in a batch that is valid in sequence.
422    ///
423    /// # Examples
424    ///
425    /// ```
426    /// use lattice_core::Document;
427    /// use lattice_core::protocol::edit::Edit;
428    /// use lattice_core::protocol::position::{Position, Range};
429    ///
430    /// # fn main() -> lattice_core::CoreResult<()> {
431    /// let mut doc = Document::from_text("one two");
432    /// doc.apply_edit_batch(vec![
433    ///     // Delete "one " …
434    ///     Edit::delete(Range::new(Position::new(0, 0), Position::new(0, 4))),
435    ///     // … so "two" now starts at byte 0.
436    ///     Edit::insert(Position::new(0, 3), "!"),
437    /// ])?;
438    /// assert_eq!(doc.text(), "two!");
439    ///
440    /// doc.undo()?; // the batch is one undo unit
441    /// assert_eq!(doc.text(), "one two");
442    /// # Ok(())
443    /// # }
444    /// ```
445    pub fn apply_edit_batch(&mut self, edits: Vec<Edit>) -> CoreResult<Vec<AppliedEdit>> {
446        let mut applied_set = Vec::with_capacity(edits.len());
447        let mut inverses = Vec::with_capacity(edits.len());
448        for edit in edits {
449            let applied = self.buffer.apply_edit(&edit)?;
450            self.transform_selections(&applied);
451            inverses.push(inverse_edit(&applied));
452            applied_set.push(applied);
453        }
454        // Inverses replay in reverse order during undo.
455        inverses.reverse();
456        self.record_inverses(inverses);
457        self.version += 1;
458        self.text_version += 1;
459        Ok(applied_set)
460    }
461
462    /// Open an undo-coalescing group: every edit applied until
463    /// [`Self::end_undo_group`] folds into a single undo entry rather
464    /// than pushing its own. This is how a vim insert session
465    /// (`i`/`a`/`o`/`cw` .. `<Esc>`) becomes one `u` step -- typed
466    /// characters, in-session backspaces, and completion inserts all
467    /// collapse together. Re-opening an already-open group starts a
468    /// fresh coalescing run (the next edit pushes a new entry).
469    pub fn begin_undo_group(&mut self) {
470        self.coalescing = true;
471        self.group_has_entry = false;
472    }
473
474    /// Close the group opened by [`Self::begin_undo_group`]. Subsequent
475    /// edits push their own entries again. Idempotent when no group is
476    /// open.
477    pub fn end_undo_group(&mut self) {
478        self.coalescing = false;
479        self.group_has_entry = false;
480    }
481
482    /// Transform this document's selections across an applied edit.
483    /// Every selection's anchor and head are independently transformed
484    /// so the caret survives owner writes. Called from `apply_edit`
485    /// and `apply_edit_batch`.
486    fn transform_selections(&mut self, applied: &AppliedEdit) {
487        let all: Vec<Selection> = self
488            .selections
489            .all()
490            .iter()
491            .map(|sel| Selection {
492                anchor: transform_position(sel.anchor, applied),
493                head: transform_position(sel.head, applied),
494                visual: sel.visual,
495            })
496            .collect();
497        let primary = self.selections.primary_index();
498        self.selections = SelectionSet::from_parts(all, primary);
499    }
500
501    /// Route a just-applied operation's inverse edits onto the undo
502    /// stack. Outside a coalescing group (the common case) this pushes a
503    /// new entry -- one operation, one undo unit. Inside an open group,
504    /// the first operation pushes (creating the entry to fold into) and
505    /// every operation after it amends that entry, so the whole group is
506    /// a single undo unit. `inverses` arrive in stored order
507    /// (reverse-application), matching [`UndoStack::push`] /
508    /// [`UndoStack::amend_top`].
509    fn record_inverses(&mut self, inverses: Vec<Edit>) {
510        if self.coalescing && self.group_has_entry {
511            // Fold into the group's existing entry. Depth is unchanged
512            // and redo stays cleared (the group's first push cleared it),
513            // so clean-position tracking needs no adjustment here.
514            self.undo.amend_top(inverses);
515        } else {
516            let pre_push_depth = self.undo.undo_depth();
517            self.undo.push(UndoEntry {
518                inverse_edits: inverses,
519                label: String::new(),
520            });
521            self.invalidate_clean_if_lost(pre_push_depth);
522            if self.coalescing {
523                self.group_has_entry = true;
524            }
525        }
526    }
527
528    /// Revert the most recent undo entry and move it onto the redo stack.
529    /// Returns the edits as applied to the buffer, in application order, so
530    /// callers can emit change events. Bumps both versions.
531    ///
532    /// # Errors
533    ///
534    /// [`CoreError::NothingToUndo`] if the undo stack is empty.
535    pub fn undo(&mut self) -> CoreResult<Vec<AppliedEdit>> {
536        let entry = self.undo.pop_for_undo().ok_or(CoreError::NothingToUndo)?;
537        let mut applied = Vec::with_capacity(entry.inverse_edits.len());
538        let mut redo_inverses = Vec::with_capacity(entry.inverse_edits.len());
539        for edit in &entry.inverse_edits {
540            let a = self.buffer.apply_edit(edit)?;
541            self.transform_selections(&a);
542            redo_inverses.push(inverse_edit(&a));
543            applied.push(a);
544        }
545        redo_inverses.reverse();
546        self.undo.record_redo(UndoEntry {
547            inverse_edits: redo_inverses,
548            label: entry.label,
549        });
550        self.version += 1;
551        self.text_version += 1;
552        Ok(applied)
553    }
554
555    /// Re-apply the most recently undone entry and move it back onto the
556    /// undo stack. The mirror of [`Self::undo`].
557    ///
558    /// # Errors
559    ///
560    /// [`CoreError::NothingToRedo`] if nothing has been undone since the
561    /// last new edit.
562    pub fn redo(&mut self) -> CoreResult<Vec<AppliedEdit>> {
563        let entry = self.undo.pop_for_redo().ok_or(CoreError::NothingToRedo)?;
564        let mut applied = Vec::with_capacity(entry.inverse_edits.len());
565        let mut undo_inverses = Vec::with_capacity(entry.inverse_edits.len());
566        for edit in &entry.inverse_edits {
567            let a = self.buffer.apply_edit(edit)?;
568            self.transform_selections(&a);
569            undo_inverses.push(inverse_edit(&a));
570            applied.push(a);
571        }
572        undo_inverses.reverse();
573        self.undo.record_undo(UndoEntry {
574            inverse_edits: undo_inverses,
575            label: entry.label,
576        });
577        self.version += 1;
578        self.text_version += 1;
579        Ok(applied)
580    }
581
582    /// Persist to the document's path. Errors if no path is set.
583    ///
584    /// Writes the text verbatim (blocking I/O) and marks the current undo
585    /// depth as clean. Returns the path written.
586    ///
587    /// # Errors
588    ///
589    /// [`CoreError::NoPath`] for a pathless document; [`CoreError::Io`] if
590    /// the write fails (the document stays dirty).
591    pub fn save(&mut self) -> CoreResult<&Path> {
592        let path = self.path.clone().ok_or(CoreError::NoPath)?;
593        std::fs::write(path.as_path(), self.buffer.as_string())?;
594        self.clean_position = Some(self.undo.undo_depth());
595        // path is set; dereference safely via the stored option.
596        Ok(self
597            .path
598            .as_deref()
599            .map(PathBuf::as_path)
600            .expect("path set above"))
601    }
602
603    /// Write the text to `path`, then adopt `path` as the document's path
604    /// and mark it clean. On a write error neither the path nor the clean
605    /// state changes.
606    ///
607    /// # Errors
608    ///
609    /// [`CoreError::Io`] if the write fails.
610    pub fn save_as(&mut self, path: impl Into<PathBuf>) -> CoreResult<()> {
611        let path = path.into();
612        std::fs::write(&path, self.buffer.as_string())?;
613        self.path = Some(Arc::new(path));
614        self.clean_position = Some(self.undo.undo_depth());
615        Ok(())
616    }
617
618    /// Called after an `apply_edit` that just pushed a new undo entry
619    /// (and cleared the redo stack as a side effect). If the saved-clean
620    /// depth lived past `pre_push_depth`, it was reachable only via the
621    /// just-cleared redo entries, so we drop it -- the document can no
622    /// longer reach disk-parity through undo/redo alone.
623    fn invalidate_clean_if_lost(&mut self, pre_push_depth: usize) {
624        if let Some(k) = self.clean_position
625            && k > pre_push_depth
626        {
627            self.clean_position = None;
628        }
629    }
630}
631
632fn inverse_edit(applied: &AppliedEdit) -> Edit {
633    Edit {
634        range: applied.inserted_range,
635        kind: EditKind::Replace {
636            text: applied.replaced_text.clone(),
637        },
638    }
639}
640
641#[allow(dead_code)]
642fn _empty_range_at_origin() -> Range {
643    Range::new(
644        lattice_protocol::position::Position::ZERO,
645        lattice_protocol::position::Position::ZERO,
646    )
647}
648
649#[cfg(test)]
650mod tests {
651    #![allow(clippy::unwrap_used, clippy::panic)]
652    use super::*;
653    use lattice_protocol::position::Position;
654    use lattice_protocol::selection::Selection;
655
656    #[test]
657    fn empty_document_has_empty_buffer_and_no_path() {
658        let d = Document::empty();
659        assert_eq!(d.text(), "");
660        assert!(d.path().is_none());
661        assert_eq!(d.version(), 0);
662        assert!(!d.dirty());
663    }
664
665    #[test]
666    fn from_text_preserves_initial_content() {
667        let d = Document::from_text("hi\n");
668        assert_eq!(d.text(), "hi\n");
669        assert_eq!(d.version(), 0);
670        assert!(!d.dirty());
671    }
672
673    #[test]
674    fn each_document_has_a_unique_id() {
675        let a = Document::empty();
676        let b = Document::empty();
677        assert_ne!(a.id(), b.id());
678    }
679
680    #[test]
681    fn apply_edit_increments_version_and_marks_dirty() {
682        let mut d = Document::empty();
683        let v0 = d.version();
684        d.apply_edit(Edit::insert(Position::ZERO, "x")).unwrap();
685        assert_eq!(d.text(), "x");
686        assert!(d.dirty());
687        assert_eq!(d.version(), v0 + 1);
688    }
689
690    #[test]
691    fn apply_edit_returns_applied_metadata() {
692        let mut d = Document::from_text("abc");
693        let r = Range::new(Position::new(0, 1), Position::new(0, 2));
694        let applied = d.apply_edit(Edit::replace(r, "X")).unwrap();
695        assert_eq!(applied.replaced_text, "b");
696        assert_eq!(applied.inserted_range.start, Position::new(0, 1));
697        assert_eq!(applied.inserted_range.end, Position::new(0, 2));
698        assert_eq!(d.text(), "aXc");
699    }
700
701    #[test]
702    fn apply_edit_batch_applies_in_order_and_is_one_undo_unit() {
703        let mut d = Document::empty();
704        let edits = vec![
705            Edit::insert(Position::ZERO, "a"),
706            Edit::insert(Position::new(0, 1), "b"),
707            Edit::insert(Position::new(0, 2), "c"),
708        ];
709        let applied = d.apply_edit_batch(edits).unwrap();
710        assert_eq!(applied.len(), 3);
711        assert_eq!(d.text(), "abc");
712        // One batched edit is one undo step.
713        d.undo().unwrap();
714        assert_eq!(d.text(), "");
715    }
716
717    #[test]
718    fn undo_reverts_last_edit() {
719        let mut d = Document::from_text("hello");
720        let r = Range::new(Position::new(0, 0), Position::new(0, 5));
721        d.apply_edit(Edit::replace(r, "world")).unwrap();
722        assert_eq!(d.text(), "world");
723        d.undo().unwrap();
724        assert_eq!(d.text(), "hello");
725    }
726
727    #[test]
728    fn redo_replays_undone_edit() {
729        let mut d = Document::from_text("hello");
730        d.apply_edit(Edit::insert(Position::new(0, 5), "!"))
731            .unwrap();
732        assert_eq!(d.text(), "hello!");
733        d.undo().unwrap();
734        assert_eq!(d.text(), "hello");
735        d.redo().unwrap();
736        assert_eq!(d.text(), "hello!");
737    }
738
739    #[test]
740    fn undo_without_history_is_an_error() {
741        let mut d = Document::empty();
742        assert!(matches!(d.undo(), Err(CoreError::NothingToUndo)));
743    }
744
745    #[test]
746    fn redo_without_history_is_an_error() {
747        let mut d = Document::empty();
748        assert!(matches!(d.redo(), Err(CoreError::NothingToRedo)));
749    }
750
751    #[test]
752    fn applying_a_new_edit_clears_pending_redo() {
753        let mut d = Document::from_text("a");
754        d.apply_edit(Edit::insert(Position::new(0, 1), "b"))
755            .unwrap();
756        d.undo().unwrap();
757        assert_eq!(d.text(), "a");
758        // Diverge: instead of redoing, apply a different edit.
759        d.apply_edit(Edit::insert(Position::new(0, 1), "c"))
760            .unwrap();
761        assert_eq!(d.text(), "ac");
762        // The previous redo path is gone.
763        assert!(matches!(d.redo(), Err(CoreError::NothingToRedo)));
764    }
765
766    #[test]
767    fn multiple_undo_walks_back_to_origin() {
768        let mut d = Document::empty();
769        d.apply_edit(Edit::insert(Position::ZERO, "a")).unwrap();
770        d.apply_edit(Edit::insert(Position::new(0, 1), "b"))
771            .unwrap();
772        d.apply_edit(Edit::insert(Position::new(0, 2), "c"))
773            .unwrap();
774        assert_eq!(d.text(), "abc");
775        d.undo().unwrap();
776        d.undo().unwrap();
777        d.undo().unwrap();
778        assert_eq!(d.text(), "");
779    }
780
781    #[test]
782    fn batch_undo_inverses_replay_in_reverse_order() {
783        // Verifies that an edit batch which mutates overlapping regions can be
784        // undone correctly because inverses replay in reverse.
785        let mut d = Document::from_text("xxxxx");
786        let r1 = Range::new(Position::new(0, 0), Position::new(0, 2));
787        let r2 = Range::new(Position::new(0, 0), Position::new(0, 2));
788        d.apply_edit_batch(vec![Edit::replace(r1, "AB"), Edit::replace(r2, "CD")])
789            .unwrap();
790        assert_eq!(d.text(), "CDxxx");
791        d.undo().unwrap();
792        assert_eq!(d.text(), "xxxxx");
793    }
794
795    #[test]
796    fn undo_group_collapses_edits_into_one_unit() {
797        // The reported bug: per-character inserts must undo as one batch.
798        let mut d = Document::empty();
799        d.begin_undo_group();
800        for (i, ch) in "hello".chars().enumerate() {
801            d.apply_edit(Edit::insert(Position::new(0, i as u32), ch.to_string()))
802                .unwrap();
803        }
804        d.end_undo_group();
805        assert_eq!(d.text(), "hello");
806        // A single undo reverts the whole session, not one char.
807        d.undo().unwrap();
808        assert_eq!(d.text(), "");
809        // And a single redo replays it whole.
810        d.redo().unwrap();
811        assert_eq!(d.text(), "hello");
812    }
813
814    #[test]
815    fn edits_outside_a_group_stay_separate_units() {
816        // Guard against over-coalescing: normal-mode edits are individual.
817        let mut d = Document::empty();
818        d.apply_edit(Edit::insert(Position::ZERO, "a")).unwrap();
819        d.apply_edit(Edit::insert(Position::new(0, 1), "b"))
820            .unwrap();
821        d.undo().unwrap();
822        assert_eq!(d.text(), "a", "only the last edit reverts");
823    }
824
825    #[test]
826    fn two_groups_are_two_undo_units() {
827        // Two insert sessions with no intervening edit must NOT merge.
828        let mut d = Document::empty();
829        d.begin_undo_group();
830        d.apply_edit(Edit::insert(Position::ZERO, "ab")).unwrap();
831        d.apply_edit(Edit::insert(Position::new(0, 2), "cd"))
832            .unwrap();
833        d.end_undo_group();
834        d.begin_undo_group();
835        d.apply_edit(Edit::insert(Position::new(0, 4), "ef"))
836            .unwrap();
837        d.apply_edit(Edit::insert(Position::new(0, 6), "gh"))
838            .unwrap();
839        d.end_undo_group();
840        assert_eq!(d.text(), "abcdefgh");
841        d.undo().unwrap();
842        assert_eq!(d.text(), "abcd", "second group reverts as one unit");
843        d.undo().unwrap();
844        assert_eq!(d.text(), "", "first group reverts as one unit");
845    }
846
847    #[test]
848    fn group_coalesces_inserts_and_in_session_deletes() {
849        // Backspaces typed during an insert session are part of the same
850        // undo unit (vim). Mixes push + amend across edit kinds.
851        let mut d = Document::empty();
852        d.begin_undo_group();
853        d.apply_edit(Edit::insert(Position::ZERO, "a")).unwrap();
854        d.apply_edit(Edit::insert(Position::new(0, 1), "b"))
855            .unwrap();
856        d.apply_edit(Edit::insert(Position::new(0, 2), "c"))
857            .unwrap();
858        // Backspace the 'c'.
859        d.apply_edit(Edit::replace(
860            Range::new(Position::new(0, 2), Position::new(0, 3)),
861            "",
862        ))
863        .unwrap();
864        d.end_undo_group();
865        assert_eq!(d.text(), "ab");
866        d.undo().unwrap();
867        assert_eq!(d.text(), "", "insert+delete session reverts as one unit");
868    }
869
870    #[test]
871    fn group_coalescing_preserves_dirty_tracking() {
872        let mut d = Document::empty();
873        assert!(!d.dirty());
874        d.begin_undo_group();
875        d.apply_edit(Edit::insert(Position::ZERO, "x")).unwrap();
876        d.apply_edit(Edit::insert(Position::new(0, 1), "y"))
877            .unwrap();
878        d.end_undo_group();
879        assert!(d.dirty());
880        d.undo().unwrap();
881        assert!(!d.dirty(), "undo of the whole group returns to clean");
882    }
883
884    #[test]
885    fn empty_group_pushes_nothing() {
886        // Entering and leaving insert without typing adds no undo history.
887        let mut d = Document::empty();
888        d.begin_undo_group();
889        d.end_undo_group();
890        assert!(matches!(d.undo(), Err(CoreError::NothingToUndo)));
891    }
892
893    #[test]
894    fn batch_inside_a_group_folds_into_the_session() {
895        // A batched edit (e.g. completion accept) mid-session joins the
896        // insert unit rather than splitting it.
897        let mut d = Document::empty();
898        d.begin_undo_group();
899        d.apply_edit(Edit::insert(Position::ZERO, "a")).unwrap();
900        d.apply_edit_batch(vec![
901            Edit::insert(Position::new(0, 1), "b"),
902            Edit::insert(Position::new(0, 2), "c"),
903        ])
904        .unwrap();
905        d.apply_edit(Edit::insert(Position::new(0, 3), "d"))
906            .unwrap();
907        d.end_undo_group();
908        assert_eq!(d.text(), "abcd");
909        d.undo().unwrap();
910        assert_eq!(d.text(), "", "single + batch edits collapse together");
911    }
912
913    #[test]
914    fn set_selections_replaces_and_bumps_version() {
915        let mut d = Document::empty();
916        let v0 = d.version();
917        let new_set = SelectionSet::single(Selection::cursor(Position::new(0, 4)));
918        d.set_selections(new_set.clone());
919        assert_eq!(d.selections(), &new_set);
920        assert!(d.version() > v0);
921    }
922
923    #[test]
924    fn text_version_bumps_on_text_mutations_only() {
925        let mut d = Document::from_text("hello");
926        let tv0 = d.text_version();
927        let v0 = d.version();
928
929        // Selection change bumps version but NOT text_version.
930        d.set_selections(SelectionSet::single(Selection::cursor(Position::new(0, 2))));
931        assert!(d.version() > v0);
932        assert_eq!(d.text_version(), tv0);
933
934        // Edit bumps both.
935        d.apply_edit(Edit::insert(Position::new(0, 5), "!"))
936            .unwrap();
937        assert!(d.text_version() > tv0);
938
939        // Undo also bumps text_version.
940        let tv_after_edit = d.text_version();
941        d.undo().unwrap();
942        assert!(d.text_version() > tv_after_edit);
943
944        // Redo also.
945        let tv_after_undo = d.text_version();
946        d.redo().unwrap();
947        assert!(d.text_version() > tv_after_undo);
948    }
949
950    #[test]
951    fn save_without_path_errors() {
952        let mut d = Document::empty();
953        d.apply_edit(Edit::insert(Position::ZERO, "x")).unwrap();
954        assert!(matches!(d.save(), Err(CoreError::NoPath)));
955    }
956
957    #[test]
958    fn save_as_writes_file_and_clears_dirty() {
959        let dir = tempdir();
960        let path = dir.join("a.txt");
961        let mut d = Document::from_text("alpha");
962        // dirty starts false; making an edit sets it.
963        d.apply_edit(Edit::insert(Position::new(0, 5), "!"))
964            .unwrap();
965        assert!(d.dirty());
966        d.save_as(&path).unwrap();
967        assert!(!d.dirty());
968        assert_eq!(std::fs::read_to_string(&path).unwrap(), "alpha!");
969        assert_eq!(d.path(), Some(path.as_path()));
970        cleanup(&dir);
971    }
972
973    #[test]
974    fn save_after_save_as_writes_to_remembered_path() {
975        let dir = tempdir();
976        let path = dir.join("b.txt");
977        let mut d = Document::from_text("first");
978        d.save_as(&path).unwrap();
979        d.apply_edit(Edit::insert(Position::new(0, 5), "!"))
980            .unwrap();
981        d.save().unwrap();
982        assert_eq!(std::fs::read_to_string(&path).unwrap(), "first!");
983        cleanup(&dir);
984    }
985
986    #[test]
987    fn open_reads_file_and_remembers_path() {
988        let dir = tempdir();
989        let path = dir.join("c.txt");
990        std::fs::write(&path, "loaded").unwrap();
991        let d = Document::open(&path).unwrap();
992        assert_eq!(d.text(), "loaded");
993        assert_eq!(d.path(), Some(path.as_path()));
994        assert!(!d.dirty());
995        cleanup(&dir);
996    }
997
998    #[test]
999    fn open_or_new_starts_an_unmodified_empty_document_for_a_missing_file() {
1000        let dir = tempdir();
1001        let path = dir.join("fresh.org");
1002        let (d, new) = Document::open_or_new(&path).unwrap();
1003        assert!(new);
1004        assert_eq!(d.text(), "");
1005        assert_eq!(d.path(), Some(path.as_path()));
1006        assert!(!d.dirty(), "vim: `&modified` is 0 on a new file");
1007        assert!(!path.exists(), "opening creates nothing");
1008        cleanup(&dir);
1009    }
1010
1011    #[test]
1012    fn open_or_new_reads_an_existing_file() {
1013        let dir = tempdir();
1014        let path = dir.join("there.txt");
1015        std::fs::write(&path, "loaded").unwrap();
1016        let (d, new) = Document::open_or_new(&path).unwrap();
1017        assert!(!new);
1018        assert_eq!(d.text(), "loaded");
1019        cleanup(&dir);
1020    }
1021
1022    /// Only a missing file is new. An unreadable one stays an error, or its
1023    /// first save would overwrite it with an empty buffer.
1024    #[test]
1025    fn open_or_new_does_not_mask_other_read_errors() {
1026        let dir = tempdir();
1027        let path = dir.join("binary.bin");
1028        std::fs::write(&path, [0xff, 0xfe, 0x00]).unwrap();
1029        assert!(Document::open_or_new(&path).is_err());
1030        cleanup(&dir);
1031    }
1032
1033    #[test]
1034    fn open_missing_file_is_an_error() {
1035        let dir = tempdir();
1036        let path = dir.join("nope.txt");
1037        assert!(Document::open(&path).is_err());
1038        cleanup(&dir);
1039    }
1040
1041    #[test]
1042    fn undo_back_to_initial_state_clears_dirty() {
1043        let mut d = Document::from_text("hi");
1044        assert!(!d.dirty());
1045        d.apply_edit(Edit::insert(Position::new(0, 2), "!"))
1046            .unwrap();
1047        assert!(d.dirty());
1048        d.undo().unwrap();
1049        assert_eq!(d.text(), "hi");
1050        assert!(!d.dirty(), "undo back to initial should be clean");
1051    }
1052
1053    #[test]
1054    fn redoing_back_to_initial_state_keeps_dirty() {
1055        // Initial -> edit -> undo (clean) -> redo (dirty again).
1056        let mut d = Document::from_text("hi");
1057        d.apply_edit(Edit::insert(Position::new(0, 2), "!"))
1058            .unwrap();
1059        d.undo().unwrap();
1060        assert!(!d.dirty());
1061        d.redo().unwrap();
1062        assert!(d.dirty(), "redoing past clean point makes it dirty again");
1063    }
1064
1065    #[test]
1066    fn undo_back_to_saved_state_clears_dirty() {
1067        let dir = tempdir();
1068        let path = dir.join("u.txt");
1069        let mut d = Document::from_text("alpha");
1070        d.save_as(&path).unwrap();
1071        assert!(!d.dirty());
1072        d.apply_edit(Edit::insert(Position::new(0, 5), "!"))
1073            .unwrap();
1074        assert!(d.dirty());
1075        d.undo().unwrap();
1076        assert_eq!(d.text(), "alpha");
1077        assert!(!d.dirty(), "undo back to saved state should clear dirty");
1078        cleanup(&dir);
1079    }
1080
1081    #[test]
1082    fn save_after_edits_then_undo_back_to_save_clears_dirty() {
1083        // Common workflow: edit, edit, save, edit, undo. Should be clean
1084        // (the undo returned to saved state).
1085        let dir = tempdir();
1086        let path = dir.join("v.txt");
1087        let mut d = Document::from_text("a");
1088        d.apply_edit(Edit::insert(Position::new(0, 1), "b"))
1089            .unwrap();
1090        d.apply_edit(Edit::insert(Position::new(0, 2), "c"))
1091            .unwrap();
1092        d.save_as(&path).unwrap();
1093        assert!(!d.dirty());
1094        d.apply_edit(Edit::insert(Position::new(0, 3), "d"))
1095            .unwrap();
1096        assert!(d.dirty());
1097        d.undo().unwrap();
1098        assert_eq!(d.text(), "abc");
1099        assert!(!d.dirty());
1100        cleanup(&dir);
1101    }
1102
1103    #[test]
1104    fn new_edit_destroying_redo_path_to_clean_makes_clean_unreachable() {
1105        // Edit (depth=1, clean at 1), undo to depth=0 (dirty - clean was
1106        // at 1), apply new edit which clears the redo stack. The redo
1107        // entry that would have led back to clean is gone.
1108        let dir = tempdir();
1109        let path = dir.join("w.txt");
1110        let mut d = Document::from_text("x");
1111        d.apply_edit(Edit::insert(Position::new(0, 1), "y"))
1112            .unwrap();
1113        d.save_as(&path).unwrap();
1114        assert!(!d.dirty());
1115        d.undo().unwrap();
1116        assert_eq!(d.text(), "x");
1117        assert!(d.dirty());
1118        // Apply a new edit that clears the redo entry containing clean.
1119        d.apply_edit(Edit::insert(Position::new(0, 1), "z"))
1120            .unwrap();
1121        // Now we're at depth=1 again, but the buffer is "xz", not the
1122        // saved "xy". We can't reach clean by any undo/redo.
1123        assert!(d.dirty());
1124        d.undo().unwrap();
1125        assert!(d.dirty(), "previous saved state is no longer reachable");
1126        d.redo().unwrap();
1127        assert!(d.dirty());
1128    }
1129
1130    #[test]
1131    fn empty_document_starts_clean() {
1132        // Even an empty document is "clean" relative to its initial state.
1133        // This matches vim/emacs: an unmodified scratch buffer is not dirty.
1134        let d = Document::empty();
1135        assert!(!d.dirty());
1136    }
1137
1138    #[test]
1139    fn save_resets_clean_position_to_current_depth() {
1140        let dir = tempdir();
1141        let path = dir.join("s.txt");
1142        let mut d = Document::from_text("a");
1143        d.apply_edit(Edit::insert(Position::new(0, 1), "b"))
1144            .unwrap();
1145        d.save_as(&path).unwrap();
1146        // After save, dirty=false. Undo should now go past clean.
1147        d.undo().unwrap();
1148        assert!(d.dirty(), "undoing past saved state is dirty");
1149        d.redo().unwrap();
1150        assert!(!d.dirty(), "redoing back to saved state is clean");
1151        cleanup(&dir);
1152    }
1153
1154    fn tempdir() -> std::path::PathBuf {
1155        // Per-test unique directory under the OS temp area. The counter is
1156        // what makes it unique: parallel tests can read the same nanosecond,
1157        // share a directory, and one's `cleanup` deletes the other's files.
1158        static COUNTER: AtomicU64 = AtomicU64::new(0);
1159        let base = std::env::temp_dir();
1160        let id = std::time::SystemTime::now()
1161            .duration_since(std::time::UNIX_EPOCH)
1162            .map(|d| d.as_nanos())
1163            .unwrap_or(0);
1164        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1165        let dir = base.join(format!("lattice-core-test-{}-{id}-{n}", std::process::id()));
1166        std::fs::create_dir_all(&dir).unwrap();
1167        dir
1168    }
1169
1170    fn cleanup(dir: &std::path::Path) {
1171        let _ = std::fs::remove_dir_all(dir);
1172    }
1173}