lattice_diff/fold.rs
1//! D.3.f.1 (2026-05-29) / DX.3-C7 (2026-06-24): `HunkFoldSource`.
2//!
3//! A [`lattice_core::FoldSource`] that emits one [`Fold`] per non-empty,
4//! multi-line current-side hunk range of a diff session. **Mode-owned**:
5//! `diff-mode`'s `on_activate` constructs one per participating buffer
6//! (holding that buffer's `Arc<DiffSession>`) and registers it via the
7//! `FoldOverlayService`; the mode's `Drop` guard removes it. This is the
8//! same shape multibuffer's `ExcerptFoldProvider` / `FileBoundaryFoldProvider`
9//! use — a self-contained `FoldSource` (no `FoldContext`) wrapped by the
10//! host's `FoldSourceAdapter`, which gates `compute_folds` to the target
11//! buffer. (Before C7 this was a context-driven `FoldProvider` pre-seeded
12//! into `FoldRegistry::with_builtins`, reading `FoldContext::diff_hunks`;
13//! that coupling between the host fold substrate and `lattice-diff` is now
14//! gone.)
15//!
16//! See `docs/dev/architecture/fold-architecture.md` §2 and
17//! `docs/dev/architecture/diff-system.md` §6.5.
18//!
19//! ## Why current-side only
20//!
21//! Hunks classify changes against an earlier baseline; the foldable
22//! region is the *current* document's lines, which live in
23//! `Hunk::ranges[1]`. Pure-`Remove` hunks have an empty current-side
24//! range (no current-side text to fold — the deletion is surfaced via a
25//! virtual row, not a fold). `Add` / `Change` / `Conflict` hunks all have
26//! non-empty current-side ranges, but a single-line hunk (`ranges[1]`
27//! covers exactly one line) is also non-foldable — the `z*` grammar
28//! treats a 1-line fold as a no-op. Filter both cases.
29//!
30//! ## Identity
31//!
32//! `hash(("diff:hunk", start_line, end_line))` is namespaced with the
33//! literal `"diff:hunk"` so that a syntax fold and a hunk fold covering
34//! the same `(start, end)` produce distinct identity hashes. Closed-state
35//! survives across diff publishes when a hunk's range is unchanged.
36
37use std::hash::{DefaultHasher, Hash, Hasher};
38use std::sync::Arc;
39
40use crate::Hunk;
41use lattice_core::{BufferId, Fold, FoldSource, ProviderId};
42
43use crate::subsystem::DiffSession;
44
45/// Namespace for per-buffer hunk-fold provider ids. OR'd with the
46/// buffer's id (low 32 bits) so simultaneous diff buffers register
47/// distinct overlay ids — `FoldOverlayService::add_source` keys removal
48/// on the id, so a shared id would let one buffer's deregistration evict
49/// another's folds. Distinct from multibuffer's `0xBBBB_*` namespaces.
50pub const HUNK_FOLD_NAMESPACE: u64 = 0xD1FF_0001_0000_0000;
51
52/// D-fix.5: namespace for per-buffer *unchanged*-fold provider ids.
53/// Distinct high bits from [`HUNK_FOLD_NAMESPACE`] so a buffer's hunk
54/// fold source and its unchanged fold source register under different
55/// overlay ids (both OR in the buffer's low-32 id) — they coexist on the
56/// same buffer covering disjoint regions (hunks vs. the gaps between).
57pub const UNCHANGED_FOLD_NAMESPACE: u64 = 0xD1FF_0002_0000_0000;
58
59/// D-fix.5: the minimum number of unchanged lines a gap must span before
60/// it is folded (VS Code's `minimumLineCount`). A `Fold` already needs
61/// `end_line > start_line` (≥ 2 lines) to be meaningful, so this is the
62/// natural floor: a 1-line gap between two changes stays visible rather
63/// than collapsing to a fold marker that hides nothing useful.
64const MIN_UNCHANGED_FOLD_LINES: u32 = 2;
65
66/// Compute the stable identity hash for a single hunk fold.
67///
68/// Namespaced with `"diff:hunk"` so the hash doesn't collide
69/// with a primary provider's hash for the same `(start_line,
70/// end_line)`. Two publishes that produce a hunk at the same
71/// span hash identically, so closed-state carries over.
72pub fn hunk_fold_identity(start_line: u32, end_line: u32) -> u64 {
73 let mut h = DefaultHasher::new();
74 "diff:hunk".hash(&mut h);
75 start_line.hash(&mut h);
76 end_line.hash(&mut h);
77 h.finish()
78}
79
80/// DX.3-C7: per-buffer hunk-fold source.
81///
82/// Holds the buffer's `Arc<DiffSession>`; `compute_folds` reads the
83/// session's currently-published `HunkIndex` (lock-free
84/// `current_hunks()`), so folds track every republish without a context.
85/// `diff-mode::on_activate` registers one via `FoldOverlayService`;
86/// `DiffModeGuard::drop` removes it.
87pub struct HunkFoldSource {
88 id: ProviderId,
89 session: Arc<DiffSession>,
90 /// D-fix.5: the `Hunk::ranges` slot this buffer occupies (0 =
91 /// baseline / two-way left, 1 = current / right, 2 = remote). Was
92 /// hard-coded to `1` (current side) when the source only ever
93 /// registered on the session's primary buffer; now that
94 /// `diff-mode::on_activate` registers a source on EVERY participant
95 /// (so both panes fold in lockstep), each folds its OWN side.
96 slot: usize,
97}
98
99impl HunkFoldSource {
100 /// Build a source for `session`, namespaced by `buffer_id` so it is
101 /// distinct from other buffers' hunk-fold sources in the registry.
102 /// `slot` is the buffer's position in `Hunk::ranges` (resolved by
103 /// the mode via `DiffSubsystem::participant_slot`).
104 pub fn new(session: Arc<DiffSession>, buffer_id: BufferId, slot: usize) -> Self {
105 Self {
106 id: ProviderId(HUNK_FOLD_NAMESPACE | buffer_id.0 as u64),
107 session,
108 slot,
109 }
110 }
111}
112
113impl FoldSource for HunkFoldSource {
114 fn id(&self) -> ProviderId {
115 self.id
116 }
117
118 fn compute_folds(&self) -> Vec<Fold> {
119 let hunks = self.session.current_hunks();
120 let slot = self.slot;
121 hunks
122 .hunks
123 .iter()
124 .filter_map(|h| fold_from_hunk(h, slot))
125 .collect()
126 }
127}
128
129/// Translate one hunk's current-side range into a [`Fold`].
130///
131/// Returns `None` when the current-side range is empty (pure
132/// `Remove`) or covers a single line (not meaningfully
133/// foldable). `LineRange::end` is exclusive in
134/// `lattice-diff`; `Fold::end_line` is inclusive in
135/// `lattice-core`, so we subtract one from the end.
136fn fold_from_hunk(hunk: &Hunk, slot: usize) -> Option<Fold> {
137 // D-fix.5: fold the hunk's range on `slot`'s OWN side (was
138 // hard-coded `ranges[1]` when this only ran on the primary).
139 // Defensive: a malformed `HunkIndex` from a buggy upstream — or a
140 // slot out of range for this hunk's arity — is treated as
141 // unfoldable rather than a panic.
142 let range = hunk.ranges.get(slot)?;
143 // Require at least two current-side lines for the fold
144 // to be meaningful — collapsing a single line to itself
145 // is a no-op the `z*` family wouldn't surface.
146 if range.end <= range.start + 1 {
147 return None;
148 }
149 let start_line = range.start;
150 let end_line = range.end - 1;
151 Some(Fold {
152 start_line,
153 end_line,
154 closed: false,
155 identity: Some(hunk_fold_identity(start_line, end_line)),
156 })
157}
158
159// ──────────────────────────────────────────────────────────────
160// D-fix.5: UnchangedFoldSource — the complement of HunkFoldSource
161// ──────────────────────────────────────────────────────────────
162
163/// D-fix.5 (2026-06-26): per-buffer, per-side **unchanged**-fold source
164/// — vimdiff `foldmethod=diff` / VS Code "Collapse Unchanged Regions".
165///
166/// Where [`HunkFoldSource`] folds the *hunks* (open by default — `za`
167/// collapses a change), this folds their **complement**: the unchanged
168/// gaps between hunks, minus a `context`-line window around each change,
169/// **closed by default** so a diff opens showing only the changes. The
170/// two coexist on the same buffer over disjoint line regions.
171///
172/// **Both sides, in lockstep.** `diff-mode::on_activate` registers one
173/// per participant buffer (resolving each side's `slot` via
174/// `DiffSubsystem::participant_slot`), so a side-by-side `:diffsplit` /
175/// openDiff folds the baseline and current panes symmetrically — the
176/// scroll-bound panes stay vertically aligned (folding only one side
177/// would desync them).
178///
179/// Option-gated, read live at `compute_folds` time from the
180/// `ConfigRegistry` service: `ui.diff.fold-unchanged` (default on) +
181/// `ui.diff.context` (default 6). No config service / option absent ⇒
182/// the safe defaults (fold on, context 6) — never a panic.
183pub struct UnchangedFoldSource {
184 id: ProviderId,
185 session: Arc<DiffSession>,
186 /// The `Hunk::ranges` slot this buffer occupies — folds the
187 /// complement on this side. See [`HunkFoldSource::slot`].
188 slot: usize,
189 /// Live config handle for the `ui.diff.*` reads. `None` in test
190 /// harnesses that don't register a `ConfigRegistry` (defaults apply).
191 config: Option<Arc<lattice_config::ConfigRegistry>>,
192}
193
194impl UnchangedFoldSource {
195 /// Build a source for `session` at `slot`, namespaced by `buffer_id`
196 /// (distinct from the buffer's hunk-fold source via
197 /// [`UNCHANGED_FOLD_NAMESPACE`]). `config` is the `ConfigRegistry`
198 /// service handle the mode pulls in `on_activate` (or `None` ⇒
199 /// defaults).
200 pub fn new(
201 session: Arc<DiffSession>,
202 buffer_id: BufferId,
203 slot: usize,
204 config: Option<Arc<lattice_config::ConfigRegistry>>,
205 ) -> Self {
206 Self {
207 id: ProviderId(UNCHANGED_FOLD_NAMESPACE | buffer_id.0 as u64),
208 session,
209 slot,
210 config,
211 }
212 }
213}
214
215impl FoldSource for UnchangedFoldSource {
216 fn id(&self) -> ProviderId {
217 self.id
218 }
219
220 fn compute_folds(&self) -> Vec<Fold> {
221 // Toggle (default on): read live so `:set nofold-unchanged`
222 // takes effect on the next recompute.
223 let enabled = self
224 .config
225 .as_ref()
226 .and_then(|c| c.get_bool_by_name("ui.diff.fold-unchanged"))
227 .unwrap_or(true);
228 if !enabled {
229 return Vec::new();
230 }
231 // Context window (default 6, vimdiff's). A negative value (can't
232 // happen — validator clamps `>= 0`) falls back to the default.
233 let context = self
234 .config
235 .as_ref()
236 .and_then(|c| c.get_int_by_name("ui.diff.context"))
237 .filter(|n| *n >= 0)
238 .map(|n| n as u32)
239 .unwrap_or(6);
240 // No line count yet (no recompute has published) ⇒ nothing to
241 // fold — the complement needs the side's EOF to bound itself.
242 let Some(line_count) = self.session.slot_line_count(self.slot) else {
243 return Vec::new();
244 };
245 let hunks = self.session.current_hunks();
246 compute_unchanged_folds(&hunks.hunks, self.slot, line_count, context)
247 }
248}
249
250/// Compute the stable identity hash for a single unchanged fold.
251/// Namespaced with `"diff:unchanged"` + the slot so it never collides
252/// with a hunk fold (`"diff:hunk"`) or the same span on the other side;
253/// span-keyed so closed-state (a user `zo`) survives a republish that
254/// reproduces the same gap.
255pub fn unchanged_fold_identity(slot: usize, start_line: u32, end_line: u32) -> u64 {
256 let mut h = DefaultHasher::new();
257 "diff:unchanged".hash(&mut h);
258 slot.hash(&mut h);
259 start_line.hash(&mut h);
260 end_line.hash(&mut h);
261 h.finish()
262}
263
264/// D-fix.5: the pure complement-of-hunks geometry — the testable core
265/// of [`UnchangedFoldSource::compute_folds`].
266///
267/// Given the published `hunks`, the `slot` side, that side's
268/// `line_count`, and the `context` window, returns one **closed** fold
269/// per unchanged gap that survives the [`MIN_UNCHANGED_FOLD_LINES`]
270/// floor. The "kept visible" set is each hunk's slot range padded by
271/// `context` (clamped to `[0, line_count)`), merged; the folds are its
272/// complement over `[0, line_count)`.
273///
274/// Graceful edges: empty `hunks` (a clean diff) ⇒ no folds (don't
275/// collapse an identical file into one line); `line_count == 0` ⇒ no
276/// folds; a `slot` out of range for a hunk's arity skips that hunk.
277fn compute_unchanged_folds(
278 hunks: &[Hunk],
279 slot: usize,
280 line_count: u32,
281 context: u32,
282) -> Vec<Fold> {
283 if hunks.is_empty() || line_count == 0 {
284 return Vec::new();
285 }
286 // "Kept visible" windows: each hunk's slot range ± context. An empty
287 // slot range (a pure change on the OTHER side — e.g. the baseline
288 // side of an Add) still anchors a window at its insertion point so
289 // the deletion/insertion marker stays in view.
290 let mut kept: Vec<(u32, u32)> = Vec::new();
291 for h in hunks {
292 let Some(r) = h.ranges.get(slot) else {
293 continue;
294 };
295 let start = r.start.saturating_sub(context);
296 let end = (r.end + context).min(line_count);
297 kept.push((start, end));
298 }
299 if kept.is_empty() {
300 return Vec::new();
301 }
302 // Merge overlapping / touching windows.
303 kept.sort_by_key(|(s, _)| *s);
304 let mut merged: Vec<(u32, u32)> = Vec::with_capacity(kept.len());
305 for (s, e) in kept {
306 match merged.last_mut() {
307 Some(last) if s <= last.1 => last.1 = last.1.max(e),
308 _ => merged.push((s, e)),
309 }
310 }
311 // The complement over [0, line_count) is the unchanged gaps to fold.
312 let mut folds = Vec::new();
313 push_unchanged_gap(&mut folds, slot, 0, merged[0].0); // leading
314 for pair in merged.windows(2) {
315 push_unchanged_gap(&mut folds, slot, pair[0].1, pair[1].0); // between
316 }
317 let last_end = merged[merged.len() - 1].1;
318 push_unchanged_gap(&mut folds, slot, last_end, line_count); // trailing
319 folds
320}
321
322/// Emit a closed unchanged fold for the gap `[gap_start, gap_end)` when
323/// it spans at least [`MIN_UNCHANGED_FOLD_LINES`]. `Fold::end_line` is
324/// inclusive (lattice-core), so it is `gap_end - 1`.
325fn push_unchanged_gap(folds: &mut Vec<Fold>, slot: usize, gap_start: u32, gap_end: u32) {
326 if gap_end.saturating_sub(gap_start) < MIN_UNCHANGED_FOLD_LINES {
327 return;
328 }
329 let start_line = gap_start;
330 let end_line = gap_end - 1;
331 folds.push(Fold {
332 start_line,
333 end_line,
334 closed: true,
335 identity: Some(unchanged_fold_identity(slot, start_line, end_line)),
336 });
337}
338
339#[cfg(test)]
340mod tests {
341 #![allow(clippy::unwrap_used)]
342 use super::*;
343 use crate::{DiffAlgorithm, Hunk, HunkIndex, HunkKind, LineRange};
344 use smallvec::smallvec;
345
346 fn hunk(kind: HunkKind, baseline: LineRange, current: LineRange) -> Hunk {
347 Hunk {
348 kind,
349 ranges: smallvec![baseline, current],
350 refine: Default::default(),
351 }
352 }
353
354 // ── fold_from_hunk: the per-hunk mapping (the real logic) ──────────
355
356 #[test]
357 fn add_hunk_yields_fold_with_inclusive_end() {
358 // Add of current-side lines [2, 5) — 3 lines.
359 let f = fold_from_hunk(
360 &hunk(HunkKind::Add, LineRange::new(2, 2), LineRange::new(2, 5)),
361 1,
362 )
363 .expect("multi-line add is foldable");
364 assert_eq!(f.start_line, 2);
365 assert_eq!(f.end_line, 4, "end_line is inclusive (range.end - 1)");
366 assert!(!f.closed, "freshly-emitted overlay folds start open");
367 assert!(f.identity.is_some());
368 }
369
370 #[test]
371 fn remove_hunk_is_not_foldable() {
372 // Pure Remove: current-side range is empty.
373 assert!(
374 fold_from_hunk(
375 &hunk(HunkKind::Remove, LineRange::new(1, 4), LineRange::new(1, 1)),
376 1,
377 )
378 .is_none(),
379 "pure-Remove hunks have no current-side range to fold"
380 );
381 }
382
383 #[test]
384 fn single_line_hunk_is_not_foldable() {
385 assert!(
386 fold_from_hunk(
387 &hunk(HunkKind::Change, LineRange::new(1, 2), LineRange::new(1, 2)),
388 1,
389 )
390 .is_none(),
391 "single-line hunks aren't foldable"
392 );
393 }
394
395 #[test]
396 fn malformed_hunk_with_missing_current_range_is_skipped() {
397 let h = Hunk {
398 kind: HunkKind::Add,
399 ranges: smallvec![LineRange::new(0, 0)],
400 refine: Default::default(),
401 };
402 assert!(
403 fold_from_hunk(&h, 1).is_none(),
404 "missing current range → no fold, no panic"
405 );
406 }
407
408 #[test]
409 fn identity_is_stable_across_spans_and_distinct_between_spans() {
410 // Span-only hash: a republish that produces the same span hashes
411 // identically (closed-state survives); different spans differ.
412 assert_eq!(hunk_fold_identity(3, 6), hunk_fold_identity(3, 6));
413 assert_ne!(hunk_fold_identity(0, 4), hunk_fold_identity(0, 5));
414 assert_ne!(hunk_fold_identity(0, 4), hunk_fold_identity(1, 4));
415 }
416
417 // ── HunkFoldSource: reads the session's published hunks ────────────
418
419 fn session_with(hunks: Vec<Hunk>) -> Arc<DiffSession> {
420 let session = Arc::new(DiffSession::new(BufferId(1), DiffAlgorithm::Histogram));
421 session.publish(Arc::new(HunkIndex {
422 hunks,
423 algorithm: DiffAlgorithm::Histogram,
424 revision: 1,
425 }));
426 session
427 }
428
429 #[test]
430 fn no_published_hunks_emits_nothing() {
431 let session = Arc::new(DiffSession::new(BufferId(1), DiffAlgorithm::Histogram));
432 let src = HunkFoldSource::new(session, BufferId(1), 1);
433 assert!(src.compute_folds().is_empty());
434 }
435
436 #[test]
437 fn change_and_conflict_hunks_both_foldable() {
438 let src = HunkFoldSource::new(
439 session_with(vec![
440 hunk(HunkKind::Change, LineRange::new(2, 5), LineRange::new(2, 6)),
441 hunk(
442 HunkKind::Conflict,
443 LineRange::new(10, 12),
444 LineRange::new(10, 14),
445 ),
446 ]),
447 BufferId(1),
448 1,
449 );
450 let folds = src.compute_folds();
451 assert_eq!(folds.len(), 2);
452 assert_eq!((folds[0].start_line, folds[0].end_line), (2, 5));
453 assert_eq!((folds[1].start_line, folds[1].end_line), (10, 13));
454 }
455
456 #[test]
457 fn source_reflects_latest_published_hunks() {
458 let session = Arc::new(DiffSession::new(BufferId(1), DiffAlgorithm::Histogram));
459 let src = HunkFoldSource::new(Arc::clone(&session), BufferId(1), 1);
460 assert!(src.compute_folds().is_empty(), "no publish yet → no folds");
461 session.publish(Arc::new(HunkIndex {
462 hunks: vec![hunk(
463 HunkKind::Add,
464 LineRange::new(3, 3),
465 LineRange::new(3, 7),
466 )],
467 algorithm: DiffAlgorithm::Histogram,
468 revision: 1,
469 }));
470 let folds = src.compute_folds();
471 assert_eq!(folds.len(), 1, "compute_folds reads current_hunks live");
472 assert_eq!((folds[0].start_line, folds[0].end_line), (3, 6));
473 }
474
475 #[test]
476 fn id_is_namespaced_per_buffer() {
477 let session = Arc::new(DiffSession::new(BufferId(7), DiffAlgorithm::Histogram));
478 let src = HunkFoldSource::new(session, BufferId(7), 1);
479 assert_eq!(src.id(), ProviderId(HUNK_FOLD_NAMESPACE | 7));
480 }
481
482 // ── HunkFoldSource: per-side (slot) folding ────────────────────────
483
484 #[test]
485 fn hunk_fold_source_folds_its_own_side() {
486 // baseline [2,6) (4 lines) vs current [2,4) (2 lines): the
487 // baseline-slot source (slot 0) folds [2,5]; the current-slot
488 // source (slot 1) folds [2,3]. Each side folds ITS OWN range.
489 let session = session_with(vec![hunk(
490 HunkKind::Change,
491 LineRange::new(2, 6),
492 LineRange::new(2, 4),
493 )]);
494 let baseline = HunkFoldSource::new(Arc::clone(&session), BufferId(1), 0);
495 let current = HunkFoldSource::new(session, BufferId(1), 1);
496 let bf = baseline.compute_folds();
497 let cf = current.compute_folds();
498 assert_eq!((bf[0].start_line, bf[0].end_line), (2, 5), "baseline side");
499 assert_eq!((cf[0].start_line, cf[0].end_line), (2, 3), "current side");
500 }
501
502 // ── UnchangedFoldSource: complement-of-hunks geometry ──────────────
503
504 #[test]
505 fn unchanged_no_hunks_folds_nothing() {
506 // A clean diff has no changes — folding the whole file into one
507 // line would be wrong (graceful: 0 hunks → no folds).
508 assert!(compute_unchanged_folds(&[], 1, 100, 6).is_empty());
509 }
510
511 #[test]
512 fn unchanged_folds_leading_and_trailing_gaps() {
513 // One Change hunk on the current side at [50, 52), context 6,
514 // file of 100 lines. Kept window = [44, 58). Complement folds:
515 // leading [0, 44) → fold lines 0..=43; trailing [58, 100) →
516 // fold 58..=99.
517 let h = hunk(
518 HunkKind::Change,
519 LineRange::new(50, 52),
520 LineRange::new(50, 52),
521 );
522 let folds = compute_unchanged_folds(&[h], 1, 100, 6);
523 assert_eq!(folds.len(), 2, "leading + trailing gap");
524 assert_eq!((folds[0].start_line, folds[0].end_line), (0, 43));
525 assert_eq!((folds[1].start_line, folds[1].end_line), (58, 99));
526 assert!(folds.iter().all(|f| f.closed), "unchanged folds are closed");
527 assert!(folds.iter().all(|f| f.identity.is_some()));
528 }
529
530 #[test]
531 fn unchanged_merges_close_hunks_and_keeps_context() {
532 // Two hunks at [20,22) and [28,30), context 6: windows [14,28)
533 // and [22,36) overlap → merge to [14,36). Complement: leading
534 // [0,14) and trailing [36,100). The 6-line gap between the hunks
535 // (22..28) is inside the merged kept window → NOT folded
536 // (context preserved).
537 let folds = compute_unchanged_folds(
538 &[
539 hunk(
540 HunkKind::Change,
541 LineRange::new(20, 22),
542 LineRange::new(20, 22),
543 ),
544 hunk(
545 HunkKind::Change,
546 LineRange::new(28, 30),
547 LineRange::new(28, 30),
548 ),
549 ],
550 1,
551 100,
552 6,
553 );
554 assert_eq!(folds.len(), 2);
555 assert_eq!((folds[0].start_line, folds[0].end_line), (0, 13));
556 assert_eq!((folds[1].start_line, folds[1].end_line), (36, 99));
557 }
558
559 #[test]
560 fn unchanged_min_gap_floor_skips_tiny_gaps() {
561 // A change at the very top ([0,1)) with context 6 → kept [0,7);
562 // leading gap [0,0) is empty (no fold). With a 1-line trailing
563 // gap (line_count 8 → trailing [7,8) = 1 line) the floor of 2
564 // skips it. So: no folds.
565 let folds = compute_unchanged_folds(
566 &[hunk(
567 HunkKind::Add,
568 LineRange::new(0, 0),
569 LineRange::new(0, 1),
570 )],
571 1,
572 8,
573 6,
574 );
575 assert!(folds.is_empty(), "a 1-line trailing gap is below the floor");
576 }
577
578 #[test]
579 fn unchanged_empty_range_anchors_a_window() {
580 // Pure Add on the OTHER side ⇒ this (baseline, slot 0) side has
581 // an EMPTY range [40,40) at the insertion point. It still
582 // anchors a kept window [34,46), so the deletion marker stays
583 // visible and the surrounding code folds around it.
584 let h = hunk(
585 HunkKind::Add,
586 LineRange::new(40, 40),
587 LineRange::new(40, 60),
588 );
589 let folds = compute_unchanged_folds(&[h], 0, 100, 6);
590 assert_eq!(folds.len(), 2);
591 assert_eq!((folds[0].start_line, folds[0].end_line), (0, 33));
592 assert_eq!((folds[1].start_line, folds[1].end_line), (46, 99));
593 }
594
595 #[test]
596 fn unchanged_source_respects_toggle_off() {
597 // With a ConfigRegistry whose `ui.diff.fold-unchanged` is false,
598 // the source emits nothing even when hunks + line counts exist.
599 let session = Arc::new(DiffSession::new(BufferId(1), DiffAlgorithm::Histogram));
600 session
601 .recompute_blocking(&[
602 ropey::Rope::from("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\n"),
603 ropey::Rope::from("a\nb\nc\nd\ne\nXX\ng\nh\ni\nj\nk\nl\n"),
604 ])
605 .expect("publish");
606 let cfg = Arc::new(lattice_config::ConfigRegistry::new());
607 cfg.init_from_linkme();
608 cfg.parse_and_set_command("ui.diff.fold-unchanged=false")
609 .expect("toggle off");
610 let src = UnchangedFoldSource::new(session, BufferId(1), 1, Some(cfg));
611 assert!(
612 src.compute_folds().is_empty(),
613 "toggle off → no unchanged folds"
614 );
615 }
616
617 #[test]
618 fn unchanged_source_reads_line_count_and_default_context() {
619 // No config (defaults: fold on, context 6). A 24-line file with
620 // one change at current [10,11) → kept [4,17); folds leading
621 // [0,4) and trailing [17,24).
622 let session = Arc::new(DiffSession::new(BufferId(1), DiffAlgorithm::Histogram));
623 // 24 identical lines, one differing at line 10 → a Change hunk.
624 let base: String = (0..24).map(|i| format!("line{i}\n")).collect();
625 let mut lines: Vec<String> = (0..24).map(|i| format!("line{i}")).collect();
626 lines[10] = "CHANGED".to_string();
627 let cur: String = lines.iter().map(|l| format!("{l}\n")).collect();
628 session
629 .recompute_blocking(&[ropey::Rope::from(base), ropey::Rope::from(cur)])
630 .expect("publish");
631 let src = UnchangedFoldSource::new(session, BufferId(1), 1, None);
632 let folds = src.compute_folds();
633 assert!(!folds.is_empty(), "default-on folds the unchanged gaps");
634 assert!(folds.iter().all(|f| f.closed));
635 // Leading gap starts at 0; the change row 10 sits in a kept
636 // window, so no fold covers it.
637 assert!(folds.iter().any(|f| f.start_line == 0));
638 assert!(
639 !folds.iter().any(|f| f.start_line <= 10 && 10 <= f.end_line),
640 "the change row is never inside an unchanged fold"
641 );
642 }
643
644 #[test]
645 fn unchanged_identity_distinct_from_hunk_and_per_side() {
646 // The unchanged identity is namespaced away from the hunk
647 // identity for the same span, and differs per side.
648 assert_ne!(unchanged_fold_identity(1, 3, 6), hunk_fold_identity(3, 6));
649 assert_ne!(
650 unchanged_fold_identity(0, 3, 6),
651 unchanged_fold_identity(1, 3, 6)
652 );
653 assert_eq!(
654 unchanged_fold_identity(1, 3, 6),
655 unchanged_fold_identity(1, 3, 6)
656 );
657 }
658}