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}