lattice_multibuffer/registry.rs
1//! M.2.b.2 (2026-06-01): typed handle lookup for multibuffer views.
2//!
3//! `BufferStore::handle_for(id)` returns `Arc<dyn Document>` (the
4//! kind-agnostic dispatch surface). Providers that need to call
5//! typed methods on the multibuffer handle (`append_excerpts`,
6//! `replace_excerpts`, `set_headerline` once M.4 lands) reach the
7//! concrete `Arc<MultibufferDocumentHandle>` through this
8//! registry — keeps `Document` clean (no `Any` / downcast
9//! contamination) and isolates the multibuffer-specific lookup to
10//! this crate.
11//!
12//! Same precedent shape as `lattice_terminal::TerminalStoreHandle`.
13//!
14//! Cleanup runs through a `DocumentClosed` typed-event subscriber
15//! wired in [`crate::mode::register_multibuffer_modes`]; when a
16//! multibuffer view's underlying buffer closes, the registry
17//! entry is removed.
18
19use std::collections::HashMap;
20use std::sync::{Arc, RwLock};
21
22use lattice_core::BufferId;
23use lattice_protocol::ids::DocumentId;
24
25use crate::MultibufferDocumentHandle;
26
27/// Typed handle lookup for active multibuffer views, keyed by
28/// the view buffer's `BufferId`. Registered as a service in
29/// `ServiceRegistry` at boot.
30pub trait MultibufferRegistry: Send + Sync + std::fmt::Debug {
31 /// Return the typed handle for `view` if registered, else
32 /// `None`. Cheap-clone: `Arc::clone`.
33 fn handle(&self, view: BufferId) -> Option<Arc<MultibufferDocumentHandle>>;
34
35 /// Register `handle` against `view`. Called by
36 /// `create_multibuffer_view` after the handle is built.
37 /// Overwrites if the view id already had a handle (last-write-
38 /// wins; production code allocates fresh ids per view so a
39 /// collision is a developer bug, not a hot-swap).
40 fn insert(&self, view: BufferId, handle: Arc<MultibufferDocumentHandle>);
41
42 /// Remove the entry for `view`. Idempotent: removing a
43 /// non-existent entry is a no-op.
44 fn remove(&self, view: BufferId);
45
46 /// Remove the entry whose handle reports `document_id` as
47 /// its `MultibufferDocumentHandle::document_id`. Called by
48 /// the `DocumentClosed` subscriber (the event payload carries
49 /// `DocumentId`, not `BufferId`). Returns `true` if an entry
50 /// was removed. `O(n)` over active views; multibuffer counts
51 /// are small so the walk is fine.
52 fn remove_by_document_id(&self, document_id: DocumentId) -> bool;
53
54 /// Count of currently-registered views. Test-friendly probe.
55 fn len(&self) -> usize;
56
57 /// OA.23b: every registered view id.
58 ///
59 /// What lets a bare SOURCE id be resolved back to the view that owns it.
60 /// A source is not a buffer the user opened — the store has never heard of
61 /// it (see [`MultibufferExcerptSource`]) — so the only way to find its
62 /// document is to ask the views. View counts are small; the walk is fine.
63 fn view_ids(&self) -> Vec<BufferId>;
64}
65
66/// Cheap-clone Arc'd alias matching the existing service-handle
67/// convention (`BufferStoreHandle`, `LspSupervisorHandle`,
68/// `TerminalStoreHandle`).
69pub type MultibufferRegistryHandle = Arc<dyn MultibufferRegistry>;
70
71/// Default in-memory `MultibufferRegistry` implementation. One
72/// `RwLock<HashMap>` indexed by view BufferId. Lookups take the
73/// read lock (no contention with parallel readers); insert /
74/// remove take the write lock (rare paths — view creation + view
75/// close).
76#[derive(Debug, Default)]
77pub struct InMemoryMultibufferRegistry {
78 inner: RwLock<HashMap<BufferId, Arc<MultibufferDocumentHandle>>>,
79}
80
81impl InMemoryMultibufferRegistry {
82 pub fn new() -> Self {
83 Self::default()
84 }
85
86 /// Construct the registry as a service handle (cheap-clone
87 /// Arc'd trait object) ready for `ServiceRegistry::register`.
88 pub fn handle() -> MultibufferRegistryHandle {
89 Arc::new(Self::new())
90 }
91}
92
93impl MultibufferRegistry for InMemoryMultibufferRegistry {
94 fn handle(&self, view: BufferId) -> Option<Arc<MultibufferDocumentHandle>> {
95 self.inner
96 .read()
97 .expect("MultibufferRegistry RwLock poisoned")
98 .get(&view)
99 .cloned()
100 }
101
102 fn insert(&self, view: BufferId, handle: Arc<MultibufferDocumentHandle>) {
103 self.inner
104 .write()
105 .expect("MultibufferRegistry RwLock poisoned")
106 .insert(view, handle);
107 }
108
109 fn remove(&self, view: BufferId) {
110 self.inner
111 .write()
112 .expect("MultibufferRegistry RwLock poisoned")
113 .remove(&view);
114 }
115
116 fn remove_by_document_id(&self, document_id: DocumentId) -> bool {
117 let mut guard = self
118 .inner
119 .write()
120 .expect("MultibufferRegistry RwLock poisoned");
121 let Some(view) = guard
122 .iter()
123 .find(|(_, h)| h.document_id() == document_id)
124 .map(|(view, _)| *view)
125 else {
126 return false;
127 };
128 guard.remove(&view).is_some()
129 }
130
131 fn len(&self) -> usize {
132 self.inner
133 .read()
134 .expect("MultibufferRegistry RwLock poisoned")
135 .len()
136 }
137
138 fn view_ids(&self) -> Vec<BufferId> {
139 self.inner
140 .read()
141 .expect("MultibufferRegistry RwLock poisoned")
142 .keys()
143 .copied()
144 .collect()
145 }
146}
147
148/// OA.23b: the view that owns `source`, across every registered view.
149///
150/// `None` when no view has it — an ordinary answer, because the caller is
151/// acting on an id a guest handed back and a view can close between the two.
152pub fn view_owning_source(
153 views: &MultibufferRegistryHandle,
154 source: BufferId,
155) -> Option<Arc<MultibufferDocumentHandle>> {
156 views
157 .view_ids()
158 .into_iter()
159 .filter_map(|view| views.handle(view))
160 .find(|handle| handle.has_source(source))
161}
162
163#[cfg(test)]
164mod tests {
165 #![allow(clippy::unwrap_used)]
166 use super::*;
167
168 fn empty_handle() -> Arc<MultibufferDocumentHandle> {
169 Arc::new(MultibufferDocumentHandle::empty(Arc::new(
170 arc_swap::ArcSwap::from_pointee(lattice_grammar::CommandRegistry::new()),
171 )))
172 }
173
174 #[test]
175 fn insert_and_lookup_roundtrip() {
176 let reg = InMemoryMultibufferRegistry::new();
177 let handle = empty_handle();
178 let id = handle.buffer_id();
179 reg.insert(id, handle.clone());
180 let looked_up = reg.handle(id).unwrap();
181 assert!(Arc::ptr_eq(&handle, &looked_up));
182 assert_eq!(reg.len(), 1);
183 }
184
185 #[test]
186 fn missing_lookup_returns_none() {
187 let reg = InMemoryMultibufferRegistry::new();
188 assert!(reg.handle(BufferId(99)).is_none());
189 }
190
191 #[test]
192 fn remove_clears_entry() {
193 let reg = InMemoryMultibufferRegistry::new();
194 let handle = empty_handle();
195 let id = handle.buffer_id();
196 reg.insert(id, handle);
197 assert_eq!(reg.len(), 1);
198 reg.remove(id);
199 assert_eq!(reg.len(), 0);
200 assert!(reg.handle(id).is_none());
201 }
202
203 #[test]
204 fn remove_missing_id_is_noop() {
205 let reg = InMemoryMultibufferRegistry::new();
206 reg.remove(BufferId(123));
207 assert_eq!(reg.len(), 0);
208 }
209
210 #[test]
211 fn remove_by_document_id_finds_and_drops_entry() {
212 let reg = InMemoryMultibufferRegistry::new();
213 let handle = empty_handle();
214 let view = handle.buffer_id();
215 let doc = handle.document_id();
216 reg.insert(view, handle);
217 assert!(reg.remove_by_document_id(doc));
218 assert_eq!(reg.len(), 0);
219 // Idempotent second call.
220 assert!(!reg.remove_by_document_id(doc));
221 }
222}
223
224/// OA.23 — the [`ExcerptSourceResolver`](lattice_core::ExcerptSourceResolver)
225/// a multibuffer-aware host wires into the plugin host.
226///
227/// One handle, because a view answers the whole question: the registry maps a
228/// view to its excerpts (composed line → source buffer + line), and every
229/// source is an `Arc<dyn Document>` that carries its own path.
230///
231/// It first took a `BufferStoreHandle` too, on the reasoning that the registry
232/// could say *which buffer* and never *which file*. That was wrong, and wrong
233/// in the way that costs a whole slice: a **scan view** — the agenda — mints
234/// its sources with `BufferId::next()` and hands them to `add_source`, never to
235/// `BufferStore::insert_document_buffer`, because they are not buffers the user
236/// opened. So the store answered `none` for every agenda row, and the only
237/// tests were the two `none` paths, which pass either way. See
238/// `a_scan_views_source_resolves_by_the_documents_own_path`.
239///
240/// Lives here rather than in the plugin host because this crate owns the
241/// composed→source translation; the host cannot depend on it (layering), which
242/// is why the trait is abstract in `lattice-core` at all.
243#[derive(Clone)]
244pub struct MultibufferExcerptSource {
245 views: MultibufferRegistryHandle,
246}
247
248impl MultibufferExcerptSource {
249 pub fn new(views: MultibufferRegistryHandle) -> Self {
250 Self { views }
251 }
252}
253
254impl std::fmt::Debug for MultibufferExcerptSource {
255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 // Neither handle has a useful Debug and both are shared state; the
257 // trait requires one, so this says what it is and stops.
258 f.write_str("MultibufferExcerptSource")
259 }
260}
261
262impl lattice_core::ExcerptSourceResolver for MultibufferExcerptSource {
263 fn excerpt_source(&self, buffer: BufferId, line: u32) -> Option<lattice_core::ExcerptSource> {
264 // Not a multibuffer: `none`, not the buffer's own path. The question is
265 // "which file does this COMPOSED line come from", and a caller wanting
266 // the current file already has one.
267 let view = self.views.handle(buffer)?;
268 // Byte 0 — the translation is line-wise and the caller asked about a
269 // line. Passing a real column would invite the answer to depend on it.
270 let (source, position) =
271 view.translate_composed_to_source(crate::Position { line, byte: 0 })?;
272 // The VIEW's source map, not the buffer store. A scan view mints its
273 // sources with `BufferId::next()` and registers them with `add_source`
274 // alone — they are not buffers the user opened and have no business in
275 // `:ls`, so the store has never heard of them. Asking the store was
276 // this seam's original shape and it answered `none` for every real
277 // agenda row: the one case it exists for.
278 let path = view.source_path(source)?;
279 Some(lattice_core::ExcerptSource {
280 source,
281 path,
282 line: position.line,
283 })
284 }
285
286 fn source_line(&self, source: BufferId, line: u32) -> Option<String> {
287 view_owning_source(&self.views, source)?.source_line(source, line)
288 }
289}
290
291#[cfg(test)]
292mod excerpt_source_tests {
293 #![allow(clippy::unwrap_used, clippy::panic)]
294 use super::*;
295 use lattice_core::ExcerptSourceResolver;
296 use std::collections::HashMap;
297 use std::path::PathBuf;
298
299 #[test]
300 fn a_buffer_that_is_not_a_multibuffer_answers_none() {
301 // Not its own path: the question is "which file does this COMPOSED
302 // line come from", and a caller wanting the current file already has
303 // `document.path()`. Answering the view's own path here would make the
304 // agenda's synthetic buffer look like a file on disk.
305 let views = InMemoryMultibufferRegistry::handle();
306 let r = MultibufferExcerptSource::new(views);
307 assert_eq!(r.excerpt_source(BufferId::next(), 0), None);
308 }
309
310 #[test]
311 fn an_unwired_line_beyond_every_excerpt_answers_none() {
312 // A cursor can be anywhere, including past the last row. That is an
313 // ordinary answer rather than an error, which is why the seam returns
314 // an option rather than a result.
315 let views = InMemoryMultibufferRegistry::handle();
316 let r = MultibufferExcerptSource::new(views);
317 assert_eq!(r.excerpt_source(BufferId::next(), 9_999), None);
318 }
319
320 /// The case the seam exists for, and the one the two `None` tests above
321 /// could not catch: a view built the way a **scan view** builds one.
322 ///
323 /// `scan_view::append_sorted` mints its source ids with `BufferId::next()`
324 /// and hands the documents to `add_source` — the multibuffer's own source
325 /// map. It never calls `BufferStore::insert_document_buffer`, because a
326 /// scan's sources are not buffers the user opened and have no business in
327 /// `:ls`. So a resolver asking the BUFFER STORE for the path answers `none`
328 /// for every real agenda row, which is the only shape anybody asks about.
329 ///
330 /// The source document carries its own path, so the view alone can answer.
331 #[tokio::test(flavor = "multi_thread")]
332 async fn a_scan_views_source_resolves_by_the_documents_own_path() {
333 let (views, view, source) = scan_shaped_view().await;
334 let r = MultibufferExcerptSource::new(views);
335 assert_eq!(
336 r.excerpt_source(view, 0),
337 Some(lattice_core::ExcerptSource {
338 source,
339 path: PathBuf::from("/org/notes.org"),
340 line: 2,
341 }),
342 );
343 }
344
345 /// OA.23b: the id handed back is the one to ACT on.
346 ///
347 /// `path` and `source` are not two spellings of the same thing. The path
348 /// names a file the editor may separately have open as the user's own
349 /// buffer; the source names the document the VIEW owns and `:w` saves.
350 /// A caller that took the path and wrote to the file by name would be
351 /// editing the other one.
352 #[tokio::test(flavor = "multi_thread")]
353 async fn the_answer_names_the_source_document_not_just_its_file() {
354 let (views, view, source) = scan_shaped_view().await;
355 let r = MultibufferExcerptSource::new(Arc::clone(&views));
356 let found = r.excerpt_source(view, 0).expect("a row resolves");
357 assert_eq!(found.source, source);
358 assert!(
359 view_owning_source(&views, found.source).is_some(),
360 "the id must resolve back to the view that owns it, or nothing can act on it"
361 );
362 }
363
364 /// The read half. The line the agenda's `s` cares about is the one BELOW
365 /// the headline — line 1 here — which no excerpt composes, so neither the
366 /// view's text nor the guest's own document can show it.
367 #[tokio::test(flavor = "multi_thread")]
368 async fn a_source_line_outside_every_excerpt_is_readable() {
369 let (views, _view, source) = scan_shaped_view().await;
370 let r = MultibufferExcerptSource::new(views);
371 assert_eq!(
372 r.source_line(source, 1).as_deref(),
373 Some(" SCHEDULED: <2026-09-03>"),
374 "the planning line is outside the excerpt and must still be readable"
375 );
376 assert_eq!(r.source_line(source, 0).as_deref(), Some("* TODO write it"));
377 // Past the last line, and an id no view owns: ordinary `none`s.
378 assert_eq!(r.source_line(source, 99), None);
379 assert_eq!(r.source_line(BufferId::next(), 0), None);
380 }
381
382 /// A view that does not own the id must not answer for it. With one view
383 /// registered the walk cannot distinguish "found it" from "guessed"; two
384 /// can.
385 #[tokio::test(flavor = "multi_thread")]
386 async fn a_source_resolves_to_the_view_that_owns_it() {
387 let (views_a, _va, source_a) = scan_shaped_view().await;
388 let (views_b, view_b, source_b) = scan_shaped_view().await;
389 // One registry holding both views.
390 let both = views_a;
391 both.insert(
392 view_b,
393 views_b.handle(view_b).expect("second view registered"),
394 );
395
396 let a = view_owning_source(&both, source_a).expect("view a owns source a");
397 let b = view_owning_source(&both, source_b).expect("view b owns source b");
398 assert!(a.has_source(source_a) && !a.has_source(source_b));
399 assert!(b.has_source(source_b) && !b.has_source(source_a));
400 assert!(
401 view_owning_source(&both, BufferId::next()).is_none(),
402 "an id no view owns must answer none, not the first view in the walk"
403 );
404 }
405
406 /// A view shaped the way `scan_view::append_sorted` shapes one: the source
407 /// lives in the view's own map and nowhere else. Returns the registry, the
408 /// view id and the source id.
409 async fn scan_shaped_view() -> (MultibufferRegistryHandle, BufferId, BufferId) {
410 let source = BufferId::next();
411 let doc = lattice_core::DocumentBuilder::default()
412 .with_text("* TODO write it\n SCHEDULED: <2026-09-03>\n* TODO and it\n")
413 .with_path(PathBuf::from("/org/notes.org"))
414 .build();
415 let registry = Arc::new(arc_swap::ArcSwap::from_pointee(
416 lattice_grammar::CommandRegistry::new(),
417 ));
418 let handle: Arc<dyn lattice_runtime::Document> = Arc::new(lattice_runtime::spawn_document(
419 source,
420 doc,
421 Arc::clone(®istry),
422 ));
423
424 // Excerpt the THIRD line, so a wrong line answer cannot pass by
425 // returning 0 and a wrong read cannot pass by reading the excerpt.
426 let mb = Arc::new(
427 crate::MultibufferDocumentHandle::new(
428 HashMap::from([(source, handle)]),
429 vec![crate::Excerpt::new(source, 2, 2)],
430 registry,
431 )
432 .expect("view builds"),
433 );
434 let view = BufferId::next();
435 let views = InMemoryMultibufferRegistry::handle();
436 views.insert(view, mb);
437 (views, view, source)
438 }
439}