lattice_runtime/handle.rs
1//! `RopeDocumentHandle` -- the public API for talking to a document
2//! actor. Cheap to clone (an `mpsc::Sender` + an
3//! `Arc<PublishedSnapshot>`); pass to any thread, hold for any
4//! lifetime, give to plugins.
5//!
6//! ## Operations
7//!
8//! Mutating methods (`apply_edit`, `undo`, `redo`, `save`,
9//! `save_as`, `set_selections`) all return [`Pending<T>`]. Per
10//! DESIGN.md §5.2.1 the dispatcher MUST NOT block the caller --
11//! so these never `await` on the actor; they enqueue and return.
12//!
13//! M.0 (2026-05-31): there is no `replace(...)` method. "The
14//! active document changes" is expressed as slot replacement —
15//! assign a fresh handle to the `Editor.document` slot; the old
16//! handle drops and its actor task exits cleanly when no other
17//! caller holds it. See `docs/dev/architecture/multibuffer-views
18//! .md` §3.1 "Why no `replace`."
19//!
20//! Read methods ([`RopeDocumentHandle::snapshot`] and the convenience
21//! pass-throughs `text`, `path`, `dirty`, `version`,
22//! `text_version`, `selections`) are wait-free and return immediately
23//! from the published snapshot. They never round-trip the actor.
24//!
25//! ## Mailbox semantics
26//!
27//! The mailbox is `tokio::sync::mpsc::unbounded_channel` (audit
28//! slice 6 / H3). Mutating methods send synchronously; the only
29//! failure mode is [`RuntimeError::ActorGone`], surfaced when
30//! the actor task has terminated. The previous bounded-channel +
31//! `RuntimeError::Busy` design dropped edits silently when the
32//! App's `apply_edit_blocking` discarded the Busy variant under
33//! bursts; the unbounded channel makes this class of bug
34//! structurally impossible. Queue depth bounds itself by edit
35//! rate × actor stall (typing is human-paced; a few KB at most).
36
37use std::path::PathBuf;
38use std::sync::Arc;
39
40use lattice_core::Document;
41use lattice_grammar::{CancellationToken, CommandInvocation, CommandRegistryHandle, Effect};
42use lattice_protocol::edit::Edit;
43use lattice_protocol::ids::DocumentId;
44use lattice_protocol::position::Position;
45use lattice_protocol::selection::SelectionSet;
46use tokio::sync::{mpsc, oneshot};
47
48use crate::actor::{ActorMsg, AppliedEdit, DocumentActor};
49use crate::pending::{InvocationId, Pending, RuntimeError};
50use crate::runtime::shared_runtime;
51use crate::snapshot::{DocumentSnapshot, PublishedSnapshot};
52
53/// Cheap-clone handle to one document actor. All callers (App,
54/// renderer, future LSP clients, plugins) talk to the actor through
55/// a `RopeDocumentHandle` -- there is no other way to reach the
56/// document's writable state.
57#[derive(Clone)]
58pub struct RopeDocumentHandle {
59 /// See the module-level "Mailbox semantics" doc for the
60 /// rationale behind the unbounded channel (audit slice 6 /
61 /// H3).
62 sender: mpsc::UnboundedSender<ActorMsg>,
63 snapshot_cell: Arc<PublishedSnapshot>,
64 /// M.2.b.0.A (2026-05-31): registry-level identity of this
65 /// handle's buffer. Distinct from
66 /// `DocumentSnapshot::id` (the per-actor `DocumentId`);
67 /// `BufferId` is what `BufferRegistry` keys by and what
68 /// `MotionContext::buffer_id` carries to kind-specific motion
69 /// handlers. Stored on the handle so trait-method dispatch
70 /// (`Document::dispatch_with_cancel`) can thread the id
71 /// through `ActorMsg::Dispatch` without the caller needing
72 /// to know it. The placeholder handle (`Default::default()`)
73 /// uses `BufferId(0)` — placeholders never traffic real
74 /// dispatch.
75 buffer_id: lattice_core::BufferId,
76}
77
78/// Placeholder handle for `Editor::default()` headless / test
79/// scaffolding. The receiver is dropped immediately, so any
80/// message sent through this handle silently fails (the
81/// caller's reply oneshot drops without being completed,
82/// reported as "actor gone" by the typed wrappers).
83/// Production handles come from
84/// [`spawn_document`]; `Editor::new(...)` overwrites this
85/// slot before any traffic flows.
86impl Default for RopeDocumentHandle {
87 fn default() -> Self {
88 let (sender, _rx) = mpsc::unbounded_channel();
89 Self {
90 sender,
91 snapshot_cell: Arc::new(PublishedSnapshot::new(DocumentSnapshot::default())),
92 // Placeholder handle: any actual traffic will fail
93 // with `RuntimeError::ActorGone` because the
94 // receiver is already dropped. The buffer_id value
95 // is observable only via `Document::buffer_id` in
96 // tests; production code overwrites the slot before
97 // any traffic flows.
98 buffer_id: lattice_core::BufferId(0),
99 }
100 }
101}
102
103/// Spawn a fresh document actor on the shared runtime and return
104/// the handle. Calling this is the *only* way to obtain a
105/// `RopeDocumentHandle`. Document moves into the actor; once spawned
106/// the document is reachable only through the handle.
107///
108/// `registry` is shared by the `ArcSwap` handle so the actor can run
109/// grammar dispatches without coupling to the App's lifetime and see
110/// runtime plugin registrations on its next dispatch (PL8.B / B3b).
111/// Cloning the handle `Arc` is one atomic increment.
112///
113/// The actor task survives until every clone of the returned
114/// handle is dropped; on the last drop the mailbox closes, the
115/// actor's `recv` loop exits, and the task returns.
116pub fn spawn_document(
117 buffer_id: lattice_core::BufferId,
118 document: Document,
119 registry: CommandRegistryHandle,
120) -> RopeDocumentHandle {
121 let (tx, rx) = mpsc::unbounded_channel();
122 let snapshot_cell = Arc::new(PublishedSnapshot::new(DocumentSnapshot::from_document(
123 &document,
124 )));
125 let actor = DocumentActor::new(document, registry, rx, snapshot_cell.clone());
126 shared_runtime().spawn(actor.run());
127 RopeDocumentHandle {
128 sender: tx,
129 snapshot_cell,
130 buffer_id,
131 }
132}
133
134impl RopeDocumentHandle {
135 // ---- Reads (wait-free, snapshot-backed) ----
136
137 /// Load the current snapshot. The returned `Arc` lives as long
138 /// as the caller needs it; the actor's subsequent publishes
139 /// don't invalidate it. Renderers call this once per visible
140 /// document per frame.
141 ///
142 /// Costs ~17ns (one atomic acquire-load + one Arc bump). For
143 /// hot paths that read the snapshot many times between
144 /// edits, prefer [`Self::snapshot_cache`] -- it caches the
145 /// `Arc` per thread and brings the per-load cost to ~2ns.
146 pub fn snapshot(&self) -> Arc<DocumentSnapshot> {
147 self.snapshot_cell.load()
148 }
149
150 /// Build a per-thread cache for the snapshot read path.
151 /// Each call returns a fresh [`crate::snapshot::SnapshotCache`]; threads that
152 /// read the snapshot every frame should hold one of these on
153 /// the stack of the read loop and call `load()` on it instead
154 /// of going through [`Self::snapshot`].
155 ///
156 /// Wait-free thread-local-cached: when the writer hasn't
157 /// changed the snapshot since the last load, the call is one
158 /// `Relaxed` atomic compare and returns a borrowed `Arc`
159 /// reference at no further cost (~2ns). When the writer has
160 /// published, the next load reloads + caches the new `Arc`.
161 pub fn snapshot_cache(&self) -> crate::snapshot::SnapshotCache {
162 crate::snapshot::SnapshotCache::new(self.snapshot_cell.clone())
163 }
164
165 /// Convenience: id (stable for the actor's lifetime).
166 pub fn id(&self) -> DocumentId {
167 self.snapshot().id
168 }
169
170 /// Convenience: rendered text. Allocates -- prefer
171 /// `snapshot().buffer.as_string()` on a held snapshot when
172 /// looping.
173 pub fn text(&self) -> String {
174 self.snapshot().text()
175 }
176
177 /// Convenience: path (clone of the `Arc<PathBuf>` slice).
178 pub fn path(&self) -> Option<PathBuf> {
179 self.snapshot().path.as_ref().map(|a| (**a).clone())
180 }
181
182 /// Convenience: dirty flag.
183 pub fn dirty(&self) -> bool {
184 self.snapshot().dirty
185 }
186
187 pub fn version(&self) -> u64 {
188 self.snapshot().version
189 }
190
191 pub fn text_version(&self) -> u64 {
192 self.snapshot().text_version
193 }
194
195 pub fn selections(&self) -> Arc<SelectionSet> {
196 self.snapshot().selections.clone()
197 }
198
199 // ---- Mutations (enqueue + Pending) ----
200
201 /// Apply one edit. Resolves to the resulting `AppliedEdit`
202 /// (range info + replaced text) once the actor commits.
203 pub fn apply_edit(&self, edit: Edit) -> Pending<AppliedEdit> {
204 self.send(|reply| ActorMsg::ApplyEdit { edit, reply })
205 }
206
207 /// Apply multiple edits as one undo unit.
208 pub fn apply_edit_batch(&self, edits: Vec<Edit>) -> Pending<Vec<AppliedEdit>> {
209 self.send(|reply| ActorMsg::ApplyEditBatch { edits, reply })
210 }
211
212 pub fn undo(&self) -> Pending<Vec<AppliedEdit>> {
213 self.send(|reply| ActorMsg::Undo { reply })
214 }
215
216 pub fn redo(&self) -> Pending<Vec<AppliedEdit>> {
217 self.send(|reply| ActorMsg::Redo { reply })
218 }
219
220 pub fn save(&self) -> Pending<PathBuf> {
221 self.send(|reply| ActorMsg::Save { reply })
222 }
223
224 pub fn save_as(&self, path: PathBuf) -> Pending<()> {
225 self.send(|reply| ActorMsg::SaveAs { path, reply })
226 }
227
228 pub fn set_selections(&self, selections: SelectionSet) -> Pending<()> {
229 self.send(|reply| ActorMsg::SetSelections { selections, reply })
230 }
231
232 /// Open an undo-coalescing group on the actor so a run of edits (a
233 /// vim insert session) collapses into a single undo unit until
234 /// [`Self::end_undo_group`]. Fire-and-forget -- ordering against the
235 /// following `apply_edit` burst is guaranteed by the FIFO mailbox,
236 /// so there is nothing to await. A closed receiver (actor gone) is a
237 /// silent no-op: there is nothing to coalesce on a dead actor.
238 pub fn begin_undo_group(&self) {
239 let _ = self.sender.send(ActorMsg::BeginUndoGroup);
240 }
241
242 /// Close the group opened by [`Self::begin_undo_group`]. Same
243 /// fire-and-forget semantics.
244 pub fn end_undo_group(&self) {
245 let _ = self.sender.send(ActorMsg::EndUndoGroup);
246 }
247
248 /// Dispatch a [`CommandInvocation`] through
249 /// [`lattice_grammar::execute`] inside the actor. The actor
250 /// holds the only `&mut Document` so all grammar-driven
251 /// mutations route here. The returned `Effect` is for the App
252 /// to apply to its session-scoped state (registers, modal
253 /// transitions, marks, etc.).
254 ///
255 /// This form uses a no-op [`CancellationToken::never()`]; the
256 /// dispatch will run to completion. Use
257 /// [`Self::dispatch_with_cancel`] when the caller needs to
258 /// cancel a long-running motion / operator on user Esc.
259 pub fn dispatch(&self, invocation: CommandInvocation, cursor: Position) -> Pending<Effect> {
260 self.dispatch_with_cancel(invocation, cursor, CancellationToken::never())
261 }
262
263 /// Like [`Self::dispatch`] but routes a caller-owned
264 /// [`CancellationToken`] into the grammar `execute` call. The
265 /// caller keeps a clone and flips it (e.g. on user Esc) to
266 /// short-circuit a long-running motion / operator. Per
267 /// DESIGN.md §5.7, cancellation is cooperative -- the grammar
268 /// polls at quantisation points (per-row in blockwise ops, per
269 /// match in search loops, etc.).
270 pub fn dispatch_with_cancel(
271 &self,
272 invocation: CommandInvocation,
273 cursor: Position,
274 cancel: CancellationToken,
275 ) -> Pending<Effect> {
276 self.dispatch_with_env(
277 invocation,
278 cursor,
279 cancel,
280 crate::document::DispatchEnv::default(),
281 )
282 }
283
284 /// N.1.4b / N.1.6 (2026-06-10): the env-carrying dispatch entry.
285 /// Threads the [`DispatchEnv`](crate::document::DispatchEnv) (the
286 /// tree-sitter `scope_resolver` for af/ac/aa/al + the `comment_syntax`
287 /// for aC/iC) into [`ActorMsg::Dispatch`] so the actor hands it to
288 /// `execute_with_env`. The env's Arc fields cross the actor channel
289 /// as Arc bumps; the snapshot is immutable so the actor reads it
290 /// wait-free. Staleness note: the resolver reflects the
291 /// last-published syntax tree, which may trail the actor's rope by an
292 /// in-flight edit -- acceptable eventual consistency (CLAUDE.md),
293 /// never a blocking reparse on the hot path (paramount #1).
294 pub fn dispatch_with_env(
295 &self,
296 invocation: CommandInvocation,
297 cursor: Position,
298 cancel: CancellationToken,
299 env: crate::document::DispatchEnv,
300 ) -> Pending<Effect> {
301 let buffer_id = self.buffer_id;
302 self.send(|reply| ActorMsg::Dispatch {
303 buffer_id,
304 invocation,
305 cursor,
306 cancel,
307 env,
308 reply,
309 })
310 }
311
312 /// M.2.b.0.A (2026-05-31): registry-level identity of this
313 /// handle. Stable for the handle's lifetime.
314 pub fn buffer_id(&self) -> lattice_core::BufferId {
315 self.buffer_id
316 }
317
318 // ---- internals ----
319
320 /// Common scaffolding for every mutating method: allocate the
321 /// `oneshot`, build the `ActorMsg`, `try_send`, and wrap the
322 /// receiver in a `Pending`. On `Full`, build a `Pending` whose
323 /// receiver is pre-loaded with `Busy` so the caller observes
324 /// the failure through the same await/`blocking_recv` path.
325 fn send<T, F>(&self, build: F) -> Pending<T>
326 where
327 F: FnOnce(oneshot::Sender<Result<T, RuntimeError>>) -> ActorMsg,
328 {
329 let id = InvocationId::next();
330 let (tx, rx) = oneshot::channel();
331 let msg = build(tx);
332 if self.sender.send(msg).is_err() {
333 // Receiver closed -> actor gone. The original `tx`
334 // was consumed into `msg` and dropped on send failure;
335 // mint a fresh oneshot so the caller's pending
336 // resolves immediately with the right error.
337 let (gone_tx, gone_rx) = oneshot::channel();
338 let _ = gone_tx.send(Err(RuntimeError::ActorGone));
339 return Pending::new(id, gone_rx);
340 }
341 Pending::new(id, rx)
342 }
343}
344
345/// M.0 (2026-05-31): handle-layer [`Document`] trait impl.
346/// Delegates every method to the inherent method of the same
347/// name on `RopeDocumentHandle` via fully-qualified syntax — the
348/// trait is a thin abstraction over the existing API surface;
349/// callers gain `Arc<dyn Document>` polymorphism for free.
350impl crate::document::Document for RopeDocumentHandle {
351 fn snapshot(&self) -> Arc<DocumentSnapshot> {
352 RopeDocumentHandle::snapshot(self)
353 }
354
355 fn snapshot_cache(&self) -> crate::snapshot::SnapshotCache {
356 RopeDocumentHandle::snapshot_cache(self)
357 }
358
359 fn id(&self) -> DocumentId {
360 RopeDocumentHandle::id(self)
361 }
362
363 fn text(&self) -> String {
364 RopeDocumentHandle::text(self)
365 }
366
367 fn path(&self) -> Option<PathBuf> {
368 RopeDocumentHandle::path(self)
369 }
370
371 fn dirty(&self) -> bool {
372 RopeDocumentHandle::dirty(self)
373 }
374
375 fn version(&self) -> u64 {
376 RopeDocumentHandle::version(self)
377 }
378
379 fn text_version(&self) -> u64 {
380 RopeDocumentHandle::text_version(self)
381 }
382
383 fn selections(&self) -> Arc<SelectionSet> {
384 RopeDocumentHandle::selections(self)
385 }
386
387 fn apply_edit(&self, edit: Edit) -> Pending<AppliedEdit> {
388 RopeDocumentHandle::apply_edit(self, edit)
389 }
390
391 fn apply_edit_batch(&self, edits: Vec<Edit>) -> Pending<Vec<AppliedEdit>> {
392 RopeDocumentHandle::apply_edit_batch(self, edits)
393 }
394
395 fn undo(&self) -> Pending<Vec<AppliedEdit>> {
396 RopeDocumentHandle::undo(self)
397 }
398
399 fn redo(&self) -> Pending<Vec<AppliedEdit>> {
400 RopeDocumentHandle::redo(self)
401 }
402
403 fn save(&self) -> Pending<PathBuf> {
404 RopeDocumentHandle::save(self)
405 }
406
407 fn save_as(&self, path: PathBuf) -> Pending<()> {
408 RopeDocumentHandle::save_as(self, path)
409 }
410
411 fn set_selections(&self, selections: SelectionSet) -> Pending<()> {
412 RopeDocumentHandle::set_selections(self, selections)
413 }
414
415 fn begin_undo_group(&self) {
416 RopeDocumentHandle::begin_undo_group(self)
417 }
418
419 fn end_undo_group(&self) {
420 RopeDocumentHandle::end_undo_group(self)
421 }
422
423 fn dispatch_with_cancel(
424 &self,
425 invocation: CommandInvocation,
426 cursor: Position,
427 cancel: CancellationToken,
428 ) -> Pending<Effect> {
429 RopeDocumentHandle::dispatch_with_cancel(self, invocation, cursor, cancel)
430 }
431
432 fn dispatch_with_env(
433 &self,
434 invocation: CommandInvocation,
435 cursor: Position,
436 cancel: CancellationToken,
437 env: crate::document::DispatchEnv,
438 ) -> Pending<Effect> {
439 RopeDocumentHandle::dispatch_with_env(self, invocation, cursor, cancel, env)
440 }
441}
442
443impl std::fmt::Debug for RopeDocumentHandle {
444 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
445 let snap = self.snapshot();
446 f.debug_struct("RopeDocumentHandle")
447 .field("id", &snap.id)
448 .field("version", &snap.version)
449 .field("text_version", &snap.text_version)
450 .field("dirty", &snap.dirty)
451 .finish()
452 }
453}
454
455#[cfg(test)]
456mod tests {
457 #![allow(clippy::unwrap_used)]
458 use super::*;
459 use lattice_grammar::CommandRegistry;
460 use lattice_protocol::position::Position;
461
462 fn empty_registry() -> CommandRegistryHandle {
463 Arc::new(arc_swap::ArcSwap::from_pointee(CommandRegistry::new()))
464 }
465
466 /// M.0 (2026-05-31): `Arc<dyn Document>` works — the trait
467 /// impl on `RopeDocumentHandle` is dyn-safe and delegates to
468 /// the inherent methods, so callers holding the trait
469 /// object see the same observable behaviour as callers
470 /// holding the concrete handle.
471 #[tokio::test(flavor = "multi_thread")]
472 async fn handle_is_usable_as_dyn_document() {
473 // Bring the trait into scope so trait-object method
474 // resolution works on `&dyn Document`. The `Document`
475 // type at module scope is the inner struct
476 // (`lattice_core::Document`); the trait lives at
477 // `crate::document::Document`.
478 use crate::document::Document as DocumentTrait;
479
480 let handle = spawn_document(
481 lattice_core::BufferId(0),
482 Document::from_text("hello"),
483 empty_registry(),
484 );
485 let dyn_doc: std::sync::Arc<dyn DocumentTrait> = std::sync::Arc::new(handle.clone());
486
487 // Reads go through the trait method (default impl).
488 assert_eq!(dyn_doc.text(), "hello");
489 assert_eq!(dyn_doc.version(), 0);
490 assert!(!dyn_doc.dirty());
491
492 // Writes go through the trait method (delegated to actor).
493 dyn_doc
494 .apply_edit(Edit::insert(Position::new(0, 5), "!"))
495 .await
496 .unwrap();
497
498 // Trait-object read sees the new state; concrete handle
499 // sees the same snapshot (same actor underneath).
500 assert_eq!(dyn_doc.text(), "hello!");
501 assert_eq!(handle.text(), "hello!");
502 }
503
504 #[tokio::test(flavor = "multi_thread")]
505 async fn handle_clone_shares_actor() {
506 let h1 = spawn_document(
507 lattice_core::BufferId(0),
508 Document::from_text("a"),
509 empty_registry(),
510 );
511 let h2 = h1.clone();
512 h1.apply_edit(Edit::insert(Position::new(0, 1), "b"))
513 .await
514 .unwrap();
515 // Both handles see the same published snapshot.
516 assert_eq!(h1.snapshot().text(), "ab");
517 assert_eq!(h2.snapshot().text(), "ab");
518 }
519
520 #[tokio::test(flavor = "multi_thread")]
521 async fn save_as_updates_path_in_snapshot() {
522 let dir = std::env::temp_dir();
523 let target = dir.join("lattice-handle-save-as.txt");
524 let h = spawn_document(
525 lattice_core::BufferId(0),
526 Document::from_text("payload"),
527 empty_registry(),
528 );
529 h.save_as(target.clone()).await.unwrap();
530 let snap = h.snapshot();
531 assert_eq!(snap.path(), Some(target.as_path()));
532 assert!(!snap.dirty);
533 let _ = std::fs::remove_file(&target);
534 }
535}