Skip to main content

lattice_mode/
locals.rs

1//! Buffer-local mode-internal state — Shape A from
2//! `mode-architecture.md` §9.4 (M.3.2.a).
3//!
4//! A typed analogue of emacs's `buffer-local-variables`. Each
5//! piece of mode-internal data declares an `OptionDecl`-style
6//! type identity via the [`BufferLocal`] trait; the typed-map
7//! [`BufferLocals`] stores them keyed by `TypeId` for O(1)
8//! type-keyed reads. Each entry carries metadata (display name,
9//! doc, owner mode) so `:describe-buffer` can enumerate every
10//! local a buffer carries grouped by its owning mode.
11//!
12//! ## Distinction from options
13//!
14//! Buffer-locals are *runtime data the mode owns*, not user-
15//! configurable values. They store opaque Rust structs
16//! (`SyntaxHandle`, `Vec<FileTreeEntry>`, `Vec<Link>`, ...)
17//! that don't have string-parseable forms and shouldn't appear
18//! in `:set` autocomplete. The user can inspect them via
19//! `:describe-buffer` but never edit them via `:set` /
20//! `:customize`.
21//!
22//! ## Ownership and the `OWNER_MODE` rule
23//!
24//! Each local declares the mode that owns it
25//! ([`BufferLocal::OWNER_MODE`]). The design (M.3.2.a) has
26//! [`crate::ModeContext`] check at write time that the *currently
27//! activating* mode matches the local's owner, rejecting cross-mode
28//! mutation with
29//! [`ModeActivationError::WrongOwnerMode`](crate::ModeActivationError::WrongOwnerMode).
30//! **That checked write surface is not implemented:** `ModeContext` has
31//! no local accessors today, writes happen host-side through
32//! [`BufferLocals::insert`] (unchecked), and `OWNER_MODE` is attribution
33//! metadata for `:describe-buffer`. Treat the rule as a convention — write
34//! only the locals your mode owns.
35//!
36//! Reads are unrestricted: any mode can read any local. This
37//! lets, e.g., `lsp-completion-mode` read `file-tree-mode`'s
38//! entries to populate path completion without a special-case
39//! handshake.
40//!
41//! ## What does NOT live here
42//!
43//! - **Universal buffer state** (rope, cursor, scroll,
44//!   version) -- direct fields on whatever struct holds them.
45//!   Buffer-locals are for *mode-specific* runtime data.
46//! - **User-facing options** -- those are `OptionDecl` in
47//!   `lattice-config`. See `mode-architecture.md` §6.4.
48//! - **Declarative contributions** (option overrides, keymap
49//!   layers, decoration providers) -- modes return these from
50//!   `Mode::options()` etc.; the registry applies them. The
51//!   mode never writes to them directly.
52//!
53//! ## Storage shape
54//!
55//! `BufferLocals` is a `HashMap<TypeId, Box<dyn LocalDyn>>`.
56//! `LocalDyn` is a sealed inner trait that lets us:
57//!
58//! - Read metadata (`name`, `doc`, `owner_mode`, `describe`)
59//!   without knowing the concrete type — for `:describe-buffer`.
60//! - Downcast back to the concrete `T` for typed reads /
61//!   removes — for the typed accessors.
62
63use std::any::{Any, TypeId};
64use std::collections::HashMap;
65
66/// Compile-time declaration of a mode-owned per-buffer local.
67///
68/// Implementing types are typically newtypes wrapping the
69/// underlying data:
70///
71/// ```
72/// use lattice_mode::{BufferLocal, BufferLocals};
73///
74/// #[derive(Clone)]
75/// pub struct FileTreeEntries(pub Vec<String>);
76///
77/// impl BufferLocal for FileTreeEntries {
78///     const NAME: &'static str = "file-tree.entries";
79///     const DOC: &'static str = "Tree-of-files entries for this buffer.";
80///     const OWNER_MODE: &'static str = "file-tree-mode";
81///     fn describe(&self) -> String {
82///         format!("{} entries", self.0.len())
83///     }
84/// }
85///
86/// let mut locals = BufferLocals::new();
87/// locals.insert(FileTreeEntries(vec!["src/".into(), "Cargo.toml".into()]));
88/// assert_eq!(locals.get::<FileTreeEntries>().unwrap().0.len(), 2);
89///
90/// // `:describe-buffer` sees it without knowing the concrete type.
91/// let d = locals.iter_descriptors().next().unwrap();
92/// assert_eq!((d.name, d.owner_mode, d.describe.as_str()), ("file-tree.entries", "file-tree-mode", "2 entries"));
93/// ```
94///
95/// `'static` bound: locals key on `TypeId`, which requires the
96/// type to be `'static`. `Send + Sync` so a buffer can be
97/// shared across threads.
98/// Slice 3c.final.B.9: `Clone` is required so the typed-map
99/// can be deep-cloned for the `BufferLocalsRenderState` per-publish
100/// snapshot. Every existing impl is already a wrapper around
101/// Clone primitives (`Vec<T>` / `PathBuf` / scalars), so the bound
102/// adds no real constraint — just lets `LocalDyn::clone_box` work
103/// through the dyn trait object.
104pub trait BufferLocal: Any + Clone + Send + Sync + 'static {
105    /// Public display name (`:describe-buffer` row label,
106    /// debug logs). Convention: `<owner-mode-name>.<key>`,
107    /// e.g. `"file-tree.entries"`. Not a registry key — locals
108    /// aren't registered globally; the name is metadata for
109    /// inspection.
110    const NAME: &'static str;
111
112    /// Doc string. Shown in `:describe-buffer` when the user
113    /// expands a local for detail.
114    const DOC: &'static str;
115
116    /// Mode id that owns this local. Attribution metadata for
117    /// `:describe-buffer`; the write-time check the design calls for
118    /// (a `ModeContext::set_local::<T>` that rejects a non-owner) is not
119    /// implemented, so nothing enforces it today (see the module docs).
120    const OWNER_MODE: &'static str;
121
122    /// Single-line summary of the local's value for
123    /// `:describe-buffer`'s tabular display. Implementations
124    /// should be cheap (no heavy formatting); detailed
125    /// inspection is via the mode's own commands.
126    fn describe(&self) -> String;
127}
128
129/// Sealed inner trait: object-safe view of [`BufferLocal`]
130/// that the typed-map can store as `Box<dyn LocalDyn>`.
131///
132/// Not part of the public API — consumers implement
133/// `BufferLocal`, the blanket impl below provides `LocalDyn`.
134pub(crate) trait LocalDyn: Any + Send + Sync {
135    fn name(&self) -> &'static str;
136    fn doc(&self) -> &'static str;
137    fn owner_mode(&self) -> &'static str;
138    fn describe(&self) -> String;
139    fn as_any(&self) -> &dyn Any;
140    // Designed-in API: `as_any_mut` for owner-mode in-place
141    // mutation, `into_any` for the `remove<T>` typed downcast.
142    // Both reach `pub(crate)` BufferLocals methods that aren't
143    // yet wired into an owner mode; flagging would penalise a
144    // deliberate completeness choice.
145    #[allow(dead_code)]
146    fn as_any_mut(&mut self) -> &mut dyn Any;
147    #[allow(dead_code)]
148    fn into_any(self: Box<Self>) -> Box<dyn Any + Send + Sync>;
149    /// Slice 3c.final.B.9: clone the inner local into a fresh
150    /// `Box<dyn LocalDyn>` so [`BufferLocals`] (and the
151    /// `BufferLocalsRenderState` lift on top of it) can be
152    /// cloned per publish.
153    fn clone_box(&self) -> Box<dyn LocalDyn>;
154}
155
156impl<T: BufferLocal> LocalDyn for T {
157    fn name(&self) -> &'static str {
158        T::NAME
159    }
160    fn doc(&self) -> &'static str {
161        T::DOC
162    }
163    fn owner_mode(&self) -> &'static str {
164        T::OWNER_MODE
165    }
166    fn describe(&self) -> String {
167        BufferLocal::describe(self)
168    }
169    fn as_any(&self) -> &dyn Any {
170        self
171    }
172    fn as_any_mut(&mut self) -> &mut dyn Any {
173        self
174    }
175    fn into_any(self: Box<Self>) -> Box<dyn Any + Send + Sync> {
176        self
177    }
178    fn clone_box(&self) -> Box<dyn LocalDyn> {
179        Box::new(self.clone())
180    }
181}
182
183/// Read-only descriptor for one buffer-local, returned by
184/// [`BufferLocals::iter_descriptors`] for `:describe-buffer`.
185#[derive(Debug, Clone)]
186pub struct LocalDescriptor {
187    /// [`BufferLocal::NAME`].
188    pub name: &'static str,
189    /// [`BufferLocal::DOC`].
190    pub doc: &'static str,
191    /// [`BufferLocal::OWNER_MODE`].
192    pub owner_mode: &'static str,
193    /// The value's [`BufferLocal::describe`] summary, computed when the
194    /// descriptor was produced.
195    pub describe: String,
196}
197
198/// Typed-map of buffer-local mode-internal state.
199///
200/// Stored on per-buffer host state, one map per buffer. The host writes
201/// entries (seeding at buffer construction, or on a mode's behalf — e.g.
202/// [`ModeActivator::set_buffer_scope_dir`](crate::ModeActivator::set_buffer_scope_dir));
203/// anyone reads via [`Self::get`]. Removal is `pub(crate)` and not yet
204/// wired to any caller.
205#[derive(Default)]
206pub struct BufferLocals {
207    map: HashMap<TypeId, Box<dyn LocalDyn>>,
208}
209
210impl std::fmt::Debug for BufferLocals {
211    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212        f.debug_struct("BufferLocals")
213            .field("len", &self.map.len())
214            .finish_non_exhaustive()
215    }
216}
217
218impl Clone for BufferLocals {
219    /// Slice 3c.final.B.9: walks the typed-map and `clone_box`'s
220    /// each entry so the whole `BufferLocals` can be deep-cloned
221    /// for `BufferLocalsRenderState` publishes.
222    fn clone(&self) -> Self {
223        let map = self.map.iter().map(|(k, v)| (*k, v.clone_box())).collect();
224        Self { map }
225    }
226}
227
228impl BufferLocals {
229    /// An empty map.
230    pub fn new() -> Self {
231        Self::default()
232    }
233
234    /// Number of locals stored.
235    pub fn len(&self) -> usize {
236        self.map.len()
237    }
238
239    /// True when no local is stored.
240    pub fn is_empty(&self) -> bool {
241        self.map.is_empty()
242    }
243
244    /// Insert (or replace) the local of type `T`. Public so
245    /// the App can seed locals at buffer-construction time
246    /// (e.g. help-mode parsing the markdown links into
247    /// `HelpLinks` when a help buffer is constructed -- the
248    /// "owner" semantically is help-mode, but the App is the
249    /// caller because parsing lives in the constructor).
250    ///
251    /// The owner-mode check is intentionally NOT enforced
252    /// here; the design gives that job to a checked
253    /// `ModeContext::set_local` for *active modes' runtime writes*,
254    /// which does not exist yet. App-level
255    /// construction-time seeding is a separate path: the App
256    /// is presumed to insert locals owned by the buffer's
257    /// eventual major mode, and the local's `OWNER_MODE`
258    /// field is metadata for `:describe-buffer` attribution
259    /// rather than an access-control mechanism on this
260    /// surface. Mirrors emacs's `setq-local` -- any code can
261    /// set a buffer-local; the major mode's claim of
262    /// ownership is by convention.
263    pub fn insert<T: BufferLocal>(&mut self, value: T) {
264        self.map.insert(TypeId::of::<T>(), Box::new(value));
265    }
266
267    /// Read the local of type `T` if present.
268    pub fn get<T: BufferLocal>(&self) -> Option<&T> {
269        self.map
270            .get(&TypeId::of::<T>())
271            .and_then(|entry| entry.as_any().downcast_ref::<T>())
272    }
273
274    /// Mutably borrow the local of type `T`. Used by the
275    /// owner mode during `on_activate` if it needs to mutate
276    /// in place (avoids the take/restore dance). `pub(crate)`
277    /// so external code goes through the context's checked
278    /// surface. Designed-in API: no owner-mode wired today.
279    #[allow(dead_code)]
280    pub(crate) fn get_mut<T: BufferLocal>(&mut self) -> Option<&mut T> {
281        self.map
282            .get_mut(&TypeId::of::<T>())
283            .and_then(|entry| entry.as_any_mut().downcast_mut::<T>())
284    }
285
286    /// Remove and return the local of type `T`. `pub(crate)`
287    /// so removal goes through the context's owner-mode check.
288    /// Designed-in API: no owner-mode wired today.
289    #[allow(dead_code)]
290    pub(crate) fn remove<T: BufferLocal>(&mut self) -> Option<T> {
291        let entry = self.map.remove(&TypeId::of::<T>())?;
292        entry.into_any().downcast::<T>().ok().map(|b| *b)
293    }
294
295    /// Iterate over descriptors for inspection. Walks every
296    /// local with its display metadata + a single-line
297    /// summary. Used by `:describe-buffer` to enumerate all
298    /// state on a buffer grouped by owner mode.
299    pub fn iter_descriptors(&self) -> impl Iterator<Item = LocalDescriptor> + '_ {
300        self.map.values().map(|entry| LocalDescriptor {
301            name: entry.name(),
302            doc: entry.doc(),
303            owner_mode: entry.owner_mode(),
304            describe: entry.describe(),
305        })
306    }
307
308    /// True if a local of type `T` is currently stored.
309    pub fn contains<T: BufferLocal>(&self) -> bool {
310        self.map.contains_key(&TypeId::of::<T>())
311    }
312}
313
314/// The directory a buffer is *about*, when that is not its own path.
315///
316/// A magit status buffer, an oil listing, a file tree, a search or agenda
317/// view — none of them is a file, so none has a path, and every one of them
318/// still belongs somewhere. Without this the editor's project resolution
319/// takes its only other branch and answers with the **process working
320/// directory**, so `:files` in a magit buffer for `~/work/api` listed
321/// whatever tree the editor happened to be launched in.
322///
323/// ## A directory, not a project root
324///
325/// The writer records where the buffer *is*; the host resolves that to a
326/// project through the ordinary `ProjectResolverHandle`. So an oil buffer on
327/// `/repo/src` records `/repo/src` and `:files` still lists `/repo` — the
328/// provider does not have to know what a project is, and the two notions
329/// cannot drift apart by being recorded twice.
330///
331/// ## Universal, hence `text-mode`
332///
333/// Owned by no single mode, like `text-mode.extra-highlights`: any buffer may
334/// have one and most do not. Written through
335/// [`ModeActivator::set_buffer_scope_dir`](crate::ModeActivator::set_buffer_scope_dir)
336/// so a provider sets it from its trigger, where it already knows the answer.
337#[derive(Debug, Clone, PartialEq, Eq)]
338pub struct BufferScopeDir(pub std::path::PathBuf);
339
340impl BufferLocal for BufferScopeDir {
341    const NAME: &'static str = "text-mode.scope-dir";
342    const DOC: &'static str = "The directory this buffer is about, for a buffer that is not \
343         itself a file — a magit repository, an oil listing's directory, a \
344         file tree's root, a search or agenda view's scan root. Project \
345         resolution (`:files`, `:search`) reads it before falling back to \
346         the buffer's own path, and then to the working directory.";
347    const OWNER_MODE: &'static str = "text-mode";
348    fn describe(&self) -> String {
349        self.0.display().to_string()
350    }
351}
352
353/// A provider's answer to "which directory is the buffer *called this*
354/// about?", asked by the host the moment it creates a synthetic buffer.
355///
356/// ## Why by name, and why a pull rather than a push
357///
358/// A provider that opens its buffer with `Effect::OpenSyntheticBuffer` never
359/// touches the buffer: it returns a name and a mode id, and the host does the
360/// rest. So it has no `BufferId` to attach a
361/// [`BufferScopeDir`](crate::BufferScopeDir) to, and by the time its mode's
362/// `on_activate` runs there is no `&mut` reach into the buffer-local map
363/// either.
364///
365/// It does, however, know the directory *before* the buffer exists — that is
366/// the whole reason magit's `RepoScopes` is keyed by name rather than by id.
367/// So the host asks, at creation, with the one thing both sides have: the
368/// name.
369///
370/// The alternative was a `scope_dir` field on `Effect::OpenSyntheticBuffer`,
371/// which is ABI churn on a widely-constructed effect (and on its WIT peer) to
372/// carry data most callers do not have.
373pub trait BufferScopeSource: Send + Sync + std::fmt::Debug {
374    /// The directory a buffer named `buffer_name` is about, if this source
375    /// knows. `None` for a name it does not recognise — every source is asked
376    /// and most will not know.
377    fn scope_dir_for_name(&self, buffer_name: &str) -> Option<std::path::PathBuf>;
378}
379
380/// Registered [`BufferScopeSource`]s. A `Vec` rather than a single handle
381/// because two providers naming buffers is normal and a single slot would let
382/// the second registration silently displace the first.
383#[derive(Default, Clone)]
384pub struct BufferScopeSourceRegistry {
385    sources: Vec<std::sync::Arc<dyn BufferScopeSource>>,
386}
387
388impl std::fmt::Debug for BufferScopeSourceRegistry {
389    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390        f.debug_struct("BufferScopeSourceRegistry")
391            .field("sources", &self.sources.len())
392            .finish()
393    }
394}
395
396impl BufferScopeSourceRegistry {
397    /// An empty registry.
398    pub fn new() -> Self {
399        Self::default()
400    }
401
402    /// Add a source. Sources are asked in registration order; nothing is
403    /// deduplicated.
404    pub fn register(&mut self, source: std::sync::Arc<dyn BufferScopeSource>) {
405        self.sources.push(source);
406    }
407
408    /// The first source that recognises `buffer_name`. First-answer-wins:
409    /// two providers claiming one name is a naming collision they have to
410    /// resolve between themselves, and picking arbitrarily is no worse than
411    /// picking last.
412    pub fn scope_dir_for_name(&self, buffer_name: &str) -> Option<std::path::PathBuf> {
413        self.sources
414            .iter()
415            .find_map(|s| s.scope_dir_for_name(buffer_name))
416    }
417
418    /// Number of registered sources.
419    pub fn len(&self) -> usize {
420        self.sources.len()
421    }
422
423    /// True when no source is registered.
424    pub fn is_empty(&self) -> bool {
425        self.sources.is_empty()
426    }
427}
428
429/// Register **and** look up with this exact alias (the `ServiceRegistry`
430/// `TypeId` rule).
431pub type BufferScopeSourceRegistryHandle =
432    std::sync::Arc<arc_swap::ArcSwap<BufferScopeSourceRegistry>>;
433
434#[cfg(test)]
435mod tests {
436    #![allow(clippy::unwrap_used, clippy::panic)]
437    use super::*;
438
439    // Test fixture: one mode-owned local.
440    #[derive(Clone)]
441    struct TestEntries(Vec<String>);
442
443    impl BufferLocal for TestEntries {
444        const NAME: &'static str = "test.entries";
445        const DOC: &'static str = "Test fixture for buffer-locals.";
446        const OWNER_MODE: &'static str = "test-mode";
447        fn describe(&self) -> String {
448            format!("{} entries", self.0.len())
449        }
450    }
451
452    // A second fixture for distinct-key tests.
453    #[derive(Clone)]
454    struct OtherFixture(i64);
455
456    impl BufferLocal for OtherFixture {
457        const NAME: &'static str = "test.other";
458        const DOC: &'static str = "Second fixture.";
459        const OWNER_MODE: &'static str = "other-mode";
460        fn describe(&self) -> String {
461            format!("value={}", self.0)
462        }
463    }
464
465    #[test]
466    fn empty_locals_have_no_entries() {
467        let l = BufferLocals::new();
468        assert!(l.is_empty());
469        assert_eq!(l.len(), 0);
470        assert!(l.get::<TestEntries>().is_none());
471    }
472
473    #[test]
474    fn insert_get_round_trips() {
475        let mut l = BufferLocals::new();
476        l.insert(TestEntries(vec!["a".into(), "b".into()]));
477        let got = l.get::<TestEntries>().expect("present");
478        assert_eq!(got.0.len(), 2);
479        assert_eq!(got.0[0], "a");
480        assert!(l.contains::<TestEntries>());
481    }
482
483    #[test]
484    fn second_insert_replaces() {
485        let mut l = BufferLocals::new();
486        l.insert(TestEntries(vec!["a".into()]));
487        l.insert(TestEntries(vec!["x".into(), "y".into(), "z".into()]));
488        assert_eq!(l.get::<TestEntries>().unwrap().0.len(), 3);
489        assert_eq!(l.len(), 1);
490    }
491
492    #[test]
493    fn get_mut_returns_mutable_reference() {
494        let mut l = BufferLocals::new();
495        l.insert(TestEntries(vec!["a".into()]));
496        l.get_mut::<TestEntries>().unwrap().0.push("b".into());
497        assert_eq!(l.get::<TestEntries>().unwrap().0.len(), 2);
498    }
499
500    #[test]
501    fn remove_returns_owned_value() {
502        let mut l = BufferLocals::new();
503        l.insert(TestEntries(vec!["x".into()]));
504        let removed = l.remove::<TestEntries>().expect("present");
505        assert_eq!(removed.0[0], "x");
506        assert!(l.is_empty());
507    }
508
509    #[test]
510    fn distinct_types_coexist() {
511        let mut l = BufferLocals::new();
512        l.insert(TestEntries(vec!["a".into()]));
513        l.insert(OtherFixture(42));
514        assert_eq!(l.len(), 2);
515        assert_eq!(l.get::<TestEntries>().unwrap().0.len(), 1);
516        assert_eq!(l.get::<OtherFixture>().unwrap().0, 42);
517    }
518
519    #[test]
520    fn iter_descriptors_yields_metadata_per_local() {
521        let mut l = BufferLocals::new();
522        l.insert(TestEntries(vec!["a".into()]));
523        l.insert(OtherFixture(7));
524        let mut descriptors: Vec<_> = l.iter_descriptors().collect();
525        descriptors.sort_by(|a, b| a.name.cmp(b.name));
526        assert_eq!(descriptors.len(), 2);
527        assert_eq!(descriptors[0].name, "test.entries");
528        assert_eq!(descriptors[0].owner_mode, "test-mode");
529        assert_eq!(descriptors[0].describe, "1 entries");
530        assert_eq!(descriptors[1].name, "test.other");
531        assert_eq!(descriptors[1].owner_mode, "other-mode");
532        assert_eq!(descriptors[1].describe, "value=7");
533    }
534}