lattice_runtime/document.rs
1//! M.0 (2026-05-31): `Document` trait — the handle-layer
2//! abstraction over a buffer that the rest of the editor talks
3//! to.
4//!
5//! Today's `RopeDocumentHandle` (a rope-backed actor handle) impls
6//! this trait; M.1 lands `MultibufferDocumentHandle` as a sibling
7//! impl composing N source handles. Dispatch / motion / render
8//! code paths hold `Arc<dyn Document>` so they serve both kinds
9//! without kind-branching at the buffer boundary.
10//!
11//! See `docs/dev/architecture/multibuffer-views.md` §3.1 for the
12//! design and the Path-A/B/C alternatives that were considered
13//! and rejected.
14//!
15//! ## Slot replacement is the only path
16//!
17//! There is intentionally no `replace(...)` method on this trait.
18//! Per the M.0 design (§3.1 "Why no `replace`"), "the active
19//! document changes" is expressed by replacing the slot's
20//! `Arc<dyn Document>` with a freshly spawned handle, not by
21//! mutating the existing handle in place. The old handle drops
22//! when its last Arc reference goes away; the actor task exits
23//! cleanly through its mailbox-close branch. One uniform code
24//! path for `:edit foo` / `:edit bar` / regular ↔ multibuffer
25//! transitions / `:b N` switches.
26//!
27//! ## Object safety
28//!
29//! Trait is `dyn`-safe: `Arc<dyn Document>` is the canonical
30//! reference shape. All methods take `&self` (writes return
31//! `Pending<T>` which round-trips the impl's internal write
32//! path — actor mpsc for `RopeDocumentHandle`, fan-out for
33//! `MultibufferDocumentHandle`).
34//!
35//! Most read methods carry default implementations derived from
36//! `snapshot()` so impls only need to provide `snapshot()` plus
37//! the write methods. `MultibufferDocumentHandle` may override
38//! `text()` if it can compose more cheaply than `snapshot()
39//! .text()`.
40
41use std::path::PathBuf;
42use std::sync::Arc;
43
44use lattice_grammar::{CancellationToken, CommandInvocation, Effect};
45use lattice_protocol::edit::Edit;
46use lattice_protocol::ids::DocumentId;
47use lattice_protocol::position::Position;
48use lattice_protocol::selection::SelectionSet;
49
50use crate::actor::AppliedEdit;
51use crate::handle::RopeDocumentHandle;
52use crate::pending::Pending;
53use crate::snapshot::{DocumentSnapshot, SnapshotCache};
54
55/// N.1.4b (2026-06-10): a sharable, thread-safe scope resolver
56/// handle threaded through grammar dispatch. The host wraps the
57/// active document's `Arc<SyntaxSnapshot>` (which impls
58/// `lattice_grammar::ScopeResolver`) in this alias, bundles it into a
59/// [`DispatchEnv`], and passes it to [`Document::dispatch_with_env`];
60/// the actor hands it to `execute_with_env` so tree-sitter text objects
61/// (af/ac/aa/al) resolve against the live tree. `Send + Sync`
62/// because the actor message crosses the dispatch channel onto the
63/// document-actor thread. The bare `ScopeResolver` trait lives in
64/// `lattice-grammar` (tree-sitter-agnostic); this threading-layer
65/// alias adds the `Arc + Send + Sync` shape per the handle
66/// convention (cf. `CommandRegistryHandle`).
67pub type ScopeResolverHandle = Arc<dyn lattice_grammar::ScopeResolver + Send + Sync>;
68
69/// IN.7: the same handle convention for the `=` operator's per-line
70/// indent source. Owned + `Send + Sync` so it can cross the actor
71/// channel; the grammar side sees a bare `&dyn IndentResolver`.
72pub type IndentResolverHandle = Arc<dyn lattice_grammar::IndentResolver + Send + Sync>;
73
74/// VM.3i: the same handle convention for `zj` / `zk`'s fold edges. The host
75/// builds one from its fold table for a dispatch; the grammar sees a bare
76/// `&dyn FoldResolver`.
77pub type FoldResolverHandle = Arc<dyn lattice_grammar::FoldResolver + Send + Sync>;
78/// VM.3e: owned mark table for `'x` / `` `x ``, like [`FoldResolverHandle`].
79pub type MarkResolverHandle = Arc<dyn lattice_grammar::MarkResolver + Send + Sync>;
80/// VM.3f: owned window lines for `H` / `M` / `L`, like [`FoldResolverHandle`].
81pub type ViewportResolverHandle = Arc<dyn lattice_grammar::ViewportResolver + Send + Sync>;
82/// VM.3g-2: owned display geometry for `gj` / `gk` / `g0` / `g$`.
83pub type DisplayResolverHandle = Arc<dyn lattice_grammar::DisplayResolver + Send + Sync>;
84
85/// N.1.6 (2026-06-10): the owned, thread-safe per-dispatch environment
86/// for text objects, carried across the actor channel. Bundles the
87/// inputs a text object's `apply` may read — the tree-sitter
88/// `scope_resolver` (`af`/`ac`, N.1.4) and the `comment_syntax`
89/// (`aC`/`iC`, N.1.6) — so the dispatch seam threads ONE value instead
90/// of a widening parameter list (the long-term-fit choice over parallel
91/// params). The actor borrows these into a `lattice_grammar::GrammarEnv`
92/// at the `execute_with_env` call. `Default` is the no-input case.
93#[derive(Clone, Default)]
94pub struct DispatchEnv {
95 pub scope_resolver: Option<ScopeResolverHandle>,
96 pub comment_syntax: Option<Arc<lattice_grammar::CommentSyntax>>,
97 /// OT.4: the buffer's tree-sitter snapshot, type-erased so
98 /// `lattice-runtime` stays syntax-free (the plugin trampoline
99 /// downcasts it back to an `Arc<SyntaxSnapshot>`).
100 ///
101 /// Distinct from `scope_resolver` rather than replacing it, and the
102 /// distinction is why this field had to exist. The resolver answers
103 /// "what scope encloses this point" for the NATIVE structural objects
104 /// and is deliberately erased to that one question — the concrete
105 /// snapshot cannot be recovered from it, so it cannot mint a plugin's
106 /// `tree-snapshot` resource.
107 ///
108 /// TS.1 left this out because grammar *actions* were the only
109 /// tree-snapshot consumer, and those dispatch through the host's Action
110 /// gate rather than this channel. OT.1 made motions and text objects
111 /// consumers too, and they come through here — so without this field
112 /// org's `ar` / `]]` / `g{` received `none` on every real keystroke
113 /// while every test that built a `GrammarEnv` by hand passed.
114 pub syntax: Option<Arc<dyn std::any::Any + Send + Sync>>,
115 /// IN.0: one level of indentation, resolved by the host from
116 /// `shiftwidth` / `expandtab` / `tabstop`. Read by `>` / `<`.
117 /// `Default` is the registered option defaults, so the actor path
118 /// indents like an unconfigured buffer rather than not at all.
119 pub indent: lattice_core::IndentUnit,
120 /// IN.7: per-line indent depth for `=`. `None` means no
121 /// structural source, and `=` reindents nothing rather than
122 /// guessing.
123 pub indent_resolver: Option<IndentResolverHandle>,
124 /// RF.2: the buffer's `textwidth`, for the reflow operator. Carried
125 /// here for the reason `syntax` and `selection` had to be: `gq`
126 /// reaches the grammar through the ACTOR path on every real
127 /// keystroke, so a field missing here is a field the operator never
128 /// sees no matter how well the direct path is tested.
129 ///
130 /// `WrapWidth`, not `usize`, so this struct keeps its derived
131 /// `Default` and still means 80 rather than 0.
132 pub textwidth: lattice_core::WrapWidth,
133 /// RF.5b: which formatting intents this buffer handles natively.
134 /// Carried for the reason `textwidth` is: `=` and `gq` reach the
135 /// grammar through the ACTOR path on every real keystroke.
136 pub native_format: lattice_grammar::registry::NativeFormatIntents,
137 /// OS.2: the active region — the Visual/Select selection extent, or `None`
138 /// outside Visual/Select.
139 ///
140 /// Carried here for the reason `syntax` above had to be: an action reached
141 /// through the ACTOR path builds its `ActionContext` from this env, so a
142 /// hard `None` would make a plugin's Visual action see no region on every
143 /// real keystroke while every test that hand-built a `GrammarEnv` passed.
144 /// That is the OT.4 failure verbatim, and it is cheaper to carry the field
145 /// than to rediscover it.
146 pub selection: Option<lattice_protocol::position::Range>,
147 /// VM.3c: the last `f` / `F` / `t` / `T`, so `;` and `,` can be motions.
148 ///
149 /// Same reasoning as every field above it: `;` is an ordinary motion on
150 /// the keystroke path, so it comes through the actor, so the state it
151 /// needs has to travel with the dispatch. `None` is the pre-first-`f`
152 /// state and makes `;` a no-op, which is what vim does.
153 pub last_find: Option<lattice_grammar::LastFind>,
154 /// VM.3i: the fold edges `zj` / `zk` step between. Same reasoning as
155 /// `last_find`: they're motions on the keystroke path, so they come
156 /// through the actor. `None` for a buffer with no folds.
157 pub fold_resolver: Option<FoldResolverHandle>,
158 /// VM.3d-2: the last search, for `n` / `N` / `*` / `#`, which are motions
159 /// on the keystroke path and so come through the actor too.
160 pub last_search: Option<lattice_grammar::LastSearch>,
161 /// VM.3e: the mark table, for `'x` / `` `x ``, which come through the
162 /// actor like every keystroke motion. `None` when no mark is set.
163 pub marks: Option<MarkResolverHandle>,
164 /// VM.3f: the lines the window shows, for `H` / `M` / `L`. The host builds
165 /// it only when the invocation is one of them.
166 pub viewport: Option<ViewportResolverHandle>,
167 /// VM.3f: `!startofline` — `H` / `M` / `L` keep the cursor's column.
168 pub nostartofline: bool,
169 /// VM.3f: `scrolloff`, the margin `H` / `L` stop short of the window's
170 /// edges by. `0` puts them on the first and last lines shown.
171 pub scrolloff: u32,
172 /// VM.3g-1: vim's `curswant`, the column `j` / `k` aim for across short
173 /// lines. Carried like every other keystroke input: `j` comes through the
174 /// actor on every press.
175 pub curswant: Option<lattice_grammar::Curswant>,
176 /// VM.3g-2: display geometry for `gj` / `gk` / `g0` / `g$`, built only when
177 /// the invocation is one of them.
178 pub display: Option<DisplayResolverHandle>,
179 /// VM.3g-3: where a motion reports the goal column it AIMED at, which for
180 /// `gj` / `gk` is not the column it reached — the display row it lands on
181 /// may be too short, and vim keeps the aim (measured: a clamped `gj`
182 /// landing at column 100 records `curswant` 160).
183 ///
184 /// Owned rather than borrowed, unlike its `GrammarEnv` counterpart, and
185 /// that is forced rather than chosen: this struct crosses the `Document`
186 /// trait into a `Pending<Effect>`, so a borrow could not outlive the
187 /// future. The actor borrows it back out at the `execute_with_env` call,
188 /// which is the same shape every other field here already has.
189 ///
190 /// `None` — the default — discards the report, which is right for every
191 /// caller that keeps no goal column.
192 pub curswant_out: Option<Arc<std::sync::Mutex<Option<lattice_grammar::Curswant>>>>,
193}
194
195/// Handle-layer abstraction over a buffer. See module docs.
196///
197/// `Debug` is a supertrait so containers holding `dyn Document`
198/// (e.g., `lattice_host::buffer_registry::DocumentEntry`) can
199/// derive `Debug` without hand-rolling per-field formatters.
200pub trait Document: Send + Sync + 'static + std::fmt::Debug {
201 // ---- Reads (wait-free, snapshot-backed) ----
202
203 /// Load the current snapshot. The returned `Arc` lives as
204 /// long as the caller needs it; subsequent publishes don't
205 /// invalidate it.
206 fn snapshot(&self) -> Arc<DocumentSnapshot>;
207
208 /// Per-thread cache for hot loops that load the snapshot
209 /// many times between edits. The cache reduces per-load cost
210 /// from ~17 ns to ~2 ns when the writer hasn't published
211 /// since the last load.
212 fn snapshot_cache(&self) -> SnapshotCache;
213
214 fn id(&self) -> DocumentId {
215 self.snapshot().id
216 }
217
218 /// Rendered text. Allocates — prefer `snapshot().buffer
219 /// .as_string()` on a held snapshot when looping.
220 fn text(&self) -> String {
221 self.snapshot().text()
222 }
223
224 fn path(&self) -> Option<PathBuf> {
225 self.snapshot().path.as_ref().map(|a| (**a).clone())
226 }
227
228 fn dirty(&self) -> bool {
229 self.snapshot().dirty
230 }
231
232 fn version(&self) -> u64 {
233 self.snapshot().version
234 }
235
236 fn text_version(&self) -> u64 {
237 self.snapshot().text_version
238 }
239
240 fn selections(&self) -> Arc<SelectionSet> {
241 self.snapshot().selections.clone()
242 }
243
244 // ---- Writes (enqueue + Pending) ----
245
246 fn apply_edit(&self, edit: Edit) -> Pending<AppliedEdit>;
247
248 fn apply_edit_batch(&self, edits: Vec<Edit>) -> Pending<Vec<AppliedEdit>>;
249
250 fn undo(&self) -> Pending<Vec<AppliedEdit>>;
251
252 fn redo(&self) -> Pending<Vec<AppliedEdit>>;
253
254 fn save(&self) -> Pending<PathBuf>;
255
256 fn save_as(&self, path: PathBuf) -> Pending<()>;
257
258 fn set_selections(&self, selections: SelectionSet) -> Pending<()>;
259
260 /// Open an undo-coalescing group so a run of edits (a vim insert
261 /// session: `i`/`a`/`o`/`cw` .. `<Esc>`) collapses into a single
262 /// undo unit until [`Self::end_undo_group`]. Fire-and-forget:
263 /// ordering against the following edits is the impl's responsibility
264 /// (the actor mailbox is FIFO), so there is nothing to await.
265 ///
266 /// Default no-op. Buffer kinds without a first-class undo stack --
267 /// the `MultibufferDocumentHandle` and the default placeholder
268 /// handle -- ignore grouping; `RopeDocumentHandle` overrides both to
269 /// signal its actor. A no-op default keeps grouping non-regressive
270 /// for those kinds (their edits remain individually undoable, as
271 /// today) without every impl having to opt in.
272 fn begin_undo_group(&self) {}
273
274 /// Close the group opened by [`Self::begin_undo_group`]. Default
275 /// no-op; see that method.
276 fn end_undo_group(&self) {}
277
278 // ---- Grammar dispatch ----
279
280 /// Dispatch a [`CommandInvocation`] through the impl's
281 /// internal grammar execution path. `MultibufferDocument
282 /// Handle` (M.1) routes the invocation through its
283 /// row-translation table to the underlying source(s).
284 fn dispatch_with_cancel(
285 &self,
286 invocation: CommandInvocation,
287 cursor: Position,
288 cancel: CancellationToken,
289 ) -> Pending<Effect>;
290
291 /// N.1.4b / N.1.6 (2026-06-10): dispatch carrying a [`DispatchEnv`]
292 /// (tree-sitter `scope_resolver` for af/ac/aa/al + `comment_syntax`
293 /// for aC/iC) so the grammar resolves those text objects against the
294 /// caller's live syntax + language. The default impl ignores the env
295 /// and delegates to [`Self::dispatch_with_cancel`] -- correct for
296 /// buffer kinds with no syntax / no leader (oil, terminal,
297 /// plain-language). `RopeDocumentHandle` overrides it to forward the
298 /// env into the actor's `execute_with_env` call; the multibuffer
299 /// overrides `dispatch_with_cancel` (N.1.5) and ignores this env.
300 fn dispatch_with_env(
301 &self,
302 invocation: CommandInvocation,
303 cursor: Position,
304 cancel: CancellationToken,
305 env: DispatchEnv,
306 ) -> Pending<Effect> {
307 let _ = env;
308 self.dispatch_with_cancel(invocation, cursor, cancel)
309 }
310
311 /// Convenience: dispatch with a never-cancelled token.
312 fn dispatch(&self, invocation: CommandInvocation, cursor: Position) -> Pending<Effect> {
313 self.dispatch_with_cancel(invocation, cursor, CancellationToken::never())
314 }
315
316 /// K.4.6 follow-up (2026-06-02): per-composed-row source line
317 /// number lookup for the gutter. `None` (default) = identity:
318 /// the composed-row index IS the source line number, so the
319 /// gutter formats `composed_row` directly. `Some(arr)` =
320 /// composed→source map: `arr[composed_row]` gives the source
321 /// line number to display.
322 ///
323 /// Regular `RopeDocumentHandle` keeps the default None impl —
324 /// for a single-file buffer, composed_row == source_row.
325 /// `MultibufferDocumentHandle` (lattice-multibuffer) overrides
326 /// to return the flattened `RowTranslation` so the gutter
327 /// shows the original file's line numbers (e.g. 429, 430,
328 /// 432 — skipping non-hit rows) rather than the meaningless
329 /// composed indices (0, 1, 2).
330 ///
331 /// Substrate-aligned per [[feedback_buffers_no_special_case]]:
332 /// the publisher reads `self.document.display_line_numbers()`
333 /// uniformly across all BufferKinds; no renderer-side or
334 /// publish-side kind branch needed.
335 fn display_line_numbers(&self) -> Option<Arc<[u32]>> {
336 None
337 }
338
339 /// K.4.7: per-excerpt highlight entries for multibuffer panes.
340 /// Default returns empty — regular single-file documents carry
341 /// no excerpt structure. `MultibufferDocumentHandle` overrides
342 /// to return one entry per excerpt that has a `SyntaxHandle`.
343 ///
344 /// The cells worker calls this uniformly on every pane's document;
345 /// no `BufferKind` branch needed in `publish_render_state`.
346 fn excerpt_highlights(&self) -> Vec<lattice_cells::ExcerptHighlight> {
347 Vec::new()
348 }
349
350 /// Monotonic counter that bumps whenever the excerpt highlight set
351 /// changes (new sources added, lang registry wired). Default: 0.
352 fn excerpt_syntax_version(&self) -> u64 {
353 0
354 }
355}
356
357/// M.0 (2026-05-31): the active-document slot held by
358/// `Editor.document`. Wraps an `Arc<dyn Document>` so that:
359///
360/// * The slot can hold either a regular rope-backed handle
361/// (today: `RopeDocumentHandle`, renamed to `RopeDocumentHandle`
362/// in M.0 Phase E) or a `MultibufferDocumentHandle` (M.1)
363/// without kind-branching at the use site — dispatch /
364/// motion / render code paths just call `Document` trait
365/// methods through `Deref<Target = dyn Document>`.
366///
367/// * The slot impls `Default` (initialised to a placeholder
368/// rope handle via `RopeDocumentHandle::default()`) so consumers
369/// that `#[derive(Default)]` over a struct containing this
370/// field work without hand-rolling the impl. The
371/// placeholder's actor receiver is closed immediately at
372/// construction, so any traffic sent through the placeholder
373/// reports `RuntimeError::ActorGone` — production code
374/// overwrites the slot before any real traffic flows.
375///
376/// * The newtype is cheap to clone (one atomic refcount bump
377/// on the inner `Arc`).
378///
379/// `Arc<dyn Document>` directly cannot implement `Default`
380/// (orphan rule on `Arc` + `dyn Trait: !Sized`), so this
381/// newtype is the smallest viable wrapper that keeps the
382/// derive ergonomics intact without forcing a manual
383/// `impl Default` over every struct that holds the slot.
384#[derive(Clone)]
385pub struct ActiveDocument(Arc<dyn Document>);
386
387impl ActiveDocument {
388 /// Wrap a concrete document handle. The handle must impl
389 /// `Document` (today: `RopeDocumentHandle` / future
390 /// `RopeDocumentHandle`; M.1: `MultibufferDocumentHandle`).
391 pub fn new<D: Document>(handle: D) -> Self {
392 Self(Arc::new(handle))
393 }
394
395 /// Wrap an already-`Arc`'d handle. Useful when an
396 /// `Arc<dyn Document>` is constructed elsewhere (e.g., by
397 /// the buffer registry) and the slot just takes ownership
398 /// of it.
399 pub fn from_arc(arc: Arc<dyn Document>) -> Self {
400 Self(arc)
401 }
402
403 /// Cheap clone of the inner `Arc`. Use when handing the
404 /// reference off to a long-lived consumer that wants to
405 /// store it independently.
406 pub fn as_arc(&self) -> Arc<dyn Document> {
407 Arc::clone(&self.0)
408 }
409}
410
411impl Default for ActiveDocument {
412 fn default() -> Self {
413 Self(Arc::new(RopeDocumentHandle::default()))
414 }
415}
416
417impl std::ops::Deref for ActiveDocument {
418 type Target = dyn Document;
419
420 fn deref(&self) -> &Self::Target {
421 &*self.0
422 }
423}
424
425impl std::fmt::Debug for ActiveDocument {
426 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
427 f.debug_tuple("ActiveDocument").field(&self.0.id()).finish()
428 }
429}