Skip to main content

lattice_runtime/
snapshot.rs

1//! `DocumentSnapshot` -- immutable view of a document at one
2//! committed point in time (DESIGN.md §5.6.8).
3//!
4//! Renderers read every frame through one `arc_swap::ArcSwap::load`
5//! per visible document. The returned `Arc<DocumentSnapshot>` lives
6//! for the entire frame; all subsequent text / metadata reads go
7//! through that `Arc` -- no actor round-trip, no lock contention.
8//!
9//! ## Snapshot fields
10//!
11//! v1 ships the subset the TUI renderer actually consumes. Future
12//! commits will fold in:
13//!
14//! - `selections: Arc<SelectionSet>` -- when multi-cursor lands
15//!   (today the App holds a single per-pane cursor).
16//! - `syntax: Option<Arc<SyntaxSnapshot>>` -- when the syntax cache
17//!   moves into the actor.
18//! - `decorations: Arc<DecorationLayer>` -- when the decoration
19//!   layer (§5.6.2) is built.
20//! - `layout: Option<Arc<LayoutCacheSnapshot>>` -- shaped buffers
21//!   only; not relevant for the TUI.
22//!
23//! All of these are `Arc`-cloned per snapshot. A snapshot's memory
24//! cost is independent of buffer size: ~6 `Arc` words plus the
25//! changed-fragment cost of the underlying immutable structures.
26//!
27//! ## Publish discipline
28//!
29//! The actor (and only the actor) writes to the published cell via
30//! `PublishedSnapshot::store`. The runtime layer keeps that method
31//! `pub(crate)` so external callers can't forge a snapshot.
32//! Renderers / readers use [`PublishedSnapshot::load`].
33
34use std::path::PathBuf;
35use std::sync::Arc;
36
37use arc_swap::ArcSwap;
38use lattice_core::{Buffer, Document};
39use lattice_protocol::ids::DocumentId;
40use lattice_protocol::selection::SelectionSet;
41
42/// One immutable snapshot of a document. Cheap to clone (every
43/// non-trivial field is already `Arc`-shareable internally;
44/// `Buffer` wraps a `ropey::Rope` whose interior is a B-tree of
45/// `Arc`s, so cloning is `O(log n)` with no heap allocation in the
46/// common case).
47#[derive(Debug, Clone, Default)]
48pub struct DocumentSnapshot {
49    pub id: DocumentId,
50    /// Bumps on any commit (edits, undo, redo, set_path, mark_clean).
51    pub version: u64,
52    /// Bumps only on text-mutating commits. Used by the syntax cache
53    /// to decide whether to reparse.
54    pub text_version: u64,
55    pub buffer: Buffer,
56    /// `Arc<PathBuf>` so cloning a snapshot doesn't clone the path
57    /// string. `None` means an unsaved buffer (`*scratch*`-style).
58    pub path: Option<Arc<PathBuf>>,
59    /// True iff the buffer differs from its on-disk state. Tracked
60    /// by the document's clean-position bookkeeping; the snapshot
61    /// just reflects the current value.
62    pub dirty: bool,
63    /// `Arc<SelectionSet>` so cloning a snapshot doesn't clone the
64    /// selection vector. v1 single-cursor: this is always one
65    /// charwise selection; multi-cursor support folds in here when
66    /// it lands.
67    pub selections: Arc<SelectionSet>,
68}
69
70impl DocumentSnapshot {
71    /// Bench-only constructor exposing [`Self::from_document`] to
72    /// criterion benches (which live outside the runtime crate).
73    /// Production callers must go through the actor's publish
74    /// discipline; this is `#[doc(hidden)]` so it doesn't show in
75    /// rustdoc and the name advertises the constraint.
76    #[doc(hidden)]
77    pub fn __bench_from_document(doc: &Document) -> Self {
78        Self::from_document(doc)
79    }
80
81    /// Build a snapshot from a `Document`. Called by the actor on
82    /// every commit. `pub(crate)` so external callers can't bypass
83    /// the actor's publish discipline.
84    pub(crate) fn from_document(doc: &Document) -> Self {
85        Self {
86            id: doc.id(),
87            version: doc.version(),
88            text_version: doc.text_version(),
89            buffer: doc.buffer().clone(),
90            // An Arc bump since OM.6b retyped `Document::path`; this allocated
91            // a fresh `PathBuf` per publish before, which is once per commit.
92            path: doc.path_shared(),
93            dirty: doc.dirty(),
94            selections: Arc::new(doc.selections().clone()),
95        }
96    }
97
98    /// Convenience: borrow the path slice if set.
99    pub fn path(&self) -> Option<&std::path::Path> {
100        self.path.as_deref().map(|p| p.as_path())
101    }
102
103    /// Convenience: render the buffer to a `String`. The renderer's
104    /// hot path should prefer [`Buffer::as_string`] on
105    /// `&self.buffer` directly; this is for tests / debug output.
106    pub fn text(&self) -> String {
107        self.buffer.as_string()
108    }
109}
110
111/// Single-writer multi-reader cell holding the current
112/// [`DocumentSnapshot`]. The actor stores; everyone else loads.
113///
114/// Backed by `arc_swap::ArcSwap`, an RCU-flavored primitive:
115/// `load` is wait-free (~2ns), `store` is a single atomic
116/// release-store; reclamation is by refcount drop on the last
117/// `Arc<DocumentSnapshot>` reader. Per DESIGN.md §5.6.8 these
118/// semantics are not fungible -- the renderer's correctness model
119/// depends on them.
120pub struct PublishedSnapshot {
121    cell: Arc<ArcSwap<DocumentSnapshot>>,
122}
123
124impl PublishedSnapshot {
125    /// M.2.b.1 (2026-05-31): promoted from `pub(crate)` so
126    /// external `Document` impls (`MultibufferDocumentHandle` in
127    /// `lattice-multibuffer`, future plugin-defined kinds) can
128    /// publish their own composed snapshots. The publish
129    /// **discipline** still applies — every impl is responsible
130    /// for "publish-after-mutate" ordering against its own
131    /// readers. The actor model in this crate enforces that
132    /// discipline through the message loop; multibuffer enforces
133    /// it through its own internal serialisation.
134    pub fn new(initial: DocumentSnapshot) -> Self {
135        Self {
136            cell: Arc::new(ArcSwap::from_pointee(initial)),
137        }
138    }
139
140    /// Borrowed handle to the shared `Arc<ArcSwap<...>>`. Used by
141    /// [`SnapshotCache::new`] to share the underlying cell with a
142    /// per-thread reader cache. Promoted from `pub(crate)` for
143    /// the same reason as [`Self::new`] — external impls need to
144    /// hand the cell to `SnapshotCache::new` for their own
145    /// `snapshot_cache()` trait method.
146    pub fn cell_arc(&self) -> Arc<ArcSwap<DocumentSnapshot>> {
147        self.cell.clone()
148    }
149
150    /// Wait-free read. Returns an `Arc<DocumentSnapshot>` that lives
151    /// as long as the caller needs it. Renderers call this once per
152    /// visible document per frame.
153    pub fn load(&self) -> Arc<DocumentSnapshot> {
154        self.cell.load_full()
155    }
156
157    /// Replace the published snapshot with `next`. Atomic; any
158    /// reader that observes the new pointer also observes all writes
159    /// the publisher ordered before the store. Promoted from
160    /// `pub(crate)` so external `Document` impls publish their
161    /// own snapshots; the actor model in this crate is no longer
162    /// the only writer.
163    pub fn store(&self, next: DocumentSnapshot) {
164        self.cell.store(Arc::new(next));
165    }
166
167    /// Bench-only `pub` wrapper around [`Self::store`]. See the
168    /// note on [`DocumentSnapshot::__bench_from_document`].
169    #[doc(hidden)]
170    pub fn __bench_store(&self, next: DocumentSnapshot) {
171        self.store(next);
172    }
173
174    /// Bench-only `pub` constructor matching [`Self::new`]. Lets
175    /// benches stand up a `PublishedSnapshot` outside the actor's
176    /// usual `spawn_document` path.
177    #[doc(hidden)]
178    pub fn __bench_new(initial: DocumentSnapshot) -> Self {
179        Self::new(initial)
180    }
181}
182
183/// Per-thread cached reader for a [`PublishedSnapshot`].
184///
185/// `arc_swap::Cache::load` is wait-free thread-local-cached: when
186/// the underlying ArcSwap pointer is unchanged, the load is one
187/// `Relaxed` atomic compare and returns the cached `Arc` reference
188/// at no further cost. When the pointer changes (post-publish),
189/// the next load reloads the new `Arc` and caches it.
190///
191/// **Per-thread state.** `Cache` is `Send` but `!Sync`. Each
192/// reader thread owns its own `SnapshotCache`; the App's renderer
193/// thread (the editor mainloop) is the canonical user. Multiple
194/// threads reading the same document each construct their own
195/// cache from clones of the underlying `Arc<PublishedSnapshot>`.
196///
197/// Backs the §5.6.8 commitment that the read path lives at the
198/// hardware floor (~2ns per load) when the writer hasn't changed
199/// the snapshot since the last frame -- the common case for a
200/// renderer reading multiple times per frame between edits.
201pub struct SnapshotCache {
202    cache: arc_swap::Cache<Arc<ArcSwap<DocumentSnapshot>>, Arc<DocumentSnapshot>>,
203}
204
205/// Placeholder cache for `Editor::default()` headless / test
206/// scaffolding. Backed by a fresh `ArcSwap<DocumentSnapshot>`
207/// cell carrying a default snapshot. Real construction goes
208/// through [`SnapshotCache::new`] from the document's
209/// `PublishedSnapshot::cell_arc()`; `Editor::new(...)`
210/// overwrites this before the first frame.
211impl Default for SnapshotCache {
212    fn default() -> Self {
213        let cell = Arc::new(ArcSwap::from_pointee(DocumentSnapshot::default()));
214        Self {
215            cache: arc_swap::Cache::new(cell),
216        }
217    }
218}
219
220impl SnapshotCache {
221    /// Build a fresh cache from a clone of the published-snapshot
222    /// cell. The first `load()` call pulls the current snapshot;
223    /// subsequent calls reuse the cached `Arc` until the writer
224    /// publishes something new.
225    pub fn new(cell: Arc<PublishedSnapshot>) -> Self {
226        Self {
227            cache: arc_swap::Cache::new(cell.cell_arc()),
228        }
229    }
230
231    /// Wait-free per-thread-cached load. Returns a reference to
232    /// the cached `Arc<DocumentSnapshot>`; clone if the caller
233    /// needs an owned `Arc`. **Cheaper than cloning** -- the
234    /// reference is valid for as long as the cache isn't loaded
235    /// again.
236    #[inline]
237    pub fn load(&mut self) -> &Arc<DocumentSnapshot> {
238        self.cache.load()
239    }
240
241    /// Owned-`Arc` variant for callers that genuinely need to
242    /// store the snapshot beyond the cache's borrow lifetime.
243    /// Costs one Arc bump on top of [`Self::load`]; use sparingly.
244    #[inline]
245    pub fn load_arc(&mut self) -> Arc<DocumentSnapshot> {
246        Arc::clone(self.cache.load())
247    }
248}
249
250impl std::fmt::Debug for SnapshotCache {
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        f.debug_struct("SnapshotCache").finish_non_exhaustive()
253    }
254}
255
256impl std::fmt::Debug for PublishedSnapshot {
257    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258        let snap = self.load();
259        f.debug_struct("PublishedSnapshot")
260            .field("version", &snap.version)
261            .field("text_version", &snap.text_version)
262            .field("id", &snap.id)
263            .finish()
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    #![allow(clippy::unwrap_used)]
270    use super::*;
271    use lattice_protocol::edit::Edit;
272    use lattice_protocol::position::Position;
273
274    #[test]
275    fn snapshot_from_fresh_document_carries_empty_text() {
276        let doc = Document::from_text("");
277        let snap = DocumentSnapshot::from_document(&doc);
278        assert_eq!(snap.text(), "");
279        assert!(!snap.dirty);
280        assert_eq!(snap.version, 0);
281    }
282
283    #[test]
284    fn snapshot_reflects_post_edit_state() {
285        let mut doc = Document::from_text("hello");
286        doc.apply_edit(Edit::insert(Position::new(0, 5), " world"))
287            .unwrap();
288        let snap = DocumentSnapshot::from_document(&doc);
289        assert_eq!(snap.text(), "hello world");
290        assert!(snap.dirty);
291        assert!(snap.version > 0);
292        assert!(snap.text_version > 0);
293    }
294
295    #[test]
296    fn published_snapshot_store_replaces_load() {
297        let doc = Document::from_text("a");
298        let cell = PublishedSnapshot::new(DocumentSnapshot::from_document(&doc));
299        assert_eq!(cell.load().text(), "a");
300
301        let mut doc2 = Document::from_text("b");
302        doc2.apply_edit(Edit::insert(Position::new(0, 1), "c"))
303            .unwrap();
304        cell.store(DocumentSnapshot::from_document(&doc2));
305        assert_eq!(cell.load().text(), "bc");
306    }
307
308    #[test]
309    fn loaded_arcs_outlive_subsequent_stores() {
310        // The renderer's contract: an Arc<DocumentSnapshot> obtained
311        // at frame start stays valid for the whole frame even if the
312        // actor publishes a newer snapshot mid-frame.
313        let doc = Document::from_text("v1");
314        let cell = PublishedSnapshot::new(DocumentSnapshot::from_document(&doc));
315        let pinned = cell.load();
316        let mut doc2 = Document::from_text("v2");
317        doc2.apply_edit(Edit::insert(Position::new(0, 2), "!"))
318            .unwrap();
319        cell.store(DocumentSnapshot::from_document(&doc2));
320        // pinned still reflects the original.
321        assert_eq!(pinned.text(), "v1");
322        assert_eq!(cell.load().text(), "v2!");
323    }
324
325    #[test]
326    fn snapshot_path_uses_arc_for_zero_copy_clone() {
327        let mut doc = Document::from_text("");
328        // `temp_dir`, not `/tmp`: a failed save leaves no path, and
329        // `/tmp` does not exist on Windows.
330        let path =
331            std::env::temp_dir().join(format!("lattice-snapshot-test-{}.txt", std::process::id()));
332        doc.save_as(&path).expect("save to the temp dir");
333        let _ = std::fs::remove_file(&path);
334        let snap = DocumentSnapshot::from_document(&doc);
335        let cloned = snap.clone();
336        // Both clones point at the same Arc<PathBuf>.
337        assert!(Arc::ptr_eq(
338            snap.path.as_ref().unwrap(),
339            cloned.path.as_ref().unwrap()
340        ));
341    }
342}