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}