Skip to main content

hjkl_buffer/
folds.rs

1//! Manual folds: contiguous row ranges that the host can collapse
2//! to a single visible "fold marker" line.
3//!
4//! Phase 9 of the migration plan unlocks this — vim users get
5//! `zo`/`zc`/`za`/`zR`/`zM` over the same buffer the editor is
6//! mutating, no separate fold tracker required.
7//!
8//! ## Fold semantics
9//!
10//! Folds are **row-range** spans, not byte spans. [`Fold`] covers
11//! `[start_row, end_row]` inclusive. The host renders folds as collapsed
12//! single-line stubs; the buffer never elides them on its own —
13//! [`crate::View::lines`] always returns the underlying logical text.
14//!
15//! Add / remove / toggle goes through
16//! [`crate::View::add_fold`] / [`crate::View::remove_fold_at`] /
17//! [`crate::View::toggle_fold_at`]. Open-all / close-all (`zR` / `zM`)
18//! go through [`crate::View::open_all_folds`] /
19//! [`crate::View::close_all_folds`]; folds keep their definitions across
20//! open/close cycles.
21
22/// A contiguous range of rows that the host can collapse to a single
23/// fold-marker line.
24///
25/// Folds are row-range spans: `[start_row, end_row]` inclusive. The buffer
26/// never elides content — [`crate::View::lines`] always returns the full
27/// logical text regardless of fold state. It is the host's render path that
28/// skips hidden rows and replaces them with a stub.
29///
30/// See the `folds` module documentation for the full invariant description.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct Fold {
33    /// First row of the folded range (visible when closed).
34    pub start_row: usize,
35    /// Last row of the folded range, inclusive.
36    pub end_row: usize,
37    /// `true` = collapsed (rows after `start_row` are hidden).
38    pub closed: bool,
39    /// `true` when this fold was created by the auto-fold engine
40    /// (tree-sitter foldmethod=expr). Manual folds created via `zf` /
41    /// [`crate::View::add_fold`] set this to `false`.
42    ///
43    /// Used by [`crate::View::set_auto_folds`] to distinguish auto
44    /// folds (which it manages) from manual folds (which it leaves
45    /// untouched).
46    pub auto_generated: bool,
47}
48
49impl Fold {
50    pub fn contains(&self, row: usize) -> bool {
51        row >= self.start_row && row <= self.end_row
52    }
53
54    /// True when `row` is hidden by a closed fold (i.e. inside the
55    /// fold but not on its `start_row` marker line).
56    pub fn hides(&self, row: usize) -> bool {
57        self.closed && row > self.start_row && row <= self.end_row
58    }
59
60    /// Number of rows the fold spans.
61    pub fn line_count(&self) -> usize {
62        self.end_row.saturating_sub(self.start_row) + 1
63    }
64}
65
66impl crate::View {
67    /// Returns a snapshot of all folds as an owned `Vec<Fold>`.
68    ///
69    /// Owned rather than `&[Fold]` because a `View` is a per-window
70    /// view onto a shared `Buffer`; another view could mutate the folds vec
71    /// between when this returns and when the caller reads the slice.
72    pub fn folds(&self) -> Vec<Fold> {
73        self.content_lock().folds.clone()
74    }
75
76    /// Register a new fold. If an existing fold has the same
77    /// `start_row`, it's replaced; otherwise the new one is inserted
78    /// in start-row order. Empty / inverted ranges are rejected.
79    pub fn add_fold(&mut self, start_row: usize, end_row: usize, closed: bool) {
80        if end_row < start_row {
81            return;
82        }
83        let last = self.row_count().saturating_sub(1);
84        if start_row > last {
85            return;
86        }
87        let end_row = end_row.min(last);
88        let fold = Fold {
89            start_row,
90            end_row,
91            closed,
92            auto_generated: false,
93        };
94        {
95            let mut c = self.content_lock_mut();
96            if let Some(idx) = c.folds.iter().position(|f| f.start_row == start_row) {
97                c.folds[idx] = fold;
98            } else {
99                let pos = c
100                    .folds
101                    .iter()
102                    .position(|f| f.start_row > start_row)
103                    .unwrap_or(c.folds.len());
104                c.folds.insert(pos, fold);
105            }
106        }
107        self.dirty_gen_bump();
108    }
109
110    /// Replace all auto-generated folds with a new set derived from
111    /// `ranges`, while leaving manual folds untouched.
112    ///
113    /// ## Algorithm (O(N) — bounded by `ranges.len()`, no unbounded growth)
114    ///
115    /// 1. Snapshot `start_row → closed` for every existing auto fold so
116    ///    open/closed state survives a reparse.
117    /// 2. Retain only manual folds (`auto_generated == false`).
118    /// 3. Insert one new `Fold` per range, re-using the snapshotted closed
119    ///    state when the start_row existed before, else `default_closed`.
120    ///
121    /// Invariants preserved:
122    /// - Folds stay sorted by `start_row` (same ordering as `add_fold`).
123    /// - Duplicate start_rows: the last range in `ranges` wins (consistent
124    ///   with `add_fold`'s replace-on-same-start-row semantics). In practice
125    ///   TS query ranges are already deduplicated.
126    /// - Empty / inverted ranges (end_row < start_row) are silently skipped.
127    /// - `end_row` is clamped to the last valid row, same as `add_fold`.
128    pub fn set_auto_folds(&mut self, ranges: &[(usize, usize)], default_closed: bool) {
129        // 1. Snapshot closed state of existing auto folds by start_row.
130        let prev_closed: std::collections::HashMap<usize, bool> = self
131            .content_lock()
132            .folds
133            .iter()
134            .filter(|f| f.auto_generated)
135            .map(|f| (f.start_row, f.closed))
136            .collect();
137
138        // 2. Retain manual folds only.
139        {
140            let mut c = self.content_lock_mut();
141            c.folds.retain(|f| !f.auto_generated);
142        }
143
144        // 3. Insert new auto folds in sorted order.
145        let last = self.row_count().saturating_sub(1);
146        for &(start_row, end_row) in ranges {
147            // Skip empty/inverted and out-of-bounds ranges.
148            if end_row < start_row || start_row > last {
149                continue;
150            }
151            let end_row = end_row.min(last);
152            // Only folds spanning more than one row are meaningful.
153            if end_row == start_row {
154                continue;
155            }
156            let closed = prev_closed
157                .get(&start_row)
158                .copied()
159                .unwrap_or(default_closed);
160            let fold = Fold {
161                start_row,
162                end_row,
163                closed,
164                auto_generated: true,
165            };
166            let mut c = self.content_lock_mut();
167            // Replace any existing fold at this start_row (manual or auto).
168            if let Some(idx) = c.folds.iter().position(|f| f.start_row == start_row) {
169                c.folds[idx] = fold;
170            } else {
171                let pos = c
172                    .folds
173                    .iter()
174                    .position(|f| f.start_row > start_row)
175                    .unwrap_or(c.folds.len());
176                c.folds.insert(pos, fold);
177            }
178        }
179
180        self.dirty_gen_bump();
181    }
182
183    /// Drop the fold whose range covers `row`. Returns `true` when a
184    /// fold was actually removed.
185    pub fn remove_fold_at(&mut self, row: usize) -> bool {
186        // Remove the INNERMOST fold containing `row` (largest start_row), so
187        // `zd` on a nested fold drops the inner one, not the enclosing block.
188        let idx = self
189            .content_lock()
190            .folds
191            .iter()
192            .enumerate()
193            .filter(|(_, f)| f.contains(row))
194            .max_by_key(|(_, f)| f.start_row)
195            .map(|(i, _)| i);
196        let Some(idx) = idx else {
197            return false;
198        };
199        self.content_lock_mut().folds.remove(idx);
200        self.dirty_gen_bump();
201        true
202    }
203
204    /// Open the fold at `row` (no-op if already open or no fold).
205    pub fn open_fold_at(&mut self, row: usize) -> bool {
206        let changed = {
207            let mut c = self.content_lock_mut();
208            let Some(f) = c
209                .folds
210                .iter_mut()
211                .filter(|f| f.contains(row))
212                .max_by_key(|f| f.start_row)
213            else {
214                return false;
215            };
216            if !f.closed {
217                return false;
218            }
219            f.closed = false;
220            true
221        };
222        if changed {
223            self.dirty_gen_bump();
224        }
225        changed
226    }
227
228    /// Close the fold at `row` (no-op if already closed or no fold).
229    pub fn close_fold_at(&mut self, row: usize) -> bool {
230        let changed = {
231            let mut c = self.content_lock_mut();
232            let Some(f) = c
233                .folds
234                .iter_mut()
235                .filter(|f| f.contains(row))
236                .max_by_key(|f| f.start_row)
237            else {
238                return false;
239            };
240            if f.closed {
241                return false;
242            }
243            f.closed = true;
244            true
245        };
246        if changed {
247            self.dirty_gen_bump();
248        }
249        changed
250    }
251
252    /// Flip the closed/open state of the fold containing `row`.
253    pub fn toggle_fold_at(&mut self, row: usize) -> bool {
254        let changed = {
255            let mut c = self.content_lock_mut();
256            let Some(f) = c
257                .folds
258                .iter_mut()
259                .filter(|f| f.contains(row))
260                .max_by_key(|f| f.start_row)
261            else {
262                return false;
263            };
264            f.closed = !f.closed;
265            true
266        };
267        if changed {
268            self.dirty_gen_bump();
269        }
270        changed
271    }
272
273    /// `zR` — open every fold.
274    pub fn open_all_folds(&mut self) {
275        let changed = {
276            let mut c = self.content_lock_mut();
277            let mut any = false;
278            for f in c.folds.iter_mut() {
279                if f.closed {
280                    f.closed = false;
281                    any = true;
282                }
283            }
284            any
285        };
286        if changed {
287            self.dirty_gen_bump();
288        }
289    }
290
291    /// `zE` — eliminate every fold.
292    pub fn clear_all_folds(&mut self) {
293        let was_nonempty = !self.content_lock().folds.is_empty();
294        if was_nonempty {
295            self.content_lock_mut().folds.clear();
296            self.dirty_gen_bump();
297        }
298    }
299
300    /// `zM` — close every fold.
301    pub fn close_all_folds(&mut self) {
302        let changed = {
303            let mut c = self.content_lock_mut();
304            let mut any = false;
305            for f in c.folds.iter_mut() {
306                if !f.closed {
307                    f.closed = true;
308                    any = true;
309                }
310            }
311            any
312        };
313        if changed {
314            self.dirty_gen_bump();
315        }
316    }
317
318    /// First fold whose range contains `row`. Useful for the host's
319    /// `za`/`zo`/`zc` handlers.
320    pub fn fold_at_row(&self, row: usize) -> Option<Fold> {
321        // Innermost fold containing `row`: with nested folds, the one with the
322        // largest `start_row` is the most-deeply-nested. Folds are stored in
323        // start-row order, so a plain `.find` would return the OUTERMOST fold
324        // and `zc`/`za`/`zo` would act on the wrong level.
325        self.content_lock()
326            .folds
327            .iter()
328            .filter(|f| f.contains(row))
329            .max_by_key(|f| f.start_row)
330            .copied()
331    }
332
333    /// True iff `row` is hidden by a closed fold (any fold).
334    pub fn is_row_hidden(&self, row: usize) -> bool {
335        self.folds().iter().any(|f| f.hides(row))
336    }
337
338    /// Open every closed fold whose body hides `row`, so the row becomes
339    /// visible. Handles nested folds in a single pass — unlike
340    /// `open_fold_at` / `FoldOp::OpenAt`, which only act on the first fold
341    /// containing the row and so can never reach a nested inner fold.
342    /// Used by `goto_line` so a jump into a folded region reveals the
343    /// target line instead of stranding the cursor on a hidden row.
344    /// Returns `true` if any fold was opened.
345    pub fn reveal_row(&mut self, row: usize) -> bool {
346        let changed = {
347            let mut c = self.content_lock_mut();
348            let mut any = false;
349            for f in c.folds.iter_mut() {
350                if f.hides(row) {
351                    f.closed = false;
352                    any = true;
353                }
354            }
355            any
356        };
357        if changed {
358            self.dirty_gen_bump();
359        }
360        changed
361    }
362
363    /// First visible row strictly after `row`, skipping any rows hidden
364    /// by closed folds. Returns `None` past the end of the buffer.
365    pub fn next_visible_row(&self, row: usize) -> Option<usize> {
366        let last = self.row_count().saturating_sub(1);
367        if last == 0 && row == 0 {
368            return None;
369        }
370        let mut r = row.checked_add(1)?;
371        while r <= last && self.is_row_hidden(r) {
372            r += 1;
373        }
374        (r <= last).then_some(r)
375    }
376
377    /// First visible row strictly before `row`, skipping hidden rows.
378    pub fn prev_visible_row(&self, row: usize) -> Option<usize> {
379        let mut r = row.checked_sub(1)?;
380        while self.is_row_hidden(r) {
381            r = r.checked_sub(1)?;
382        }
383        Some(r)
384    }
385
386    /// Drop every fold that touches `[start_row, end_row]`.
387    pub fn invalidate_folds_in_range(&mut self, start_row: usize, end_row: usize) {
388        let before = self.content_lock().folds.len();
389        invalidate_folds(&mut self.content_lock_mut().folds, start_row, end_row);
390        if self.content_lock().folds.len() != before {
391            self.dirty_gen_bump();
392        }
393    }
394
395    /// Shift every buffer fold by an edit's row-delta band. Mirrors
396    /// [`crate::buffer::View::rebase_marks`] for the shared fold storage —
397    /// see [`shift_folds_after_edit`] for the per-fold rules.
398    pub fn rebase_folds(
399        &mut self,
400        edit_start: usize,
401        drop_end: usize,
402        shift_threshold: usize,
403        delta: isize,
404    ) {
405        if delta == 0 {
406            return;
407        }
408        let mut c = self.content_lock_mut();
409        shift_folds_after_edit(&mut c.folds, edit_start, drop_end, shift_threshold, delta);
410    }
411
412    /// Replace the entire fold set wholesale. Used to install a per-window fold
413    /// snapshot into the shared buffer on focus change (window-level folds): the
414    /// app keeps each window's open/closed state and swaps it in before dispatch,
415    /// so motions/render/`z`-ops operate on the focused window's folds.
416    pub fn set_folds(&mut self, folds: &[Fold]) {
417        {
418            let mut c = self.content_lock_mut();
419            if c.folds.as_slice() == folds {
420                return; // no-op — avoid a spurious dirty_gen bump
421            }
422            c.folds = folds.to_vec();
423        }
424        self.dirty_gen_bump();
425    }
426}
427
428/// Drop every fold in `folds` that touches `[start_row, end_row]`, in place.
429///
430/// Free helper so both [`crate::View::invalidate_folds_in_range`] (operating
431/// on the shared content) and the app's window-level edit-coherence pass
432/// (operating on a sibling window's owned `Vec<Fold>`) share one rule — vim
433/// opens/forgets any fold the edit overlapped.
434pub fn invalidate_folds(folds: &mut Vec<Fold>, start_row: usize, end_row: usize) {
435    folds.retain(|f| f.end_row < start_row || f.start_row > end_row);
436}
437
438// ── Row-delta shifting (edit-coherence) ──────────────────────────────────
439//
440// A manual (`zf`) fold is a row-range that has to track the same
441// insert/delete row-shift the engine already applies to marks and the
442// jumplist (see `Editor::shift_marks_after_edit`). Without this, a fold
443// below an edit keeps stale row numbers and the renderer / fold-aware ops
444// (`dd`, `p`, …) act on the wrong rows (#audit-r2 fix 1).
445//
446// The four `(edit_start, drop_end, shift_threshold, delta)` parameters are
447// the exact same band description `Editor::shift_marks_after_edit` computes
448// for marks: `[edit_start, drop_end)` is the row band the edit deleted
449// (empty for inserts), and any row `>= shift_threshold` moves by `delta`.
450// Reusing the identical band keeps folds, marks, and jumplist entries
451// shifting in lockstep for the same edit.
452
453/// Shift a single fold's `start_row` / `end_row` by an edit's row-delta band.
454/// Returns `None` when the edit's deleted band fully consumes the fold.
455///
456/// Each endpoint is mapped independently through the same drop/shift rule
457/// [`crate::buffer::View::rebase_marks`] applies to a point mark. Mapping
458/// the two endpoints independently is what produces the vim-shaped "edit
459/// inside a fold" semantics for free:
460/// - Both endpoints below `shift_threshold` and outside the deleted band →
461///   fold untouched (edit happened entirely outside the fold).
462/// - `start_row` outside the deleted band but `end_row` inside it → the
463///   edit deleted the fold's tail; it clips to end at the last surviving
464///   row (`edit_start - 1`).
465/// - `start_row` inside the deleted band but `end_row` outside it → the
466///   edit deleted the fold's head; it clips to start at `edit_start` (the
467///   row the surviving tail now occupies).
468/// - Both endpoints inside the deleted band → the edit consumed the whole
469///   fold; it's dropped.
470/// - `start_row` below the threshold and `end_row` at/above it (an insert
471///   or a deletion landing strictly inside the fold) → `start_row` stays,
472///   `end_row` shifts by `delta`: the fold grows (insert) or shrinks
473///   (delete) around the edit, matching vim.
474/// - Both endpoints at/above the threshold → the fold shifts wholesale.
475pub fn shift_fold(
476    fold: Fold,
477    edit_start: usize,
478    drop_end: usize,
479    shift_threshold: usize,
480    delta: isize,
481) -> Option<Fold> {
482    if delta == 0 {
483        return Some(fold);
484    }
485    let map_row = |row: usize| -> Option<usize> {
486        if (edit_start..drop_end).contains(&row) {
487            None
488        } else if row >= shift_threshold {
489            Some(((row as isize) + delta).max(0) as usize)
490        } else {
491            Some(row)
492        }
493    };
494    let mapped_start = map_row(fold.start_row);
495    let mapped_end = map_row(fold.end_row);
496    if mapped_start.is_none() && mapped_end.is_none() {
497        return None;
498    }
499    let new_start = mapped_start.unwrap_or(edit_start);
500    let new_end = mapped_end.unwrap_or_else(|| edit_start.saturating_sub(1));
501    if new_end < new_start {
502        return None;
503    }
504    Some(Fold {
505        start_row: new_start,
506        end_row: new_end,
507        closed: fold.closed,
508        auto_generated: fold.auto_generated,
509    })
510}
511
512/// Shift every fold in `folds` by an edit's row-delta band, in place.
513/// Folds the edit's deleted band fully consumes are dropped (mirrors
514/// [`invalidate_folds`] for the folds that DO survive but move).
515///
516/// Shared by [`crate::View::rebase_folds`] (engine-side, the buffer's own
517/// fold storage) and the app's sibling-window fold snapshot shift, so both
518/// converge on the identical row-shift rule.
519pub fn shift_folds_after_edit(
520    folds: &mut Vec<Fold>,
521    edit_start: usize,
522    drop_end: usize,
523    shift_threshold: usize,
524    delta: isize,
525) {
526    if delta == 0 {
527        return;
528    }
529    folds.retain_mut(
530        |f| match shift_fold(*f, edit_start, drop_end, shift_threshold, delta) {
531            Some(shifted) => {
532                *f = shifted;
533                true
534            }
535            None => false,
536        },
537    );
538}
539
540#[cfg(test)]
541mod tests {
542    use crate::View;
543
544    fn b() -> View {
545        View::from_str("a\nb\nc\nd\ne")
546    }
547
548    #[test]
549    fn add_keeps_folds_in_start_row_order() {
550        let mut buf = b();
551        buf.add_fold(2, 3, true);
552        buf.add_fold(0, 1, false);
553        let starts: Vec<usize> = buf.folds().iter().map(|f| f.start_row).collect();
554        assert_eq!(starts, vec![0, 2]);
555    }
556
557    #[test]
558    fn set_folds_replaces_wholesale() {
559        let mut buf = b();
560        buf.add_fold(0, 1, false);
561        // Install a different per-window snapshot.
562        let snapshot = vec![super::Fold {
563            start_row: 2,
564            end_row: 3,
565            closed: true,
566            auto_generated: false,
567        }];
568        buf.set_folds(&snapshot);
569        assert_eq!(buf.folds(), snapshot);
570        // Idempotent: re-installing the same set is a no-op (no dirty bump).
571        let dg = buf.dirty_gen();
572        buf.set_folds(&snapshot);
573        assert_eq!(buf.dirty_gen(), dg);
574    }
575
576    #[test]
577    fn invalidate_folds_helper_drops_overlapping() {
578        let f = |s, e| super::Fold {
579            start_row: s,
580            end_row: e,
581            closed: true,
582            auto_generated: false,
583        };
584        let mut folds = vec![f(0, 2), f(4, 6), f(8, 10)];
585        // Edit touches rows 5..5 → only the [4,6] fold overlaps.
586        super::invalidate_folds(&mut folds, 5, 5);
587        let starts: Vec<usize> = folds.iter().map(|x| x.start_row).collect();
588        assert_eq!(starts, vec![0, 8]);
589    }
590
591    #[test]
592    fn add_replaces_existing_with_same_start_row() {
593        let mut buf = b();
594        buf.add_fold(1, 2, true);
595        buf.add_fold(1, 4, false);
596        assert_eq!(buf.folds().len(), 1);
597        assert_eq!(buf.folds()[0].end_row, 4);
598        assert!(!buf.folds()[0].closed);
599    }
600
601    #[test]
602    fn add_clamps_end_row_to_buffer_bounds() {
603        let mut buf = b();
604        buf.add_fold(2, 99, true);
605        assert_eq!(buf.folds()[0].end_row, 4);
606    }
607
608    #[test]
609    fn add_rejects_inverted_range() {
610        let mut buf = b();
611        buf.add_fold(3, 1, true);
612        assert!(buf.folds().is_empty());
613    }
614
615    #[test]
616    fn toggle_flips_state() {
617        let mut buf = b();
618        buf.add_fold(1, 3, false);
619        assert!(!buf.folds()[0].closed);
620        assert!(buf.toggle_fold_at(2));
621        assert!(buf.folds()[0].closed);
622        assert!(buf.toggle_fold_at(2));
623        assert!(!buf.folds()[0].closed);
624    }
625
626    #[test]
627    fn is_row_hidden_excludes_start_row() {
628        let mut buf = b();
629        buf.add_fold(1, 3, true);
630        assert!(!buf.is_row_hidden(0));
631        assert!(!buf.is_row_hidden(1)); // start row stays visible
632        assert!(buf.is_row_hidden(2));
633        assert!(buf.is_row_hidden(3));
634        assert!(!buf.is_row_hidden(4));
635    }
636
637    #[test]
638    fn open_close_all_changes_every_fold() {
639        let mut buf = b();
640        buf.add_fold(0, 1, false);
641        buf.add_fold(2, 3, true);
642        buf.close_all_folds();
643        assert!(buf.folds().iter().all(|f| f.closed));
644        buf.open_all_folds();
645        assert!(buf.folds().iter().all(|f| !f.closed));
646    }
647
648    #[test]
649    fn invalidate_drops_overlapping_folds() {
650        let mut buf = b();
651        buf.add_fold(0, 1, true);
652        buf.add_fold(2, 3, true);
653        buf.add_fold(4, 4, true);
654        buf.invalidate_folds_in_range(2, 3);
655        let starts: Vec<usize> = buf.folds().iter().map(|f| f.start_row).collect();
656        assert_eq!(starts, vec![0, 4]);
657    }
658
659    // ── auto_generated flag + set_auto_folds ─────────────────────────────────
660
661    #[test]
662    fn add_fold_sets_auto_generated_false() {
663        let mut buf = b();
664        buf.add_fold(1, 3, false);
665        assert!(
666            !buf.folds()[0].auto_generated,
667            "manual add_fold must have auto_generated=false"
668        );
669    }
670
671    #[test]
672    fn set_auto_folds_adds_auto_folds() {
673        let mut buf = b();
674        buf.set_auto_folds(&[(0, 2), (3, 4)], false);
675        let folds = buf.folds();
676        assert_eq!(folds.len(), 2);
677        assert!(folds[0].auto_generated);
678        assert!(folds[1].auto_generated);
679        assert_eq!(folds[0].start_row, 0);
680        assert_eq!(folds[1].start_row, 3);
681    }
682
683    #[test]
684    fn set_auto_folds_second_call_replaces_first() {
685        let mut buf = b();
686        buf.set_auto_folds(&[(0, 2), (3, 4)], false);
687        assert_eq!(buf.folds().len(), 2);
688        // Replace with a different set.
689        buf.set_auto_folds(&[(1, 4)], false);
690        let folds = buf.folds();
691        assert_eq!(folds.len(), 1, "second call must replace first set");
692        assert_eq!(folds[0].start_row, 1);
693        assert!(folds[0].auto_generated);
694    }
695
696    #[test]
697    fn set_auto_folds_preserves_manual_folds() {
698        let mut buf = b();
699        // Add a manual fold.
700        buf.add_fold(0, 1, true);
701        // Auto-fold the remaining range.
702        buf.set_auto_folds(&[(2, 4)], false);
703        let folds = buf.folds();
704        assert_eq!(folds.len(), 2, "manual fold must survive set_auto_folds");
705        let manual = folds.iter().find(|f| f.start_row == 0).unwrap();
706        assert!(!manual.auto_generated, "manual fold flag must stay false");
707        let auto = folds.iter().find(|f| f.start_row == 2).unwrap();
708        assert!(auto.auto_generated);
709    }
710
711    #[test]
712    fn set_auto_folds_preserves_open_closed_state_by_start_row() {
713        let mut buf = b();
714        // First auto-fold pass: create a closed fold at row 0.
715        buf.set_auto_folds(&[(0, 2)], true); // default_closed=true → starts closed
716        assert!(buf.folds()[0].closed, "fold must start closed per default");
717
718        // User opens the fold (simulated by toggle).
719        buf.toggle_fold_at(0);
720        assert!(!buf.folds()[0].closed, "fold must now be open");
721
722        // Second auto-fold pass with same start_row — must preserve open state.
723        buf.set_auto_folds(&[(0, 2)], true); // default_closed=true but prev was open
724        assert!(
725            !buf.folds()[0].closed,
726            "open/closed state must be preserved across set_auto_folds"
727        );
728    }
729
730    #[test]
731    fn set_auto_folds_skips_single_row_and_inverted_ranges() {
732        let mut buf = b();
733        buf.set_auto_folds(&[(1, 1), (3, 2)], false);
734        assert!(
735            buf.folds().is_empty(),
736            "single-row and inverted ranges must be skipped"
737        );
738    }
739
740    #[test]
741    fn set_auto_folds_new_folds_use_default_closed() {
742        let mut buf = b();
743        buf.set_auto_folds(&[(0, 4)], true);
744        assert!(
745            buf.folds()[0].closed,
746            "new auto fold must use default_closed=true"
747        );
748
749        // Clear and re-run with default_closed=false.
750        buf.set_auto_folds(&[(0, 4)], false);
751        // This is a *new* start_row (it was removed + re-added), BUT the
752        // snapshot preserved the previous state (closed=true from above)
753        // because the start_row is the same.
754        // Wait — the test verifies the preservation path, not the default path.
755        // Let's use a fresh start_row to test the default path:
756        let mut buf2 = b();
757        buf2.set_auto_folds(&[(2, 4)], false);
758        assert!(
759            !buf2.folds()[0].closed,
760            "brand-new auto fold must start open when default_closed=false"
761        );
762    }
763
764    // ── row-delta shifting (audit-r2 fix 1) ───────────────────────────────
765
766    fn fold(s: usize, e: usize) -> super::Fold {
767        super::Fold {
768            start_row: s,
769            end_row: e,
770            closed: true,
771            auto_generated: false,
772        }
773    }
774
775    #[test]
776    fn shift_fold_insert_above_shifts_down() {
777        // 10-line file, fold at rows 4..6, insert one row at row 0
778        // (`ggO x<Esc>`): vim shifts the fold to 5..7.
779        let f = fold(4, 6);
780        let shifted = super::shift_fold(f, 0, 0, 1, 1).unwrap();
781        assert_eq!((shifted.start_row, shifted.end_row), (5, 7));
782    }
783
784    #[test]
785    fn shift_fold_delete_above_shifts_up() {
786        // Fold at rows 4..6, one row deleted above at row 0.
787        let f = fold(4, 6);
788        let shifted = super::shift_fold(f, 0, 1, 1, -1).unwrap();
789        assert_eq!((shifted.start_row, shifted.end_row), (3, 5));
790    }
791
792    #[test]
793    fn shift_fold_delete_fully_overlapping_drops() {
794        // Fold at rows 4..6, deletion covers rows 4..6 entirely.
795        let f = fold(4, 6);
796        assert!(super::shift_fold(f, 4, 7, 7, -3).is_none());
797    }
798
799    #[test]
800    fn shift_fold_delete_overlapping_tail_clips() {
801        // Fold at rows 4..6, deletion of rows 6..8 (tail only) clips the
802        // fold to end at the last surviving row.
803        let f = fold(4, 6);
804        let shifted = super::shift_fold(f, 6, 9, 9, -3).unwrap();
805        assert_eq!((shifted.start_row, shifted.end_row), (4, 5));
806    }
807
808    #[test]
809    fn shift_fold_delete_overlapping_head_clips() {
810        // Fold at rows 4..6, deletion of rows 3..4 (head only) clips the
811        // fold to start where the surviving tail now sits.
812        let f = fold(4, 6);
813        let shifted = super::shift_fold(f, 3, 5, 5, -2).unwrap();
814        assert_eq!((shifted.start_row, shifted.end_row), (3, 4));
815    }
816
817    #[test]
818    fn shift_fold_edit_inside_grows_on_insert() {
819        // Fold at rows 4..6, a line inserted at row 5 (strictly inside):
820        // vim grows the fold's end, leaves the start alone.
821        let f = fold(4, 6);
822        let shifted = super::shift_fold(f, 5, 5, 6, 1).unwrap();
823        assert_eq!((shifted.start_row, shifted.end_row), (4, 7));
824    }
825
826    #[test]
827    fn shift_fold_edit_inside_shrinks_on_delete() {
828        // Fold at rows 4..8, rows 5..6 deleted (strictly inside): the fold
829        // shrinks around the deletion instead of clipping or dropping.
830        let f = fold(4, 8);
831        let shifted = super::shift_fold(f, 5, 7, 7, -2).unwrap();
832        assert_eq!((shifted.start_row, shifted.end_row), (4, 6));
833    }
834
835    #[test]
836    fn shift_fold_unaffected_when_entirely_before_edit() {
837        let f = fold(1, 2);
838        let shifted = super::shift_fold(f, 10, 11, 11, 1).unwrap();
839        assert_eq!((shifted.start_row, shifted.end_row), (1, 2));
840    }
841
842    #[test]
843    fn shift_fold_zero_delta_is_noop() {
844        let f = fold(4, 6);
845        let shifted = super::shift_fold(f, 0, 0, 0, 0).unwrap();
846        assert_eq!(shifted, f);
847    }
848
849    #[test]
850    fn shift_folds_after_edit_shifts_vec_in_place() {
851        let mut folds = vec![fold(4, 6), fold(1, 2)];
852        // Insert one row at row 0: both folds shift down.
853        super::shift_folds_after_edit(&mut folds, 0, 0, 1, 1);
854        let ranges: Vec<(usize, usize)> = folds.iter().map(|f| (f.start_row, f.end_row)).collect();
855        assert_eq!(ranges, vec![(5, 7), (2, 3)]);
856    }
857
858    #[test]
859    fn shift_folds_after_edit_drops_fully_consumed() {
860        let mut folds = vec![fold(4, 6)];
861        super::shift_folds_after_edit(&mut folds, 4, 7, 7, -3);
862        assert!(folds.is_empty());
863    }
864
865    #[test]
866    fn rebase_folds_shifts_buffer_fold_storage() {
867        let mut buf = View::from_str("0\n1\n2\n3\n4\n5\n6\n7\n8\n9");
868        buf.add_fold(4, 6, true);
869        buf.rebase_folds(0, 0, 1, 1);
870        let folds = buf.folds();
871        assert_eq!(folds.len(), 1);
872        assert_eq!((folds[0].start_row, folds[0].end_row), (5, 7));
873    }
874}