Skip to main content

lattice_runtime/
actor.rs

1//! `DocumentActor` -- the tokio task that owns one document's
2//! writable state (DESIGN.md §5.7, §5.6.8).
3//!
4//! ## Responsibilities
5//!
6//! 1. **Exclusive ownership** of one [`lattice_core::Document`]. No
7//!    other code path holds a `&mut Document`; mutations arrive as
8//!    `ActorMsg` variants on the bounded mailbox.
9//! 2. **Snapshot publish** after every committed mutation. The
10//!    actor builds a [`DocumentSnapshot`] from the post-commit
11//!    state and writes it to the [`PublishedSnapshot`] cell with
12//!    `store_release` semantics.
13//! 3. **Unbounded mailbox** -- audit slice 6 / H3. The mailbox
14//!    was originally bounded with a `try_send` -> `Busy` path
15//!    for callers; in practice, App-side `apply_edit_blocking`
16//!    discarded `Busy` silently and bursts could desync the
17//!    buffer from what the user typed. Unbounded eliminates the
18//!    silent-drop class entirely; queue depth bounds itself by
19//!    edit rate × actor-stall-duration (typing is human-paced).
20//! 4. **Graceful shutdown** -- when every [`crate::RopeDocumentHandle`]
21//!    is dropped the mailbox closes; the actor's `recv` loop exits
22//!    naturally.
23//!
24//! ## Why one task per document, not a thread
25//!
26//! Documents are I/O-bound (file save/open, future LSP
27//! attribution); a tokio task is the right granularity. The
28//! `LocalSet` complexity of "stay on one thread" isn't needed
29//! because `Document` is `Send`. Across cores the actor still has
30//! exclusive logical ownership -- only one task handles any given
31//! document.
32//!
33//! ## Why the actor builds the snapshot, not the handle
34//!
35//! Snapshot publish happens *inside* the actor's task, after the
36//! mutation. Doing it on the handle side would race with concurrent
37//! readers and miss the publish-before-respond ordering required by
38//! the §5.6.8 acquire/release contract.
39
40use std::path::PathBuf;
41use std::sync::Arc;
42
43use lattice_core::{Buffer, CoreError, Document};
44use lattice_grammar::{
45    CancellationToken, CommandInvocation, CommandRegistryHandle, Effect, execute_with_env,
46};
47use lattice_protocol::edit::Edit;
48use lattice_protocol::position::Position;
49use lattice_protocol::selection::SelectionSet;
50use tokio::sync::{mpsc, oneshot};
51
52use crate::pending::RuntimeError;
53use crate::snapshot::{DocumentSnapshot, PublishedSnapshot};
54
55/// One unit of work the actor executes. Each variant carries its
56/// own `oneshot::Sender` so the response can flow back to the
57/// originating `Pending<T>`. The actor never sees the
58/// `InvocationId`; it's purely caller-side telemetry.
59///
60/// `Shutdown` is here so tests can deterministically drain the
61/// actor; in production graceful shutdown happens by dropping the
62/// last handle (mailbox closes; `recv()` returns `None`).
63pub(crate) enum ActorMsg {
64    ApplyEdit {
65        edit: Edit,
66        reply: oneshot::Sender<Result<lattice_core::buffer::AppliedEdit, RuntimeError>>,
67    },
68    ApplyEditBatch {
69        edits: Vec<Edit>,
70        reply: oneshot::Sender<Result<Vec<lattice_core::buffer::AppliedEdit>, RuntimeError>>,
71    },
72    Undo {
73        reply: oneshot::Sender<Result<Vec<lattice_core::buffer::AppliedEdit>, RuntimeError>>,
74    },
75    Redo {
76        reply: oneshot::Sender<Result<Vec<lattice_core::buffer::AppliedEdit>, RuntimeError>>,
77    },
78    /// Save to the document's existing path. Returns the path so
79    /// the caller can echo it without going back through the
80    /// snapshot.
81    Save {
82        reply: oneshot::Sender<Result<PathBuf, RuntimeError>>,
83    },
84    /// Save to a new path (becomes the document's path).
85    SaveAs {
86        path: PathBuf,
87        reply: oneshot::Sender<Result<(), RuntimeError>>,
88    },
89    SetSelections {
90        selections: SelectionSet,
91        reply: oneshot::Sender<Result<(), RuntimeError>>,
92    },
93    /// Open / close an undo-coalescing group on the owned document
94    /// (vim's insert session = one undo unit). Fire-and-forget: no
95    /// reply and no snapshot mutation -- opening a group changes no
96    /// buffer-visible state. The mailbox is FIFO, so a `BeginUndoGroup`
97    /// enqueued before a burst of `ApplyEdit` is guaranteed to be
98    /// observed first, and `EndUndoGroup` after -- the caller never has
99    /// to await these.
100    BeginUndoGroup,
101    EndUndoGroup,
102    /// Run a [`lattice_grammar::execute`] dispatch against the
103    /// document. The actor holds the only `&mut Document` so all
104    /// invocation-driven mutations route here. Returns the
105    /// `Effect` the grammar produced; the App applies it to
106    /// session-scoped state (registers, modal, marks, ...).
107    /// `cursor` is the App's view cursor (per-pane), passed in
108    /// because the grammar needs it but it's not document-owned
109    /// state.
110    /// `cancel` is the cooperative cancellation token. The actor
111    /// passes it straight to [`lattice_grammar::execute`]; the
112    /// caller (App) holds a clone and flips it on user Esc.
113    /// Cheap callers that don't need cancellation pass
114    /// [`CancellationToken::never()`].
115    Dispatch {
116        /// M.2.b.0.A (2026-05-31): registry-level `BufferId`
117        /// for this dispatch. Threaded through to
118        /// `MotionContext::buffer_id` so kind-specific motions
119        /// can look up active-mode state via a service
120        /// registry. Distinct from the actor's owned
121        /// `Document::id()` which is a `DocumentId`.
122        buffer_id: lattice_core::BufferId,
123        invocation: CommandInvocation,
124        cursor: Position,
125        cancel: CancellationToken,
126        /// N.1.4b / N.1.6 (2026-06-10): the per-dispatch text-object
127        /// env — the tree-sitter `scope_resolver` (af/ac/aa/al) + the
128        /// `comment_syntax` (aC/iC). Empty for buffers with no syntax /
129        /// no leader. Crosses the channel as Arc bumps; the snapshot is
130        /// immutable so the actor reads it wait-free.
131        env: crate::document::DispatchEnv,
132        reply: oneshot::Sender<Result<Effect, RuntimeError>>,
133    },
134}
135
136/// The actor task. Constructed by [`crate::spawn_document`].
137pub struct DocumentActor {
138    document: Document,
139    /// Shared with the App and any other caller; the actor holds the
140    /// `ArcSwap` handle so it can run [`lattice_grammar::execute`]
141    /// against the registry from within its own task, snapshotting it
142    /// wait-free (`.load()`) on each dispatch so a plugin registered at
143    /// runtime becomes live for this buffer on its next keystroke
144    /// (PL8.B / B3b).
145    registry: CommandRegistryHandle,
146    inbox: mpsc::UnboundedReceiver<ActorMsg>,
147    snapshot_cell: Arc<PublishedSnapshot>,
148}
149
150impl DocumentActor {
151    pub(crate) fn new(
152        document: Document,
153        registry: CommandRegistryHandle,
154        inbox: mpsc::UnboundedReceiver<ActorMsg>,
155        snapshot_cell: Arc<PublishedSnapshot>,
156    ) -> Self {
157        Self {
158            document,
159            registry,
160            inbox,
161            snapshot_cell,
162        }
163    }
164
165    /// Drive the actor to completion. Exits when every handle has
166    /// been dropped (the mailbox closes). Spawned by
167    /// [`crate::spawn_document`] onto the shared runtime.
168    pub async fn run(mut self) {
169        while let Some(msg) = self.inbox.recv().await {
170            // Publish-before-reply: every message handler runs
171            // its work, publishes the new snapshot, *then* sends
172            // the reply. This guarantees a caller that observes
173            // the reply (e.g. a `block_on` returns) also observes
174            // the new published snapshot via `arc_swap::load`.
175            // Without this ordering, callers can see stale
176            // snapshots after their wait completes -- a race
177            // every test failure here was hitting.
178            self.handle(msg);
179        }
180        // All handles dropped -- graceful shutdown.
181    }
182
183    fn handle(&mut self, msg: ActorMsg) {
184        match msg {
185            ActorMsg::ApplyEdit { edit, reply } => {
186                let result = self.document.apply_edit(edit).map_err(RuntimeError::Core);
187                self.publish();
188                let _ = reply.send(result);
189            }
190            ActorMsg::ApplyEditBatch { edits, reply } => {
191                let result = self
192                    .document
193                    .apply_edit_batch(edits)
194                    .map_err(RuntimeError::Core);
195                self.publish();
196                let _ = reply.send(result);
197            }
198            ActorMsg::Undo { reply } => {
199                let result = self.document.undo().map_err(RuntimeError::Core);
200                self.publish();
201                let _ = reply.send(result);
202            }
203            ActorMsg::Redo { reply } => {
204                let result = self.document.redo().map_err(RuntimeError::Core);
205                self.publish();
206                let _ = reply.send(result);
207            }
208            ActorMsg::Save { reply } => {
209                let result = self
210                    .document
211                    .save()
212                    .map(|p| p.to_path_buf())
213                    .map_err(RuntimeError::Core);
214                self.publish();
215                let _ = reply.send(result);
216            }
217            ActorMsg::SaveAs { path, reply } => {
218                let result = self.document.save_as(path).map_err(RuntimeError::Core);
219                self.publish();
220                let _ = reply.send(result);
221            }
222            ActorMsg::SetSelections { selections, reply } => {
223                self.document.set_selections(selections);
224                self.publish();
225                let _ = reply.send(Ok(()));
226            }
227            // Undo-group toggles: no reply, no publish (buffer text is
228            // untouched; only how the *next* edits fold onto the undo
229            // stack changes).
230            ActorMsg::BeginUndoGroup => self.document.begin_undo_group(),
231            ActorMsg::EndUndoGroup => self.document.end_undo_group(),
232            ActorMsg::Dispatch {
233                buffer_id,
234                invocation,
235                cursor,
236                cancel,
237                env,
238                reply,
239            } => {
240                // N.1.4b / N.1.6 (2026-06-10): borrow the owned env's Arc
241                // handles into the grammar's `GrammarEnv`, dropping the
242                // `+ Send + Sync` auto traits the channel required. Empty
243                // fields when the buffer has no syntax / no comment leader
244                // -- the structural + comment objects then resolve nothing;
245                // the classic objects never read it.
246                let scope_resolver: Option<&dyn lattice_grammar::ScopeResolver> =
247                    match env.scope_resolver.as_deref() {
248                        Some(r) => Some(r),
249                        None => None,
250                    };
251                // IN.7: deref the owned handle to the borrowed form
252                // the grammar sees, mirroring `scope_resolver` above.
253                let indent_resolver: Option<&dyn lattice_grammar::IndentResolver> =
254                    env.indent_resolver.as_deref().map(|r| r as _);
255                // VM.3i: deref the owned fold handle, like `indent_resolver`.
256                let fold_resolver: Option<&dyn lattice_grammar::FoldResolver> =
257                    env.fold_resolver.as_deref().map(|r| r as _);
258                let to_env = lattice_grammar::GrammarEnv {
259                    scope_resolver,
260                    comment_syntax: env.comment_syntax.as_deref(),
261                    // OT.4: the actor path DOES carry the raw snapshot now.
262                    // TS.1 left it `None` on the reasoning that grammar actions
263                    // were the only tree-snapshot consumer and they dispatch
264                    // through the host's Action gate — true when it was written,
265                    // and stale from OT.1, which gave motions and text objects
266                    // the same handle. Those come through here, so a hard `None`
267                    // meant org's `ar` / `]]` / `g{` saw no tree on any real
268                    // keystroke while the seam looked wired end to end.
269                    syntax: env.syntax.as_ref(),
270                    indent: env.indent,
271                    indent_resolver,
272                    textwidth: env.textwidth,
273                    native_format: env.native_format,
274                    selection: env.selection,
275                    last_find: env.last_find,
276                    fold_resolver,
277                    last_search: env.last_search.as_ref(),
278                    marks: env.marks.as_deref().map(|m| m as _),
279                    viewport: env.viewport.as_deref().map(|v| v as _),
280                    nostartofline: env.nostartofline,
281                    scrolloff: env.scrolloff,
282                    curswant: env.curswant,
283                    display: env.display.as_deref().map(|d| d as _),
284                    // VM.3g-3: borrowed back out of the owned slot, the same
285                    // way every handle above is. The `Arc` stays alive in
286                    // `env` for the whole dispatch, so the borrow cannot
287                    // outlive it.
288                    curswant_out: env.curswant_out.as_deref(),
289                };
290                // B3b: snapshot the registry wait-free for this dispatch. A
291                // plugin registered at runtime (loader RCU-store into the
292                // `ArcSwap`) becomes live on the next keystroke; a mid-dispatch
293                // store never mutates the snapshot this call holds.
294                let registry = self.registry.load();
295                let result = execute_with_env(
296                    &registry,
297                    &mut self.document,
298                    buffer_id,
299                    cursor,
300                    invocation,
301                    &cancel,
302                    to_env,
303                )
304                .map_err(RuntimeError::Grammar);
305                self.publish();
306                let _ = reply.send(result);
307            }
308        }
309    }
310
311    fn publish(&self) {
312        self.snapshot_cell
313            .store(DocumentSnapshot::from_document(&self.document));
314    }
315}
316
317// Re-export AppliedEdit at this layer so callers don't need to
318// reach into lattice-core/buffer just to spell the success type.
319pub use lattice_core::buffer::AppliedEdit;
320
321// Make the unused-symbol warnings explicit.
322#[allow(dead_code)]
323const _: fn(&CoreError, &Buffer) = |_, _| {};
324
325#[cfg(test)]
326mod tests {
327    #![allow(clippy::unwrap_used)]
328    use super::*;
329    use crate::handle::spawn_document;
330    use lattice_grammar::{CommandRegistry, CommandRegistryHandle};
331    use lattice_protocol::position::Position;
332
333    fn empty_registry() -> CommandRegistryHandle {
334        Arc::new(arc_swap::ArcSwap::from_pointee(CommandRegistry::new()))
335    }
336
337    #[tokio::test(flavor = "multi_thread")]
338    async fn apply_edit_publishes_new_snapshot() {
339        let handle = spawn_document(
340            lattice_core::BufferId(0),
341            Document::from_text("hello"),
342            empty_registry(),
343        );
344        let initial = handle.snapshot();
345        assert_eq!(initial.text(), "hello");
346        let initial_version = initial.version;
347
348        handle
349            .apply_edit(Edit::insert(Position::new(0, 5), " world"))
350            .await
351            .unwrap();
352
353        let after = handle.snapshot();
354        assert_eq!(after.text(), "hello world");
355        assert!(after.version > initial_version);
356        assert!(after.text_version > initial.text_version);
357    }
358
359    #[tokio::test(flavor = "multi_thread")]
360    async fn undo_restores_previous_snapshot_text() {
361        let handle = spawn_document(
362            lattice_core::BufferId(0),
363            Document::from_text("a"),
364            empty_registry(),
365        );
366        handle
367            .apply_edit(Edit::insert(Position::new(0, 1), "b"))
368            .await
369            .unwrap();
370        assert_eq!(handle.snapshot().text(), "ab");
371        handle.undo().await.unwrap();
372        assert_eq!(handle.snapshot().text(), "a");
373    }
374
375    #[tokio::test(flavor = "multi_thread")]
376    async fn redo_replays_undone_edit() {
377        let handle = spawn_document(
378            lattice_core::BufferId(0),
379            Document::from_text(""),
380            empty_registry(),
381        );
382        handle
383            .apply_edit(Edit::insert(Position::ZERO, "x"))
384            .await
385            .unwrap();
386        handle.undo().await.unwrap();
387        handle.redo().await.unwrap();
388        assert_eq!(handle.snapshot().text(), "x");
389    }
390
391    #[tokio::test(flavor = "multi_thread")]
392    async fn snapshots_loaded_pre_publish_remain_coherent() {
393        // §5.6.8 contract: an Arc<DocumentSnapshot> obtained at
394        // frame start stays valid for the whole frame.
395        let handle = spawn_document(
396            lattice_core::BufferId(0),
397            Document::from_text("v1"),
398            empty_registry(),
399        );
400        let pinned = handle.snapshot();
401        handle
402            .apply_edit(Edit::insert(Position::new(0, 2), "!"))
403            .await
404            .unwrap();
405        assert_eq!(pinned.text(), "v1");
406        assert_eq!(handle.snapshot().text(), "v1!");
407    }
408
409    #[tokio::test(flavor = "multi_thread")]
410    async fn invalid_edit_returns_core_error_without_publish() {
411        let handle = spawn_document(
412            lattice_core::BufferId(0),
413            Document::from_text("abc"),
414            empty_registry(),
415        );
416        let v_before = handle.snapshot().version;
417        // Insert at line 99 -- out of range.
418        let res = handle
419            .apply_edit(Edit::insert(Position::new(99, 0), "x"))
420            .await;
421        assert!(matches!(res, Err(RuntimeError::Core(_))));
422        // Version still bumps on the publish that happens after
423        // every message, but the buffer text is unchanged.
424        let after = handle.snapshot();
425        assert_eq!(after.text(), "abc");
426        // Version monotonicity holds (publish always happens).
427        assert!(after.version >= v_before);
428    }
429
430    #[tokio::test(flavor = "multi_thread")]
431    async fn dropping_all_handles_shuts_down_actor() {
432        let handle = spawn_document(
433            lattice_core::BufferId(0),
434            Document::from_text(""),
435            empty_registry(),
436        );
437        let h2 = handle.clone();
438        drop(handle);
439        h2.apply_edit(Edit::insert(Position::ZERO, "a"))
440            .await
441            .unwrap();
442        drop(h2);
443        // Actor task exits when its mailbox closes; the test simply
444        // asserts that no panic / hang occurs across the drop.
445    }
446
447    #[tokio::test(flavor = "multi_thread")]
448    async fn dispatch_with_cancel_short_circuits_when_pre_flipped() {
449        // The actor MUST honour a flipped cancellation token by
450        // surfacing CommandError::Cancelled. The grammar dispatcher
451        // checks the token before any registry lookup, so an empty
452        // registry + bogus CommandId is a sufficient minimal case.
453        use lattice_grammar::CommandId;
454        use lattice_grammar::CommandInvocation;
455        use lattice_grammar::error::CommandError;
456
457        let handle = spawn_document(
458            lattice_core::BufferId(0),
459            Document::from_text("hello"),
460            empty_registry(),
461        );
462        let token = CancellationToken::new();
463        token.cancel();
464
465        let result = handle
466            .dispatch_with_cancel(
467                CommandInvocation::of(CommandId::new(1)),
468                Position::ZERO,
469                token,
470            )
471            .await;
472        assert!(matches!(
473            result,
474            Err(RuntimeError::Grammar(CommandError::Cancelled))
475        ));
476    }
477
478    #[tokio::test(flavor = "multi_thread")]
479    async fn dispatch_with_cancel_runs_when_token_fresh() {
480        // Sanity: a fresh token does not block dispatch -- the
481        // unknown-command error must surface, not Cancelled.
482        use lattice_grammar::CommandId;
483        use lattice_grammar::CommandInvocation;
484        use lattice_grammar::error::CommandError;
485
486        let handle = spawn_document(
487            lattice_core::BufferId(0),
488            Document::from_text("hello"),
489            empty_registry(),
490        );
491        let token = CancellationToken::new();
492
493        let result = handle
494            .dispatch_with_cancel(
495                CommandInvocation::of(CommandId::new(1)),
496                Position::ZERO,
497                token,
498            )
499            .await;
500        assert!(matches!(
501            result,
502            Err(RuntimeError::Grammar(CommandError::UnknownCommand))
503        ));
504    }
505
506    #[tokio::test(flavor = "multi_thread")]
507    async fn dispatch_with_scope_resolver_threads_resolver_to_text_object() {
508        // N.1.4b: the resolver handed to `dispatch_with_scope_resolver`
509        // must reach the grammar's `TextObjectContext.scope_resolver`
510        // inside the actor's `execute` call. A bare text object discards
511        // its range (Effect::None until visual integration lands), so the
512        // probe text object records, out of band, what the resolver
513        // returned -- proving the whole wire: host handle -> ActorMsg ->
514        // execute_with_scope_resolver -> text-object apply.
515        use lattice_grammar::registry::{ScopeResolver, TextObjectSpec};
516        use std::sync::Mutex;
517
518        // Mock resolver: returns a fixed byte-precise span so the
519        // assertion can distinguish "resolver reached the context" from
520        // "resolver was absent" (None).
521        struct MockResolver;
522        impl ScopeResolver for MockResolver {
523            fn scope_at(
524                &self,
525                _line: u32,
526                _col_byte: u32,
527                _suffix: &str,
528            ) -> Option<lattice_protocol::position::Range> {
529                Some(lattice_protocol::position::Range::new(
530                    Position::new(3, 2),
531                    Position::new(9, 5),
532                ))
533            }
534
535            // TSM.1: stub -- see SyntaxSnapshot's scope_toward for rationale.
536            fn scope_toward(
537                &self,
538                _line: u32,
539                _col_byte: u32,
540                _suffix: &str,
541                _dir: lattice_grammar::NavDir,
542                _boundary: lattice_grammar::NavBoundary,
543                _count: u32,
544            ) -> Option<lattice_protocol::Position> {
545                None
546            }
547        }
548
549        // Outer Option = "apply ran at all"; inner Option = the
550        // scope_at result the text object observed.
551        let observed: Arc<Mutex<Option<Option<lattice_protocol::position::Range>>>> =
552            Arc::new(Mutex::new(None));
553        let probe = observed.clone();
554
555        let mut registry = CommandRegistry::new();
556        let tobj = registry.register_text_object(
557            "test-scope-probe",
558            "N.1.4b test: records the scope resolver result it is handed",
559            TextObjectSpec {
560                apply: Arc::new(move |ctx| {
561                    let resolved = ctx
562                        .scope_resolver
563                        .and_then(|r| r.scope_at(ctx.at.line, 0, ".function.outer"));
564                    *probe.lock().unwrap() = Some(resolved);
565                    Ok(lattice_protocol::position::Range::empty(ctx.at))
566                }),
567                args_schema: Vec::new(),
568            },
569        );
570
571        let handle = spawn_document(
572            lattice_core::BufferId(0),
573            Document::from_text("fn a() {}\nfn b() {}\nfn c() {}\n"),
574            Arc::new(arc_swap::ArcSwap::from_pointee(registry)),
575        );
576
577        // Positive: a resolver is supplied -> the text object sees Some
578        // and scope_at returns the mock range.
579        let resolver: crate::document::ScopeResolverHandle = Arc::new(MockResolver);
580        handle
581            .dispatch_with_env(
582                CommandInvocation::of(tobj.0),
583                Position::ZERO,
584                CancellationToken::never(),
585                crate::document::DispatchEnv {
586                    scope_resolver: Some(resolver),
587                    comment_syntax: None,
588                    ..Default::default()
589                },
590            )
591            .await
592            .unwrap();
593        assert_eq!(
594            *observed.lock().unwrap(),
595            Some(Some(lattice_protocol::position::Range::new(
596                Position::new(3, 2),
597                Position::new(9, 5),
598            ))),
599            "resolver passed to dispatch_with_scope_resolver must reach the text object context"
600        );
601
602        // Negative: the no-resolver path (`dispatch_with_cancel`) -> the
603        // text object sees None. Proves the resolver is genuinely
604        // threaded, not a hard-coded Some.
605        *observed.lock().unwrap() = None;
606        handle
607            .dispatch_with_cancel(
608                CommandInvocation::of(tobj.0),
609                Position::ZERO,
610                CancellationToken::never(),
611            )
612            .await
613            .unwrap();
614        assert_eq!(
615            *observed.lock().unwrap(),
616            Some(None),
617            "without a resolver the text object's scope_resolver must be None"
618        );
619    }
620
621    /// OT.4: the raw syntax snapshot reaches a text object through this path.
622    ///
623    /// The peer of the test above, and it exists because the two fields are NOT
624    /// interchangeable. `scope_resolver` is erased to one question ("what scope
625    /// encloses this point") for the native structural objects; a PLUGIN object
626    /// needs the concrete snapshot to mint a `tree-snapshot` resource, and it
627    /// cannot be recovered from the resolver.
628    ///
629    /// Until OT.4 this arm passed a hard `None`, so org's `ar` / `]]` / `g{`
630    /// saw no tree on any real keystroke. Every existing test missed it by
631    /// constructing a `GrammarEnv` by hand with the snapshot already in it —
632    /// which is the shape that passes against the broken version.
633    #[tokio::test(flavor = "multi_thread")]
634    async fn the_syntax_snapshot_reaches_a_text_object() {
635        use lattice_grammar::{CommandInvocation, TextObjectSpec};
636        use lattice_protocol::CancellationToken;
637        use std::sync::Mutex;
638
639        // Any `Send + Sync` payload will do: the actor's job is to pass the
640        // handle through untouched, and the trampoline downcasts it. Using a
641        // stand-in rather than a real `SyntaxSnapshot` keeps `lattice-runtime`
642        // syntax-free, which is the reason the field is type-erased at all.
643        #[derive(Debug, PartialEq)]
644        struct Marker(u32);
645
646        // Outer Option = "apply ran at all"; inner = what it downcast to.
647        let observed: Arc<Mutex<Option<Option<u32>>>> = Arc::new(Mutex::new(None));
648        let probe = observed.clone();
649
650        let mut registry = CommandRegistry::new();
651        let tobj = registry.register_text_object(
652            "test-syntax-probe",
653            "OT.4 test: records the syntax handle it is handed",
654            TextObjectSpec {
655                apply: Arc::new(move |ctx| {
656                    let seen = ctx
657                        .syntax
658                        .and_then(|any| any.clone().downcast::<Marker>().ok())
659                        .map(|m| m.0);
660                    *probe.lock().unwrap() = Some(seen);
661                    Ok(lattice_protocol::position::Range::empty(ctx.at))
662                }),
663                args_schema: Vec::new(),
664            },
665        );
666
667        let handle = spawn_document(
668            lattice_core::BufferId(0),
669            Document::from_text("fn a() {}\n"),
670            Arc::new(arc_swap::ArcSwap::from_pointee(registry)),
671        );
672
673        handle
674            .dispatch_with_env(
675                CommandInvocation::of(tobj.0),
676                Position::ZERO,
677                CancellationToken::never(),
678                crate::document::DispatchEnv {
679                    syntax: Some(Arc::new(Marker(7))),
680                    ..Default::default()
681                },
682            )
683            .await
684            .unwrap();
685        assert_eq!(
686            *observed.lock().unwrap(),
687            Some(Some(7)),
688            "the snapshot handed to `dispatch_with_env` must reach the text \
689             object's context"
690        );
691
692        // Negative, so a hard-coded `Some` cannot pass either.
693        *observed.lock().unwrap() = None;
694        handle
695            .dispatch_with_cancel(
696                CommandInvocation::of(tobj.0),
697                Position::ZERO,
698                CancellationToken::never(),
699            )
700            .await
701            .unwrap();
702        assert_eq!(
703            *observed.lock().unwrap(),
704            Some(None),
705            "with no snapshot supplied the text object must see None"
706        );
707    }
708}