Skip to main content

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(&registry),
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}