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}