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}