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    /// Last row index containing real content — skips vim's single
364    /// phantom trailing empty row. `ropey`'s `len_lines()` always
365    /// synthesizes one extra empty final "line" when the buffer text
366    /// ends in `\n` (vim treats that `\n` as a terminator, not a
367    /// separator). Mirrors `hjkl_engine::motions::move_bottom`'s clamp
368    /// (`G`) so vertical motions agree with `G` on where the buffer
369    /// "ends". A buffer whose *real* last line happens to be empty
370    /// (e.g. `"foo\n\n"`, row 1) is untouched — only a single trailing
371    /// phantom row is ever skipped.
372    fn last_content_row(&self) -> usize {
373        let raw_last = self.row_count().saturating_sub(1);
374        if raw_last > 0 {
375            let c = self.content_lock();
376            if crate::buffer::rope_line_str(&c.text, raw_last).is_empty() {
377                return raw_last - 1;
378            }
379        }
380        raw_last
381    }
382
383    /// First visible row strictly after `row`, skipping any rows hidden
384    /// by closed folds. Returns `None` past the end of the buffer.
385    pub fn next_visible_row(&self, row: usize) -> Option<usize> {
386        let last = self.last_content_row();
387        if last == 0 && row == 0 {
388            return None;
389        }
390        let mut r = row.checked_add(1)?;
391        while r <= last && self.is_row_hidden(r) {
392            r += 1;
393        }
394        (r <= last).then_some(r)
395    }
396
397    /// First visible row strictly before `row`, skipping hidden rows.
398    pub fn prev_visible_row(&self, row: usize) -> Option<usize> {
399        let mut r = row.checked_sub(1)?;
400        while self.is_row_hidden(r) {
401            r = r.checked_sub(1)?;
402        }
403        Some(r)
404    }
405
406    /// Drop every fold that touches `[start_row, end_row]`.
407    pub fn invalidate_folds_in_range(&mut self, start_row: usize, end_row: usize) {
408        let before = self.content_lock().folds.len();
409        invalidate_folds(&mut self.content_lock_mut().folds, start_row, end_row);
410        if self.content_lock().folds.len() != before {
411            self.dirty_gen_bump();
412        }
413    }
414
415    /// Shift every buffer fold by an edit's row-delta band. Mirrors
416    /// [`crate::buffer::View::rebase_marks`] for the shared fold storage —
417    /// see [`shift_folds_after_edit`] for the per-fold rules.
418    pub fn rebase_folds(
419        &mut self,
420        edit_start: usize,
421        drop_end: usize,
422        shift_threshold: usize,
423        delta: isize,
424    ) {
425        if delta == 0 {
426            return;
427        }
428        let mut c = self.content_lock_mut();
429        shift_folds_after_edit(&mut c.folds, edit_start, drop_end, shift_threshold, delta);
430    }
431
432    /// Replace the entire fold set wholesale. Used to install a per-window fold
433    /// snapshot into the shared buffer on focus change (window-level folds): the
434    /// app keeps each window's open/closed state and swaps it in before dispatch,
435    /// so motions/render/`z`-ops operate on the focused window's folds.
436    pub fn set_folds(&mut self, folds: &[Fold]) {
437        {
438            let mut c = self.content_lock_mut();
439            if c.folds.as_slice() == folds {
440                return; // no-op — avoid a spurious dirty_gen bump
441            }
442            c.folds = folds.to_vec();
443        }
444        self.dirty_gen_bump();
445    }
446}
447
448/// Drop every fold in `folds` that touches `[start_row, end_row]`, in place.
449///
450/// Free helper so both [`crate::View::invalidate_folds_in_range`] (operating
451/// on the shared content) and the app's window-level edit-coherence pass
452/// (operating on a sibling window's owned `Vec<Fold>`) share one rule — vim
453/// opens/forgets any fold the edit overlapped.
454pub fn invalidate_folds(folds: &mut Vec<Fold>, start_row: usize, end_row: usize) {
455    folds.retain(|f| f.end_row < start_row || f.start_row > end_row);
456}
457
458// ── Row-delta shifting (edit-coherence) ──────────────────────────────────
459//
460// A manual (`zf`) fold is a row-range that has to track the same
461// insert/delete row-shift the engine already applies to marks and the
462// jumplist (see `Editor::shift_marks_after_edit`). Without this, a fold
463// below an edit keeps stale row numbers and the renderer / fold-aware ops
464// (`dd`, `p`, …) act on the wrong rows (#audit-r2 fix 1).
465//
466// The four `(edit_start, drop_end, shift_threshold, delta)` parameters are
467// the exact same band description `Editor::shift_marks_after_edit` computes
468// for marks: `[edit_start, drop_end)` is the row band the edit deleted
469// (empty for inserts), and any row `>= shift_threshold` moves by `delta`.
470// Reusing the identical band keeps folds, marks, and jumplist entries
471// shifting in lockstep for the same edit.
472
473/// Shift a single fold's `start_row` / `end_row` by an edit's row-delta band.
474/// Returns `None` when the edit's deleted band fully consumes the fold.
475///
476/// Each endpoint is mapped independently through the same drop/shift rule
477/// [`crate::buffer::View::rebase_marks`] applies to a point mark. Mapping
478/// the two endpoints independently is what produces the vim-shaped "edit
479/// inside a fold" semantics for free:
480/// - Both endpoints below `shift_threshold` and outside the deleted band →
481///   fold untouched (edit happened entirely outside the fold).
482/// - `start_row` outside the deleted band but `end_row` inside it → the
483///   edit deleted the fold's tail; it clips to end at the last surviving
484///   row (`edit_start - 1`).
485/// - `start_row` inside the deleted band but `end_row` outside it → the
486///   edit deleted the fold's head; it clips to start at `edit_start` (the
487///   row the surviving tail now occupies).
488/// - Both endpoints inside the deleted band → the edit consumed the whole
489///   fold; it's dropped.
490/// - `start_row` below the threshold and `end_row` at/above it (an insert
491///   or a deletion landing strictly inside the fold) → `start_row` stays,
492///   `end_row` shifts by `delta`: the fold grows (insert) or shrinks
493///   (delete) around the edit, matching vim.
494/// - Both endpoints at/above the threshold → the fold shifts wholesale.
495pub fn shift_fold(
496    fold: Fold,
497    edit_start: usize,
498    drop_end: usize,
499    shift_threshold: usize,
500    delta: isize,
501) -> Option<Fold> {
502    if delta == 0 {
503        return Some(fold);
504    }
505    let map_row = |row: usize| -> Option<usize> {
506        if (edit_start..drop_end).contains(&row) {
507            None
508        } else if row >= shift_threshold {
509            Some(((row as isize) + delta).max(0) as usize)
510        } else {
511            Some(row)
512        }
513    };
514    let mapped_start = map_row(fold.start_row);
515    let mapped_end = map_row(fold.end_row);
516    if mapped_start.is_none() && mapped_end.is_none() {
517        return None;
518    }
519    let new_start = mapped_start.unwrap_or(edit_start);
520    let new_end = mapped_end.unwrap_or_else(|| edit_start.saturating_sub(1));
521    if new_end < new_start {
522        return None;
523    }
524    Some(Fold {
525        start_row: new_start,
526        end_row: new_end,
527        closed: fold.closed,
528        auto_generated: fold.auto_generated,
529    })
530}
531
532/// Shift every fold in `folds` by an edit's row-delta band, in place.
533/// Folds the edit's deleted band fully consumes are dropped (mirrors
534/// [`invalidate_folds`] for the folds that DO survive but move).
535///
536/// Shared by [`crate::View::rebase_folds`] (engine-side, the buffer's own
537/// fold storage) and the app's sibling-window fold snapshot shift, so both
538/// converge on the identical row-shift rule.
539pub fn shift_folds_after_edit(
540    folds: &mut Vec<Fold>,
541    edit_start: usize,
542    drop_end: usize,
543    shift_threshold: usize,
544    delta: isize,
545) {
546    if delta == 0 {
547        return;
548    }
549    folds.retain_mut(
550        |f| match shift_fold(*f, edit_start, drop_end, shift_threshold, delta) {
551            Some(shifted) => {
552                *f = shifted;
553                true
554            }
555            None => false,
556        },
557    );
558}
559
560#[cfg(test)]
561mod tests {
562    use crate::View;
563
564    fn b() -> View {
565        View::from_str("a\nb\nc\nd\ne")
566    }
567
568    #[test]
569    fn add_keeps_folds_in_start_row_order() {
570        let mut buf = b();
571        buf.add_fold(2, 3, true);
572        buf.add_fold(0, 1, false);
573        let starts: Vec<usize> = buf.folds().iter().map(|f| f.start_row).collect();
574        assert_eq!(starts, vec![0, 2]);
575    }
576
577    #[test]
578    fn set_folds_replaces_wholesale() {
579        let mut buf = b();
580        buf.add_fold(0, 1, false);
581        // Install a different per-window snapshot.
582        let snapshot = vec![super::Fold {
583            start_row: 2,
584            end_row: 3,
585            closed: true,
586            auto_generated: false,
587        }];
588        buf.set_folds(&snapshot);
589        assert_eq!(buf.folds(), snapshot);
590        // Idempotent: re-installing the same set is a no-op (no dirty bump).
591        let dg = buf.dirty_gen();
592        buf.set_folds(&snapshot);
593        assert_eq!(buf.dirty_gen(), dg);
594    }
595
596    #[test]
597    fn invalidate_folds_helper_drops_overlapping() {
598        let f = |s, e| super::Fold {
599            start_row: s,
600            end_row: e,
601            closed: true,
602            auto_generated: false,
603        };
604        let mut folds = vec![f(0, 2), f(4, 6), f(8, 10)];
605        // Edit touches rows 5..5 → only the [4,6] fold overlaps.
606        super::invalidate_folds(&mut folds, 5, 5);
607        let starts: Vec<usize> = folds.iter().map(|x| x.start_row).collect();
608        assert_eq!(starts, vec![0, 8]);
609    }
610
611    #[test]
612    fn add_replaces_existing_with_same_start_row() {
613        let mut buf = b();
614        buf.add_fold(1, 2, true);
615        buf.add_fold(1, 4, false);
616        assert_eq!(buf.folds().len(), 1);
617        assert_eq!(buf.folds()[0].end_row, 4);
618        assert!(!buf.folds()[0].closed);
619    }
620
621    #[test]
622    fn add_clamps_end_row_to_buffer_bounds() {
623        let mut buf = b();
624        buf.add_fold(2, 99, true);
625        assert_eq!(buf.folds()[0].end_row, 4);
626    }
627
628    #[test]
629    fn add_rejects_inverted_range() {
630        let mut buf = b();
631        buf.add_fold(3, 1, true);
632        assert!(buf.folds().is_empty());
633    }
634
635    #[test]
636    fn toggle_flips_state() {
637        let mut buf = b();
638        buf.add_fold(1, 3, false);
639        assert!(!buf.folds()[0].closed);
640        assert!(buf.toggle_fold_at(2));
641        assert!(buf.folds()[0].closed);
642        assert!(buf.toggle_fold_at(2));
643        assert!(!buf.folds()[0].closed);
644    }
645
646    #[test]
647    fn is_row_hidden_excludes_start_row() {
648        let mut buf = b();
649        buf.add_fold(1, 3, true);
650        assert!(!buf.is_row_hidden(0));
651        assert!(!buf.is_row_hidden(1)); // start row stays visible
652        assert!(buf.is_row_hidden(2));
653        assert!(buf.is_row_hidden(3));
654        assert!(!buf.is_row_hidden(4));
655    }
656
657    #[test]
658    fn open_close_all_changes_every_fold() {
659        let mut buf = b();
660        buf.add_fold(0, 1, false);
661        buf.add_fold(2, 3, true);
662        buf.close_all_folds();
663        assert!(buf.folds().iter().all(|f| f.closed));
664        buf.open_all_folds();
665        assert!(buf.folds().iter().all(|f| !f.closed));
666    }
667
668    #[test]
669    fn invalidate_drops_overlapping_folds() {
670        let mut buf = b();
671        buf.add_fold(0, 1, true);
672        buf.add_fold(2, 3, true);
673        buf.add_fold(4, 4, true);
674        buf.invalidate_folds_in_range(2, 3);
675        let starts: Vec<usize> = buf.folds().iter().map(|f| f.start_row).collect();
676        assert_eq!(starts, vec![0, 4]);
677    }
678
679    // ── auto_generated flag + set_auto_folds ─────────────────────────────────
680
681    #[test]
682    fn add_fold_sets_auto_generated_false() {
683        let mut buf = b();
684        buf.add_fold(1, 3, false);
685        assert!(
686            !buf.folds()[0].auto_generated,
687            "manual add_fold must have auto_generated=false"
688        );
689    }
690
691    #[test]
692    fn set_auto_folds_adds_auto_folds() {
693        let mut buf = b();
694        buf.set_auto_folds(&[(0, 2), (3, 4)], false);
695        let folds = buf.folds();
696        assert_eq!(folds.len(), 2);
697        assert!(folds[0].auto_generated);
698        assert!(folds[1].auto_generated);
699        assert_eq!(folds[0].start_row, 0);
700        assert_eq!(folds[1].start_row, 3);
701    }
702
703    #[test]
704    fn set_auto_folds_second_call_replaces_first() {
705        let mut buf = b();
706        buf.set_auto_folds(&[(0, 2), (3, 4)], false);
707        assert_eq!(buf.folds().len(), 2);
708        // Replace with a different set.
709        buf.set_auto_folds(&[(1, 4)], false);
710        let folds = buf.folds();
711        assert_eq!(folds.len(), 1, "second call must replace first set");
712        assert_eq!(folds[0].start_row, 1);
713        assert!(folds[0].auto_generated);
714    }
715
716    #[test]
717    fn set_auto_folds_preserves_manual_folds() {
718        let mut buf = b();
719        // Add a manual fold.
720        buf.add_fold(0, 1, true);
721        // Auto-fold the remaining range.
722        buf.set_auto_folds(&[(2, 4)], false);
723        let folds = buf.folds();
724        assert_eq!(folds.len(), 2, "manual fold must survive set_auto_folds");
725        let manual = folds.iter().find(|f| f.start_row == 0).unwrap();
726        assert!(!manual.auto_generated, "manual fold flag must stay false");
727        let auto = folds.iter().find(|f| f.start_row == 2).unwrap();
728        assert!(auto.auto_generated);
729    }
730
731    #[test]
732    fn set_auto_folds_preserves_open_closed_state_by_start_row() {
733        let mut buf = b();
734        // First auto-fold pass: create a closed fold at row 0.
735        buf.set_auto_folds(&[(0, 2)], true); // default_closed=true → starts closed
736        assert!(buf.folds()[0].closed, "fold must start closed per default");
737
738        // User opens the fold (simulated by toggle).
739        buf.toggle_fold_at(0);
740        assert!(!buf.folds()[0].closed, "fold must now be open");
741
742        // Second auto-fold pass with same start_row — must preserve open state.
743        buf.set_auto_folds(&[(0, 2)], true); // default_closed=true but prev was open
744        assert!(
745            !buf.folds()[0].closed,
746            "open/closed state must be preserved across set_auto_folds"
747        );
748    }
749
750    #[test]
751    fn set_auto_folds_skips_single_row_and_inverted_ranges() {
752        let mut buf = b();
753        buf.set_auto_folds(&[(1, 1), (3, 2)], false);
754        assert!(
755            buf.folds().is_empty(),
756            "single-row and inverted ranges must be skipped"
757        );
758    }
759
760    #[test]
761    fn set_auto_folds_new_folds_use_default_closed() {
762        let mut buf = b();
763        buf.set_auto_folds(&[(0, 4)], true);
764        assert!(
765            buf.folds()[0].closed,
766            "new auto fold must use default_closed=true"
767        );
768
769        // Clear and re-run with default_closed=false.
770        buf.set_auto_folds(&[(0, 4)], false);
771        // This is a *new* start_row (it was removed + re-added), BUT the
772        // snapshot preserved the previous state (closed=true from above)
773        // because the start_row is the same.
774        // Wait — the test verifies the preservation path, not the default path.
775        // Let's use a fresh start_row to test the default path:
776        let mut buf2 = b();
777        buf2.set_auto_folds(&[(2, 4)], false);
778        assert!(
779            !buf2.folds()[0].closed,
780            "brand-new auto fold must start open when default_closed=false"
781        );
782    }
783
784    // ── row-delta shifting (audit-r2 fix 1) ───────────────────────────────
785
786    fn fold(s: usize, e: usize) -> super::Fold {
787        super::Fold {
788            start_row: s,
789            end_row: e,
790            closed: true,
791            auto_generated: false,
792        }
793    }
794
795    #[test]
796    fn shift_fold_insert_above_shifts_down() {
797        // 10-line file, fold at rows 4..6, insert one row at row 0
798        // (`ggO x<Esc>`): vim shifts the fold to 5..7.
799        let f = fold(4, 6);
800        let shifted = super::shift_fold(f, 0, 0, 1, 1).unwrap();
801        assert_eq!((shifted.start_row, shifted.end_row), (5, 7));
802    }
803
804    #[test]
805    fn shift_fold_delete_above_shifts_up() {
806        // Fold at rows 4..6, one row deleted above at row 0.
807        let f = fold(4, 6);
808        let shifted = super::shift_fold(f, 0, 1, 1, -1).unwrap();
809        assert_eq!((shifted.start_row, shifted.end_row), (3, 5));
810    }
811
812    #[test]
813    fn shift_fold_delete_fully_overlapping_drops() {
814        // Fold at rows 4..6, deletion covers rows 4..6 entirely.
815        let f = fold(4, 6);
816        assert!(super::shift_fold(f, 4, 7, 7, -3).is_none());
817    }
818
819    #[test]
820    fn shift_fold_delete_overlapping_tail_clips() {
821        // Fold at rows 4..6, deletion of rows 6..8 (tail only) clips the
822        // fold to end at the last surviving row.
823        let f = fold(4, 6);
824        let shifted = super::shift_fold(f, 6, 9, 9, -3).unwrap();
825        assert_eq!((shifted.start_row, shifted.end_row), (4, 5));
826    }
827
828    #[test]
829    fn shift_fold_delete_overlapping_head_clips() {
830        // Fold at rows 4..6, deletion of rows 3..4 (head only) clips the
831        // fold to start where the surviving tail now sits.
832        let f = fold(4, 6);
833        let shifted = super::shift_fold(f, 3, 5, 5, -2).unwrap();
834        assert_eq!((shifted.start_row, shifted.end_row), (3, 4));
835    }
836
837    #[test]
838    fn shift_fold_edit_inside_grows_on_insert() {
839        // Fold at rows 4..6, a line inserted at row 5 (strictly inside):
840        // vim grows the fold's end, leaves the start alone.
841        let f = fold(4, 6);
842        let shifted = super::shift_fold(f, 5, 5, 6, 1).unwrap();
843        assert_eq!((shifted.start_row, shifted.end_row), (4, 7));
844    }
845
846    #[test]
847    fn shift_fold_edit_inside_shrinks_on_delete() {
848        // Fold at rows 4..8, rows 5..6 deleted (strictly inside): the fold
849        // shrinks around the deletion instead of clipping or dropping.
850        let f = fold(4, 8);
851        let shifted = super::shift_fold(f, 5, 7, 7, -2).unwrap();
852        assert_eq!((shifted.start_row, shifted.end_row), (4, 6));
853    }
854
855    #[test]
856    fn shift_fold_unaffected_when_entirely_before_edit() {
857        let f = fold(1, 2);
858        let shifted = super::shift_fold(f, 10, 11, 11, 1).unwrap();
859        assert_eq!((shifted.start_row, shifted.end_row), (1, 2));
860    }
861
862    #[test]
863    fn shift_fold_zero_delta_is_noop() {
864        let f = fold(4, 6);
865        let shifted = super::shift_fold(f, 0, 0, 0, 0).unwrap();
866        assert_eq!(shifted, f);
867    }
868
869    #[test]
870    fn shift_folds_after_edit_shifts_vec_in_place() {
871        let mut folds = vec![fold(4, 6), fold(1, 2)];
872        // Insert one row at row 0: both folds shift down.
873        super::shift_folds_after_edit(&mut folds, 0, 0, 1, 1);
874        let ranges: Vec<(usize, usize)> = folds.iter().map(|f| (f.start_row, f.end_row)).collect();
875        assert_eq!(ranges, vec![(5, 7), (2, 3)]);
876    }
877
878    #[test]
879    fn shift_folds_after_edit_drops_fully_consumed() {
880        let mut folds = vec![fold(4, 6)];
881        super::shift_folds_after_edit(&mut folds, 4, 7, 7, -3);
882        assert!(folds.is_empty());
883    }
884
885    #[test]
886    fn rebase_folds_shifts_buffer_fold_storage() {
887        let mut buf = View::from_str("0\n1\n2\n3\n4\n5\n6\n7\n8\n9");
888        buf.add_fold(4, 6, true);
889        buf.rebase_folds(0, 0, 1, 1);
890        let folds = buf.folds();
891        assert_eq!(folds.len(), 1);
892        assert_eq!((folds[0].start_row, folds[0].end_row), (5, 7));
893    }
894}