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 ®istry,
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}