Skip to main content

lattice_host/
fold_provider.rs

1//! D.3.f.0 (2026-05-29): `FoldProvider` substrate.
2//!
3//! See `docs/dev/architecture/fold-architecture.md` for the
4//! full design; capsule:
5//!
6//! - One primary provider runs per `recompute_folds()` call,
7//!   keyed on `:set foldmethod=`.
8//! - All overlay providers run on every `recompute_folds()`.
9//! - Providers are pure functions of [`FoldContext`]; the
10//!   registry pre-loads inputs.
11//!
12//! This module ships the trait + the registry; the five
13//! built-in primary providers wrap the existing
14//! [`crate::folds`] helpers. Overlays are mode-owned (DX.3-C7):
15//! `diff-mode` registers a `HunkFoldSource` and `MultibufferMode`
16//! its excerpt / file-boundary sources via the `FoldOverlayService`.
17
18use std::collections::HashMap;
19use std::path::Path;
20use std::sync::Arc;
21
22use lattice_core::{Buffer, BufferId, Fold, FoldMethod, IndentUnit, ProviderId, ProviderKind};
23use lattice_syntax::SyntaxSnapshot;
24
25/// D.3.f.0: per-recompute inputs passed to every provider.
26///
27/// The registry pre-loads what providers might need so each
28/// `FoldProvider::compute` is a pure function — no
29/// back-references to `Editor`, no `&mut` state. Fields are
30/// `Option` because not every provider needs every input
31/// (the indent provider ignores `syntax`; the LSP provider
32/// reads only `lsp_folds`; etc.).
33///
34/// DX.3-C7 (2026-06-24): the `diff_hunks` field is GONE. Diff hunk folds
35/// are no longer a context-driven primary/overlay reading
36/// `FoldContext::diff_hunks`; they are a mode-owned
37/// [`lattice_core::FoldSource`] (`HunkFoldSource`) that holds its own
38/// `DiffSession`, registered by `diff-mode::on_activate` via the
39/// `FoldOverlayService`. The host fold substrate no longer depends on
40/// `lattice-diff`.
41pub struct FoldContext<'a> {
42    pub buffer: &'a Buffer,
43    /// IG.5: this buffer's resolved indent level.
44    ///
45    /// `foldmethod=indent` measures depth in DISPLAY COLUMNS, so it needs
46    /// `tabstop` to compare a tab-indented line against a space-indented one.
47    /// Before IG.5 it counted whitespace characters and folded tab-indented
48    /// files at the wrong boundaries; sharing the walk with indentation
49    /// guides (`lattice_core::indent_blocks`) is what made the omission
50    /// visible, since the two would otherwise disagree about the same block.
51    pub indent: IndentUnit,
52    pub buffer_id: BufferId,
53    pub path: Option<&'a Path>,
54    pub syntax: Option<&'a SyntaxSnapshot>,
55    pub lsp_folds: Option<&'a [Fold]>,
56}
57
58/// D.3.f.0: a registered source of folds.
59///
60/// `id()` is stable across recomputes and namespaces the
61/// provider's identity hashes (so two providers that emit
62/// folds with the same `(start_line, end_line)` don't
63/// collide on `Fold::identity`).
64pub trait FoldProvider: Send + Sync {
65    fn id(&self) -> ProviderId;
66    fn kind(&self) -> ProviderKind;
67    fn compute(&self, ctx: &FoldContext<'_>) -> Vec<Fold>;
68}
69
70/// D.3.f.0: per-`Editor` fold-provider registry.
71///
72/// One primary per `FoldMethod` (built-ins registered at
73/// construction); overlays added/removed by their owning
74/// subsystems (`DiffSubsystem` for hunk folds, multibuffer
75/// subsystem for excerpt / file-boundary folds in M.7 / M.8).
76pub struct FoldRegistry {
77    primaries: HashMap<FoldMethod, Arc<dyn FoldProvider>>,
78    overlays: Vec<Arc<dyn FoldProvider>>,
79}
80
81impl std::fmt::Debug for FoldRegistry {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("FoldRegistry")
84            .field("primaries", &self.primaries.keys().collect::<Vec<_>>())
85            .field(
86                "overlays",
87                &self.overlays.iter().map(|p| p.id()).collect::<Vec<_>>(),
88            )
89            .finish()
90    }
91}
92
93impl FoldRegistry {
94    /// Construct with the five built-in primary providers
95    /// already registered. Caller adds overlays as their
96    /// subsystems come online.
97    pub fn with_builtins() -> Self {
98        use crate::folds::{
99            IndentPrimary, LspPrimary, ManualPrimary, MarkdownPrimary, SyntaxPrimary,
100        };
101        let mut primaries: HashMap<FoldMethod, Arc<dyn FoldProvider>> = HashMap::new();
102        primaries.insert(FoldMethod::Manual, Arc::new(ManualPrimary));
103        primaries.insert(FoldMethod::Indent, Arc::new(IndentPrimary));
104        primaries.insert(FoldMethod::Markdown, Arc::new(MarkdownPrimary));
105        primaries.insert(FoldMethod::Syntax, Arc::new(SyntaxPrimary));
106        primaries.insert(FoldMethod::Lsp, Arc::new(LspPrimary));
107        // DX.3-C7 (2026-06-24): NO overlays pre-seeded. Overlay fold
108        // sources are mode-owned — `diff-mode::on_activate` registers the
109        // per-buffer `HunkFoldSource` via the `FoldOverlayService`, and
110        // `MultibufferMode` registers its excerpt / file-boundary sources
111        // the same way. The registry starts empty; each subsystem adds
112        // its overlays when its mode activates and removes them on Drop.
113        let overlays: Vec<Arc<dyn FoldProvider>> = Vec::new();
114        Self {
115            primaries,
116            overlays,
117        }
118    }
119
120    /// Look up the primary provider for `method`. Always
121    /// returns `Some` for the five built-in methods; future
122    /// plugin-registered methods would populate this map
123    /// too.
124    pub fn primary(&self, method: FoldMethod) -> Option<&Arc<dyn FoldProvider>> {
125        self.primaries.get(&method)
126    }
127
128    /// Register an overlay provider. Called by the
129    /// `FoldOverlayService` when a mode activates (e.g.
130    /// `diff-mode::on_activate` registers a `HunkFoldSource`,
131    /// `MultibufferMode` its excerpt sources). Returns the
132    /// provider's `ProviderId` so the caller can later
133    /// remove it.
134    pub fn add_overlay(&mut self, provider: Arc<dyn FoldProvider>) -> ProviderId {
135        let id = provider.id();
136        // Symmetric remove path uses id; an existing
137        // registration with the same id is a programming
138        // bug — replace it rather than duplicate.
139        if let Some(slot) = self.overlays.iter_mut().find(|p| p.id() == id) {
140            *slot = provider;
141        } else {
142            self.overlays.push(provider);
143        }
144        id
145    }
146
147    /// Remove the overlay with the given id. No-op if not
148    /// registered.
149    pub fn remove_overlay(&mut self, id: ProviderId) {
150        self.overlays.retain(|p| p.id() != id);
151    }
152
153    /// Iterate registered overlays in registration order.
154    pub fn overlays(&self) -> impl Iterator<Item = &Arc<dyn FoldProvider>> {
155        self.overlays.iter()
156    }
157}
158
159impl Default for FoldRegistry {
160    fn default() -> Self {
161        Self::with_builtins()
162    }
163}
164
165/// M.7: wraps a [`lattice_core::FoldSource`] as a `FoldProvider`.
166/// `target_buffer_id` scopes this overlay: `compute()` returns
167/// empty when `ctx.buffer_id` doesn't match, so providers from
168/// multiple simultaneous multibuffers don't bleed into each other.
169pub struct FoldSourceAdapter {
170    source: Arc<dyn lattice_core::FoldSource>,
171    target_buffer_id: BufferId,
172}
173
174impl FoldProvider for FoldSourceAdapter {
175    fn id(&self) -> ProviderId {
176        self.source.id()
177    }
178    fn kind(&self) -> ProviderKind {
179        ProviderKind::Overlay
180    }
181    fn compute(&self, ctx: &FoldContext<'_>) -> Vec<Fold> {
182        if ctx.buffer_id != self.target_buffer_id {
183            return Vec::new();
184        }
185        self.source.compute_folds()
186    }
187}
188
189/// M.7: [`lattice_core::FoldOverlayService`] impl that wraps the
190/// shared `Arc<Mutex<FoldRegistry>>`. `MultibufferMode::on_activate`
191/// obtains this via `ctx.service::<FoldOverlayServiceHandle>()` and
192/// registers `ExcerptFoldProvider` without depending on
193/// `lattice-host`.
194pub struct FoldOverlayServiceImpl {
195    registry: Arc<std::sync::Mutex<FoldRegistry>>,
196}
197
198impl FoldOverlayServiceImpl {
199    pub fn new(registry: Arc<std::sync::Mutex<FoldRegistry>>) -> Self {
200        Self { registry }
201    }
202}
203
204impl lattice_core::FoldOverlayService for FoldOverlayServiceImpl {
205    fn add_source(
206        &self,
207        source: Arc<dyn lattice_core::FoldSource>,
208        buffer_id: BufferId,
209    ) -> ProviderId {
210        self.registry
211            .lock()
212            .expect("fold_registry poisoned")
213            .add_overlay(Arc::new(FoldSourceAdapter {
214                source,
215                target_buffer_id: buffer_id,
216            }))
217    }
218
219    fn remove_source(&self, id: ProviderId) {
220        self.registry
221            .lock()
222            .expect("fold_registry poisoned")
223            .remove_overlay(id);
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    #![allow(clippy::unwrap_used)]
230    use super::*;
231
232    struct StaticOverlay {
233        id: ProviderId,
234        folds: Vec<Fold>,
235    }
236
237    impl FoldProvider for StaticOverlay {
238        fn id(&self) -> ProviderId {
239            self.id
240        }
241        fn kind(&self) -> ProviderKind {
242            ProviderKind::Overlay
243        }
244        fn compute(&self, _ctx: &FoldContext<'_>) -> Vec<Fold> {
245            self.folds.clone()
246        }
247    }
248
249    #[test]
250    fn builtins_register_all_five_primaries() {
251        let r = FoldRegistry::with_builtins();
252        for fm in [
253            FoldMethod::Manual,
254            FoldMethod::Indent,
255            FoldMethod::Markdown,
256            FoldMethod::Syntax,
257            FoldMethod::Lsp,
258        ] {
259            assert!(r.primary(fm).is_some(), "missing primary for {:?}", fm);
260        }
261    }
262
263    #[test]
264    fn overlay_add_and_remove_round_trip() {
265        let mut r = FoldRegistry::with_builtins();
266        // DX.3-C7: `with_builtins` no longer pre-seeds any overlay
267        // (baseline is 0); overlays are mode-owned. Computing `baseline`
268        // keeps the assertions robust regardless.
269        let baseline = r.overlays().count();
270        let id = r.add_overlay(Arc::new(StaticOverlay {
271            id: ProviderId(42),
272            folds: vec![],
273        }));
274        assert_eq!(id, ProviderId(42));
275        assert_eq!(r.overlays().count(), baseline + 1);
276        r.remove_overlay(ProviderId(42));
277        assert_eq!(r.overlays().count(), baseline);
278    }
279
280    #[test]
281    fn add_overlay_with_existing_id_replaces() {
282        let mut r = FoldRegistry::with_builtins();
283        let baseline = r.overlays().count();
284        r.add_overlay(Arc::new(StaticOverlay {
285            id: ProviderId(7),
286            folds: vec![Fold {
287                start_line: 0,
288                end_line: 1,
289                closed: false,
290                identity: None,
291            }],
292        }));
293        r.add_overlay(Arc::new(StaticOverlay {
294            id: ProviderId(7),
295            folds: vec![],
296        }));
297        assert_eq!(
298            r.overlays().count(),
299            baseline + 1,
300            "same-id overlay must replace, not duplicate"
301        );
302    }
303
304    #[test]
305    fn with_builtins_has_no_overlays() {
306        // DX.3-C7: overlays are mode-owned now — the hunk-fold source is
307        // registered by `diff-mode::on_activate`, not pre-seeded here.
308        let r = FoldRegistry::with_builtins();
309        assert_eq!(
310            r.overlays().count(),
311            0,
312            "with_builtins must pre-seed no overlays; they are mode-owned"
313        );
314    }
315}