Skip to main content

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}