Skip to main content

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}