Skip to main content

lattice_ui_tui/app/
folds.rs

1//! Code folding -- the App-side surface above
2//! `crate::folds`. Vim's `z*` commands (zo / zc / za / zR /
3//! zM / zd / zf), the `recompute_folds` provider dispatch
4//! (manual / indent / markdown / syntax), and the
5//! cursor-snap glue that keeps `j` / `k` / search hits from
6//! landing inside hidden fold bodies.
7//!
8//! Methods that live here:
9//! - `recompute_folds` (dispatcher across foldmethods),
10//!   `recompute_syntax_folds` (private syntax-provider
11//!   helper with markdown / indent fallback).
12//! - `do_create_fold_from_visual` (`zf`), the
13//!   `do_set_fold_state_at_cursor` family (`zo` / `zc` /
14//!   `za`), `do_set_all_folds` (`zR` / `zM`),
15//!   `do_goto_fold` (`zj` / `zk`),
16//!   `do_delete_fold_at_cursor` (`zd`).
17//! - Renderer-side queries: `line_inside_closed_fold`,
18//!   `fold_start_at`, `fold_start_at_any`.
19//! - Cursor maintenance: `snap_cursor_past_closed_folds`,
20//!   `auto_open_folds_at_cursor`.
21//! - Free fns: `compute_fold_hash` (cache key for
22//!   refresh_highlights), `innermost_fold_idx`,
23//!   `fold_to_close_at`, `outermost_fold_idx` (the
24//!   selection rules behind zo / zc / za).
25//!
26//! What does NOT live here: the fold algorithm itself
27//! (compute_indent_folds / compute_markdown_folds /
28//! compute_syntax_folds in `crate::folds`), the `Fold`
29//! struct (lives in `app.rs`), tree-sitter query plumbing
30//! (`crate::syntax`).
31
32use super::{App, Fold};
33
34impl App {
35    /// Refresh [`Self::folds`] from the active [`FoldMethod`].
36    /// `manual` -- no-op (preserves user `zf` folds). The other
37    /// providers (`indent` / `markdown` / `syntax`) replace `folds`
38    /// with the recomputed set, preserving the closed/open state of
39    /// any existing fold whose identity matches a recomputed one
40    /// (so `zc` survives a reparse).
41    ///
42    /// `Syntax` runs the language's tree-sitter `folds.scm` query
43    /// against the live parse tree and emits one fold per `@fold`
44    /// capture spanning more than one line. When the buffer's
45    /// language doesn't ship a `folds.scm` (or the parse tree
46    /// hasn't been built yet), the syntax provider cascades to the
47    /// markdown / indent providers based on the file extension --
48    /// so `:set foldmethod=syntax` is useful even on a plain-text
49    /// buffer.
50    /// 5.5.D: full fold recompute moved to
51    /// [`lattice_host::editor::Editor::recompute_folds`] alongside
52    /// `recompute_syntax_folds` / `recompute_lsp_folds`. Renderer
53    /// call sites keep this thin wrapper until 5.5.G collapses
54    /// App's match entirely.
55    pub fn recompute_folds(&mut self) {
56        // Slice 3c.final.E.2: route through `mutate_editor`.
57        self.mutate_editor(|e| e.recompute_folds());
58    }
59
60    // 5.5.G.16: `do_create_fold_from_visual` migrated to
61    // [`lattice_host::dispatch::Editor`].
62
63    // 5.5.G.1: `do_set_fold_state_at_cursor` / `do_set_all_folds`
64    // / `do_goto_fold` / `do_delete_fold_at_cursor` all migrated
65    // to [`lattice_host::dispatch::Editor`] (the `Action::*Fold*`
66    // arms in `Editor::dispatch` call them directly). The private
67    // selection-rule helpers `innermost_fold_idx`, `fold_to_close_at`,
68    // and `outermost_fold_idx` co-moved.
69
70    /// 5.8.U: body migrated to
71    /// [`lattice_host::dispatch::Editor::line_inside_closed_fold`]
72    /// so the GPUI peer can reach the same check.
73    pub fn line_inside_closed_fold(&self, line: u32) -> bool {
74        // Slice 3c.final.X.cleanup: read folds from published
75        // `ad()` instead of via the actor seam. Editor::line_inside_closed_fold
76        // gates on `option_cache.foldenable` then walks `self.folds` —
77        // both are mirrored on `ActiveDocumentRenderState`.
78        // cfg(test) escape hatch — see `App::cursor()`.
79        #[cfg(test)]
80        {
81            self.editor.line_inside_closed_fold(line)
82        }
83        #[cfg(not(test))]
84        {
85            let ad = self.ad();
86            if !ad.option_cache.foldenable {
87                return false;
88            }
89            ad.folds
90                .iter()
91                .any(|f| f.closed && line > f.start_line && line <= f.end_line)
92        }
93    }
94
95    /// 5.5.G.23: body migrated to
96    /// [`lattice_host::dispatch::Editor::fold_start_at`]. Retained
97    /// as a delegate because the renderer's per-frame gutter pass
98    /// (search.rs, motions.rs, render.rs) and the host-side
99    /// `run_document_invocation` both still call it before the
100    /// helper deletion sweep.
101    pub fn fold_start_at(&self, line: u32) -> Option<Fold> {
102        // Slice 3c.final.X.cleanup: read via published `ad().folds`.
103        // cfg(test) escape hatch — see `App::cursor()`.
104        #[cfg(test)]
105        {
106            self.editor.fold_start_at(line).copied()
107        }
108        #[cfg(not(test))]
109        {
110            let ad = self.ad();
111            if !ad.option_cache.foldenable {
112                return None;
113            }
114            ad.folds
115                .iter()
116                .find(|f| f.closed && f.start_line == line)
117                .copied()
118        }
119    }
120
121    /// 5.5.G.23: body migrated to
122    /// [`lattice_host::dispatch::Editor::fold_start_at_any`]. Retained
123    /// as a 1-line delegate; renderer call sites retire on the same
124    /// sweep as `fold_start_at`.
125    pub fn fold_start_at_any(&self, line: u32) -> Option<Fold> {
126        // Slice 3c.final.X.cleanup: read via published `ad().folds`.
127        // cfg(test) escape hatch — see `App::cursor()`.
128        #[cfg(test)]
129        {
130            self.editor.fold_start_at_any(line).copied()
131        }
132        #[cfg(not(test))]
133        {
134            let ad = self.ad();
135            if !ad.option_cache.foldenable {
136                return None;
137            }
138            ad.folds.iter().find(|f| f.start_line == line).copied()
139        }
140    }
141
142    /// 5.5.G.23: body migrated to
143    /// [`lattice_host::dispatch::Editor::snap_cursor_past_closed_folds`].
144    /// Retained as a 1-line delegate because the renderer's
145    /// search-result + motions paths still drive it; deletion
146    /// follows once those callers go host-side.
147    pub(super) fn snap_cursor_past_closed_folds(&mut self, prev_line: u32) {
148        // Slice 3c.final.E.2: route through `mutate_editor`.
149        self.mutate_editor(move |e| e.snap_cursor_past_closed_folds(prev_line));
150    }
151
152    /// 5.5.G.4: body migrated to
153    /// [`lattice_host::dispatch::Editor::auto_open_folds_at_cursor`].
154    /// Retained as a delegate because 7 ui-tui call sites
155    /// (search.rs, motions.rs, dispatch.rs) still invoke it; the
156    /// delegate retires when those call sites migrate.
157    pub fn auto_open_folds_at_cursor(&mut self) {
158        // Slice 3c.final.E.2: route through `mutate_editor`.
159        self.mutate_editor(|e| e.auto_open_folds_at_cursor());
160    }
161}
162
163/// Hash a slice of folds into a single u64 for the highlight
164/// cache key. `start_line` / `end_line` / `closed` is the
165/// minimum that affects the visible window; `identity` is
166/// excluded -- two folds with the same range and state but
167/// different identities don't change which bytes are visible.
168pub(super) fn compute_fold_hash(folds: &[Fold]) -> u64 {
169    use std::hash::{DefaultHasher, Hash, Hasher};
170    let mut h = DefaultHasher::new();
171    folds.len().hash(&mut h);
172    for f in folds {
173        f.start_line.hash(&mut h);
174        f.end_line.hash(&mut h);
175        f.closed.hash(&mut h);
176    }
177    h.finish()
178}
179
180// 5.5.G.1: private selection-rule helpers (`innermost_fold_idx`,
181// `fold_to_close_at`, `outermost_fold_idx`) migrated to
182// `lattice_host::dispatch` alongside the `do_*` arms that called
183// them.
184
185#[cfg(test)]
186mod tests {
187    #![allow(clippy::unwrap_used, clippy::panic)]
188
189    use super::compute_fold_hash;
190    use crate::app::test_helpers::{
191        app_with, attach_test_syntax, invoke_motion, press, press_chars,
192    };
193    use crate::app::*;
194    use lattice_grammar::{ModalState, VisualKind};
195    use lattice_protocol::edit::Edit;
196    use lattice_protocol::position::Position;
197
198    // Phase 5.8.AF.5 / Slice X2.6: three `refresh_highlights_cache_
199    // invalidates_*` tests were retired with the
200    // `Editor::visible_highlights_key` field they pinned. Equivalent
201    // coverage for the worker's cache-invalidation contract
202    // (fold change, fold toggle, edit -> new snapshot) lives in
203    // `lattice_host::overlay_worker::recompute` tests (display-line
204    // B4.2: the span-cache worker was gutted to the overlay bucket).
205
206    /// Reported 2026-09-01: navigating from the agenda (or through an org
207    /// link) to another org file "does not unfold enough to reveal the
208    /// headline we navigated to".
209    ///
210    /// `org-mode` sets `foldlevel = 0`, so an org file opens fully collapsed.
211    /// The cross-file jump then set the cursor and stopped, leaving it inside
212    /// a closed fold — the user arrives looking at a collapsed outline with no
213    /// sign of where they went.
214    ///
215    /// Driven through the real `Effect::OpenBufferAt` — the effect BOTH the
216    /// agenda's `<CR>` and org's link-follow emit — against a real file with
217    /// real syntax folds, because the doubt was never whether
218    /// `auto_open_folds_at_cursor` works. It was whether the folds exist yet at
219    /// the moment the jump positions the cursor.
220    #[tokio::test(flavor = "multi_thread")]
221    async fn a_cross_file_jump_reveals_the_line_it_lands_on() {
222        let dir = crate::app::test_helpers::unique_tempdir();
223        std::fs::create_dir_all(&dir).unwrap();
224        let target = dir.join("target.rs");
225        // Nested blocks so `foldmethod=syntax` yields a fold that CONTAINS the
226        // line we jump to, rather than one that merely starts there.
227        std::fs::write(
228            &target,
229            "fn outer() {\n    let a = 1;\n    if a > 0 {\n        let deep = 2;\n    }\n}\n",
230        )
231        .unwrap();
232
233        let mut a = app_with("elsewhere\n", 20);
234        a.mutate_editor(|e| {
235            e.handle_effect(lattice_grammar::Effect::SetOption {
236                spec: "foldmethod=syntax".to_string(),
237            });
238            // What `org-mode` contributes, spelled globally so this test needs
239            // no org plugin: the file opens fully collapsed.
240            e.handle_effect(lattice_grammar::Effect::SetOption {
241                spec: "foldlevel=0".to_string(),
242            });
243        });
244
245        let deep = Position::new(3, 8);
246        a.apply_effect(lattice_grammar::Effect::OpenBufferAt {
247            path: Some(target.clone()),
248            position: deep,
249            force: false,
250            content: None,
251            activate_minor: None,
252        });
253
254        let folds = a.editor.folds.clone();
255        assert!(
256            !folds.is_empty(),
257            "precondition: the target file has syntax folds; without any, this \
258             test would pass on the broken code too"
259        );
260        assert!(
261            folds
262                .iter()
263                .any(|f| deep.line >= f.start_line && deep.line <= f.end_line),
264            "precondition: the landing line is inside a fold, got {folds:?}"
265        );
266        assert!(
267            !folds
268                .iter()
269                .any(|f| f.closed && deep.line >= f.start_line && deep.line <= f.end_line),
270            "the jump revealed its target — no closed fold still hides it, got {folds:?}"
271        );
272    }
273
274    /// CD.2, through the TUI peer: a plugin's `OpenBufferAt` with `content`
275    /// and `activate_minor` seeds a new file, activates the minor and lands
276    /// the cursor — the same host body the GPUI peer calls.
277    #[test]
278    fn open_buffer_at_seeds_a_new_file_and_activates_its_minor() {
279        let dir = std::env::temp_dir().join(format!(
280            "lattice-tui-cd2-{}-{}",
281            std::process::id(),
282            std::time::SystemTime::now()
283                .duration_since(std::time::UNIX_EPOCH)
284                .map(|d| d.as_nanos())
285                .unwrap_or(0)
286        ));
287        std::fs::create_dir_all(&dir).unwrap();
288        let path = dir.join("draft.org");
289
290        let mut a = app_with("elsewhere\n", 20);
291        a.apply_effect(lattice_grammar::Effect::OpenBufferAt {
292            path: Some(path.clone()),
293            position: Position::new(0, 7),
294            force: false,
295            content: Some("* TODO thing\n".to_string()),
296            activate_minor: Some("read-only-mode".to_string()),
297        });
298
299        assert_eq!(a.editor.document.snapshot().text(), "* TODO thing\n");
300        assert_eq!(a.editor.cursor, Position::new(0, 7));
301        let id = a.editor.active_pane_buffer_id();
302        assert!(
303            a.editor
304                .active_modes
305                .get(&id)
306                .is_some_and(|m| m.is_active(lattice_mode::ModeId::new("read-only-mode"))),
307            "the minor rides the open"
308        );
309        assert!(!path.exists(), "nothing written before :w");
310        let _ = std::fs::remove_dir_all(&dir);
311    }
312
313    // ---- compute_fold_hash ----
314
315    #[test]
316    fn fold_hash_distinguishes_closed_vs_open() {
317        let f_open = Fold {
318            start_line: 0,
319            end_line: 2,
320            closed: false,
321            identity: None,
322        };
323        let f_closed = Fold {
324            closed: true,
325            ..f_open
326        };
327        assert_ne!(compute_fold_hash(&[f_open]), compute_fold_hash(&[f_closed]));
328    }
329
330    #[test]
331    fn fold_hash_distinguishes_different_ranges() {
332        let f1 = Fold {
333            start_line: 0,
334            end_line: 2,
335            closed: false,
336            identity: None,
337        };
338        let f2 = Fold {
339            start_line: 0,
340            end_line: 3,
341            closed: false,
342            identity: None,
343        };
344        assert_ne!(compute_fold_hash(&[f1]), compute_fold_hash(&[f2]));
345    }
346
347    #[test]
348    fn fold_hash_ignores_identity() {
349        // identity is metadata for closed-state preservation
350        // across recomputes; doesn't affect which bytes are
351        // visible. Two folds with same range/state but
352        // different identities should hash equal.
353        let f1 = Fold {
354            start_line: 0,
355            end_line: 2,
356            closed: false,
357            identity: Some(42),
358        };
359        let f2 = Fold {
360            identity: Some(99),
361            ..f1
362        };
363        assert_eq!(compute_fold_hash(&[f1]), compute_fold_hash(&[f2]));
364    }
365
366    // ---- z* fold operations ----
367
368    // ---- Folds (zf, zo, zc, za, zR, zM, zd) ----
369
370    #[test]
371    fn zf_from_visual_creates_a_closed_fold() {
372        let mut a = app_with("a\nb\nc\nd\ne", 10);
373        a.apply(Action::EnterVisual(VisualKind::Linewise));
374        a.apply(invoke_motion(a.editor.builtins.line_down));
375        a.apply(invoke_motion(a.editor.builtins.line_down));
376        // Selection now spans lines 0..2.
377        a.apply(Action::CreateFoldFromVisual);
378        assert_eq!(a.editor.folds.len(), 1);
379        let fold = &a.editor.folds[0];
380        assert_eq!(fold.start_line, 0);
381        assert_eq!(fold.end_line, 2);
382        assert!(fold.closed);
383        // Visual exited.
384        assert_eq!(a.editor.modal, ModalState::Normal);
385    }
386
387    #[test]
388    fn zf_outside_visual_emits_error() {
389        let mut a = app_with("hello", 10);
390        a.apply(Action::CreateFoldFromVisual);
391        let msg = a.editor.last_message.as_ref().unwrap();
392        assert_eq!(msg.level, EchoLevel::Error);
393    }
394
395    // ---- VM.3h: `zf` is an operator; scrolls reach Visual and Select ----
396
397    /// `zfj` folds the cursor line and the next, over real keystrokes (an
398    /// operator needs the real path). vim: `zfj` folds two lines, closed.
399    #[test]
400    fn zf_j_folds_two_lines() {
401        let mut a = app_with("a\nb\nc\nd\ne", 10);
402        press_chars(&mut a, "zfj");
403        assert_eq!(a.editor.folds.len(), 1, "folds: {:?}", a.editor.folds);
404        let f = &a.editor.folds[0];
405        assert_eq!((f.start_line, f.end_line, f.closed), (0, 1, true));
406    }
407
408    /// VM.3h: `zfk` folds UPWARD from column 0.
409    ///
410    /// The slice recorded this as a known gap: `k` was exclusive and there
411    /// were no linewise operator targets, so the span ended at byte 0 of the
412    /// cursor's own line and no fold was made. VM.3L added those targets, and
413    /// nothing pinned the case until now. vim 9.2 from line 3 folds lines
414    /// 2–3, which is what this asserts (0-based 1–2).
415    #[test]
416    fn zf_k_folds_upward() {
417        let mut a = app_with("a\nb\nc\nd\ne", 10);
418        a.editor.cursor = Position::new(2, 0);
419        press_chars(&mut a, "zfk");
420        assert_eq!(a.editor.folds.len(), 1, "folds: {:?}", a.editor.folds);
421        let f = &a.editor.folds[0];
422        assert_eq!((f.start_line, f.end_line, f.closed), (1, 2, true));
423    }
424
425    /// `zfip` folds a paragraph, not the blank line after it.
426    #[test]
427    fn zf_ip_folds_a_paragraph() {
428        let mut a = app_with("a\nb\n\nc\nd", 10);
429        press_chars(&mut a, "zfip");
430        assert_eq!(a.editor.folds.len(), 1, "folds: {:?}", a.editor.folds);
431        let f = &a.editor.folds[0];
432        assert_eq!((f.start_line, f.end_line), (0, 1));
433    }
434
435    /// vim: charwise `vjzf` folds WHOLE lines 1–2 and ends Visual. The end
436    /// matters: the host leaves Visual after an operator only when it edits
437    /// or yanks, and a fold does neither.
438    #[test]
439    fn visual_zf_folds_whole_lines_and_ends_visual() {
440        let mut a = app_with("abc\ndef\nghi", 10);
441        a.editor.cursor = Position::new(0, 1);
442        press_chars(&mut a, "vjzf");
443        assert_eq!(a.editor.folds.len(), 1, "folds: {:?}", a.editor.folds);
444        let f = &a.editor.folds[0];
445        assert_eq!((f.start_line, f.end_line, f.closed), (0, 1, true));
446        assert_eq!(a.editor.modal, ModalState::Normal);
447    }
448
449    /// `zf` alone waits for a motion instead of erroring, as an operator does.
450    #[test]
451    fn zf_alone_waits_for_a_motion() {
452        let mut a = app_with("a\nb\nc", 10);
453        press_chars(&mut a, "zf");
454        assert!(a.editor.folds.is_empty());
455        assert_eq!(a.editor.partial_chord.len(), 2, "`zf` is a pending prefix");
456    }
457
458    /// No doubled form: `zff` must stay a prefix, or it would shadow
459    /// `zff{char}` (fold to the next `{char}`).
460    #[test]
461    fn zff_is_a_prefix_not_a_doubled_operator() {
462        let a = app_with("a", 10);
463        let path = [
464            crate::chord::KeyChord::char('z'),
465            crate::chord::KeyChord::char('f'),
466            crate::chord::KeyChord::char('f'),
467        ];
468        assert!(
469            matches!(
470                a.editor
471                    .keymap
472                    .lookup(crate::keymap::BindingMode::Normal, &path),
473                crate::keymap_trie::LookupResult::Partial
474            ),
475            "`zff` must wait for the find-char target"
476        );
477    }
478
479    /// vim scrolls with `<C-f>` in Visual, and the selection follows the
480    /// cursor.
481    #[test]
482    fn ctrl_f_in_visual_pages_and_grows_the_selection() {
483        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
484        let text: String = (0..80).map(|n| format!("line {n}\n")).collect();
485        let mut a = app_with(&text, 10);
486        press_chars(&mut a, "v");
487        press(
488            &mut a,
489            KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL),
490        );
491        assert!(matches!(a.editor.modal, ModalState::Visual(_)));
492        assert!(a.editor.cursor.line > 0, "<C-f> pages the cursor down");
493        assert_eq!(
494            a.editor.visual_anchor.map(|p| p.line),
495            Some(0),
496            "the anchor stays, so the selection grows"
497        );
498    }
499
500    /// In Select a Ctrl chord never overtypes: `<C-f>` pages, the buffer is
501    /// untouched, and Select stays.
502    #[test]
503    fn ctrl_f_in_select_pages_rather_than_overtyping() {
504        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
505        let text: String = (0..80).map(|n| format!("line {n}\n")).collect();
506        let mut a = app_with(&text, 10);
507        press_chars(&mut a, "gh");
508        assert!(
509            matches!(a.editor.modal, ModalState::Select(_)),
510            "test premise"
511        );
512        press(
513            &mut a,
514            KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL),
515        );
516        assert!(matches!(a.editor.modal, ModalState::Select(_)));
517        assert!(a.editor.cursor.line > 0);
518        assert_eq!(a.editor.document.snapshot().buffer.as_string(), text);
519    }
520
521    /// The `z` family is Normal + Visual only: in Select `z` is typed text.
522    #[test]
523    fn z_in_select_still_overtypes() {
524        let mut a = app_with("abc", 10);
525        press_chars(&mut a, "gh");
526        assert!(
527            matches!(a.editor.modal, ModalState::Select(_)),
528            "test premise"
529        );
530        press_chars(&mut a, "z");
531        assert!(
532            a.editor
533                .document
534                .snapshot()
535                .buffer
536                .as_string()
537                .starts_with('z')
538        );
539        assert_eq!(a.editor.modal, ModalState::Insert);
540    }
541
542    /// `zz` in Visual scrolls and leaves the selection exactly as it was.
543    #[test]
544    fn zz_in_visual_scrolls_without_touching_the_selection() {
545        let text: String = (0..80).map(|n| format!("line {n}\n")).collect();
546        let mut a = app_with(&text, 10);
547        a.editor.cursor = Position::new(30, 0);
548        press_chars(&mut a, "vj");
549        let (anchor, cursor) = (a.editor.visual_anchor, a.editor.cursor);
550        press_chars(&mut a, "zz");
551        assert!(matches!(a.editor.modal, ModalState::Visual(_)));
552        assert_eq!(a.editor.visual_anchor, anchor);
553        assert_eq!(a.editor.cursor, cursor);
554    }
555
556    /// VM.3i: `zk` composes with an operator. vim 9.2, folds on 4–6 and 9–10,
557    /// `dzk` from 12,3 deletes "line 10\nline 11\nli" charwise, landing on
558    /// 10,1.
559    /// VM.3i: a count repeats `zj`, over real keystrokes (the host's
560    /// `dispatch_chord` harness doesn't carry counts). vim 9.2: `2zj` from 1,3,
561    /// folds on 4–6 and 9–10, lands on 9,1.
562    #[test]
563    fn a_count_repeats_zj() {
564        let text: String = (1..=14).map(|n| format!("line {n}\n")).collect();
565        let mut a = app_with(&text, 20);
566        for (start_line, end_line) in [(3, 5), (8, 9)] {
567            a.editor.folds.push(Fold {
568                start_line,
569                end_line,
570                closed: false,
571                identity: None,
572            });
573        }
574        a.editor.cursor = Position::new(0, 2);
575        press_chars(&mut a, "2zj");
576        assert_eq!(a.editor.cursor, Position::new(8, 0));
577    }
578
579    /// VM.3L closes VM.3i's gap: `dzj` from 1,3 obeys `:h exclusive-linewise`
580    /// and keeps line 3's newline, as vim 9.2 does ("ne 1\nline 2\nline 3").
581    #[test]
582    fn dzj_keeps_the_newline_before_the_next_fold() {
583        let text: String = (1..=14).map(|n| format!("line {n}\n")).collect();
584        let mut a = app_with(&text, 20);
585        for (start_line, end_line) in [(3, 5), (8, 9)] {
586            a.editor.folds.push(Fold {
587                start_line,
588                end_line,
589                closed: false,
590                identity: None,
591            });
592        }
593        a.editor.cursor = Position::new(0, 2);
594        press_chars(&mut a, "dzj");
595        let after = a.editor.document.snapshot().buffer.as_string();
596        assert_eq!(after.lines().next(), Some("li"), "{after:?}");
597        assert_eq!(after.lines().nth(1), Some("line 4"), "{after:?}");
598        assert_eq!(after.lines().count(), 12);
599    }
600
601    #[test]
602    fn dzk_deletes_back_to_the_previous_fold_end() {
603        let text: String = (1..=14).map(|n| format!("line {n}\n")).collect();
604        let mut a = app_with(&text, 20);
605        for (start_line, end_line) in [(3, 5), (8, 9)] {
606            a.editor.folds.push(Fold {
607                start_line,
608                end_line,
609                closed: false,
610                identity: None,
611            });
612        }
613        a.editor.cursor = Position::new(11, 2);
614        press_chars(&mut a, "dzk");
615        let after = a.editor.document.snapshot().buffer.as_string();
616        assert_eq!(
617            after.lines().nth(9),
618            Some("ne 12"),
619            "lines 10-12 join at the deleted span: {after:?}"
620        );
621        assert_eq!(after.lines().count(), 12);
622        assert_eq!(a.editor.cursor, Position::new(9, 0));
623    }
624
625    #[test]
626    fn zo_opens_fold_at_cursor() {
627        let mut a = app_with("a\nb\nc", 10);
628        a.editor.folds.push(Fold {
629            start_line: 0,
630            end_line: 2,
631            closed: true,
632            identity: None,
633        });
634        a.apply(Action::OpenFoldAtCursor);
635        assert!(!a.editor.folds[0].closed);
636    }
637
638    #[test]
639    fn zc_closes_fold_at_cursor() {
640        let mut a = app_with("a\nb\nc", 10);
641        a.editor.folds.push(Fold {
642            start_line: 0,
643            end_line: 2,
644            closed: false,
645            identity: None,
646        });
647        a.apply(Action::CloseFoldAtCursor);
648        assert!(a.editor.folds[0].closed);
649    }
650
651    #[test]
652    fn za_toggles_fold_at_cursor() {
653        let mut a = app_with("a\nb\nc", 10);
654        a.editor.folds.push(Fold {
655            start_line: 0,
656            end_line: 2,
657            closed: false,
658            identity: None,
659        });
660        a.apply(Action::ToggleFoldAtCursor);
661        assert!(a.editor.folds[0].closed);
662        a.apply(Action::ToggleFoldAtCursor);
663        assert!(!a.editor.folds[0].closed);
664    }
665
666    #[test]
667    fn capital_zr_opens_all_folds() {
668        let mut a = app_with("a\nb\nc\nd", 10);
669        a.editor.folds.push(Fold {
670            start_line: 0,
671            end_line: 1,
672            closed: true,
673            identity: None,
674        });
675        a.editor.folds.push(Fold {
676            start_line: 2,
677            end_line: 3,
678            closed: true,
679            identity: None,
680        });
681        a.apply(Action::OpenAllFolds);
682        assert!(a.editor.folds.iter().all(|f| !f.closed));
683    }
684
685    #[test]
686    fn capital_zm_closes_all_folds() {
687        let mut a = app_with("a\nb\nc\nd", 10);
688        a.editor.folds.push(Fold {
689            start_line: 0,
690            end_line: 1,
691            closed: false,
692            identity: None,
693        });
694        a.editor.folds.push(Fold {
695            start_line: 2,
696            end_line: 3,
697            closed: false,
698            identity: None,
699        });
700        a.apply(Action::CloseAllFolds);
701        assert!(a.editor.folds.iter().all(|f| f.closed));
702    }
703
704    #[test]
705    fn zd_deletes_fold_at_cursor() {
706        let mut a = app_with("a\nb\nc\nd", 10);
707        a.editor.folds.push(Fold {
708            start_line: 0,
709            end_line: 2,
710            closed: true,
711            identity: None,
712        });
713        a.editor.cursor = Position::new(1, 0);
714        a.apply(Action::DeleteFoldAtCursor);
715        assert!(a.editor.folds.is_empty());
716    }
717
718    // --- Nested-fold semantics (`zc` / `zo` / `za` / `zd`) -----
719
720    fn nested_folds_app() -> App {
721        // Two nested open folds: outer covers lines 0..=10, inner
722        // covers 2..=8. Cursor sits inside both at line 4.
723        let mut a = app_with(&"x\n".repeat(12), 10);
724        a.editor.folds.push(Fold {
725            start_line: 0,
726            end_line: 10,
727            closed: false,
728            identity: None,
729        });
730        a.editor.folds.push(Fold {
731            start_line: 2,
732            end_line: 8,
733            closed: false,
734            identity: None,
735        });
736        a.editor.cursor = Position::new(4, 0);
737        a
738    }
739
740    #[test]
741    fn zc_closes_innermost_open_fold_first() {
742        let mut a = nested_folds_app();
743        a.apply(Action::CloseFoldAtCursor);
744        let inner = a.editor.folds.iter().find(|f| f.start_line == 2).unwrap();
745        let outer = a.editor.folds.iter().find(|f| f.start_line == 0).unwrap();
746        assert!(inner.closed, "inner should close first");
747        assert!(!outer.closed, "outer should remain open until next zc");
748    }
749
750    #[test]
751    fn second_zc_closes_outer_fold() {
752        let mut a = nested_folds_app();
753        a.apply(Action::CloseFoldAtCursor); // closes inner
754        a.apply(Action::CloseFoldAtCursor); // should close outer
755        let inner = a.editor.folds.iter().find(|f| f.start_line == 2).unwrap();
756        let outer = a.editor.folds.iter().find(|f| f.start_line == 0).unwrap();
757        assert!(inner.closed);
758        assert!(outer.closed);
759    }
760
761    #[test]
762    fn zo_opens_outermost_closed_fold_first() {
763        let mut a = nested_folds_app();
764        // Both folds closed.
765        for f in a.editor.folds.iter_mut() {
766            f.closed = true;
767        }
768        a.apply(Action::OpenFoldAtCursor);
769        let outer = a.editor.folds.iter().find(|f| f.start_line == 0).unwrap();
770        let inner = a.editor.folds.iter().find(|f| f.start_line == 2).unwrap();
771        assert!(!outer.closed, "outer should open first");
772        assert!(inner.closed, "inner should remain closed until next zo");
773    }
774
775    #[test]
776    fn za_toggles_to_open_when_any_fold_closed_then_close_when_all_open() {
777        let mut a = nested_folds_app();
778        // Close outer only.
779        a.editor.folds[0].closed = true;
780        // za with the outer closed => open the outermost closed (the outer).
781        a.apply(Action::ToggleFoldAtCursor);
782        let outer = a.editor.folds.iter().find(|f| f.start_line == 0).unwrap();
783        let inner = a.editor.folds.iter().find(|f| f.start_line == 2).unwrap();
784        assert!(!outer.closed);
785        assert!(!inner.closed);
786        // Now both open: za should close the innermost.
787        a.apply(Action::ToggleFoldAtCursor);
788        let inner = a.editor.folds.iter().find(|f| f.start_line == 2).unwrap();
789        let outer = a.editor.folds.iter().find(|f| f.start_line == 0).unwrap();
790        assert!(inner.closed);
791        assert!(!outer.closed);
792    }
793
794    #[test]
795    fn zc_with_all_folds_closed_emits_e490() {
796        let mut a = nested_folds_app();
797        for f in a.editor.folds.iter_mut() {
798            f.closed = true;
799        }
800        a.apply(Action::CloseFoldAtCursor);
801        // No state change; both still closed.
802        assert!(a.editor.folds.iter().all(|f| f.closed));
803        // E490 echoed.
804        let msg = a
805            .editor
806            .last_message
807            .as_ref()
808            .expect("message")
809            .text
810            .clone();
811        assert!(msg.contains("E490"), "expected E490, got {msg:?}");
812    }
813
814    #[test]
815    fn zd_removes_innermost_only() {
816        let mut a = nested_folds_app();
817        a.apply(Action::DeleteFoldAtCursor);
818        // The inner (start=2) fold is gone; outer remains.
819        assert!(a.editor.folds.iter().any(|f| f.start_line == 0));
820        assert!(!a.editor.folds.iter().any(|f| f.start_line == 2));
821    }
822
823    // --- Linear j/k skip closed folds (`docs/user/folding.md`) ---
824
825    #[test]
826    fn line_down_from_closed_fold_heading_skips_to_after_fold() {
827        // 12-line buffer with a closed fold spanning lines 1..=4.
828        // From line 1 (heading), `j` should land on line 5, not 2.
829        let mut a = app_with(&"x\n".repeat(12), 10);
830        a.editor.folds.push(Fold {
831            start_line: 1,
832            end_line: 4,
833            closed: true,
834            identity: None,
835        });
836        a.editor.cursor = Position::new(1, 0);
837        a.apply(invoke_motion(a.editor.builtins.line_down));
838        assert_eq!(
839            a.editor.cursor.line, 5,
840            "j from closed-fold heading must skip to fold.end_line + 1"
841        );
842    }
843
844    #[test]
845    fn line_up_into_closed_fold_snaps_to_heading() {
846        // From line 5, `k` lands on 4 -- inside a closed fold (1..=4).
847        // Snap to fold.start_line (1).
848        let mut a = app_with(&"x\n".repeat(12), 10);
849        a.editor.folds.push(Fold {
850            start_line: 1,
851            end_line: 4,
852            closed: true,
853            identity: None,
854        });
855        a.editor.cursor = Position::new(5, 0);
856        a.apply(invoke_motion(a.editor.builtins.line_up));
857        assert_eq!(
858            a.editor.cursor.line, 1,
859            "k into a closed fold must snap to its heading line"
860        );
861    }
862
863    #[test]
864    fn linear_j_into_open_fold_does_not_skip() {
865        // Open folds don't hide content; j moves one line as usual.
866        let mut a = app_with(&"x\n".repeat(12), 10);
867        a.editor.folds.push(Fold {
868            start_line: 1,
869            end_line: 4,
870            closed: false,
871            identity: None,
872        });
873        a.editor.cursor = Position::new(1, 0);
874        a.apply(invoke_motion(a.editor.builtins.line_down));
875        assert_eq!(a.editor.cursor.line, 2);
876    }
877
878    #[test]
879    fn linear_motion_with_nofoldenable_does_not_skip() {
880        // `:set nofoldenable` / `zi` should make every line visible
881        // for navigation, including closed-fold interiors.
882        let mut a = app_with(&"x\n".repeat(12), 10);
883        a.editor.folds.push(Fold {
884            start_line: 1,
885            end_line: 4,
886            closed: true,
887            identity: None,
888        });
889        a.set_foldenable_for_test(false);
890        a.editor.cursor = Position::new(1, 0);
891        a.apply(invoke_motion(a.editor.builtins.line_down));
892        assert_eq!(a.editor.cursor.line, 2);
893    }
894
895    fn line_down_lands_on_next_fold_heading_when_consecutive() {
896        // Three closed folds back-to-back: 1..=3, 4..=6, 7..=9.
897        // Each `j` moves one visible line; a closed fold's heading
898        // IS a visible line. So:
899        //   line 1 (fold A heading) --j--> line 4 (fold B heading)
900        //   line 4 (fold B heading) --j--> line 7 (fold C heading)
901        //   line 7 (fold C heading) --j--> line 10 (after fold C)
902        let mut a = app_with(&"x\n".repeat(12), 10);
903        a.editor.folds.push(Fold {
904            start_line: 1,
905            end_line: 3,
906            closed: true,
907            identity: None,
908        });
909        a.editor.folds.push(Fold {
910            start_line: 4,
911            end_line: 6,
912            closed: true,
913            identity: None,
914        });
915        a.editor.folds.push(Fold {
916            start_line: 7,
917            end_line: 9,
918            closed: true,
919            identity: None,
920        });
921        a.editor.cursor = Position::new(1, 0);
922        a.apply(invoke_motion(a.editor.builtins.line_down));
923        assert_eq!(a.editor.cursor.line, 4, "first j → fold B heading");
924        a.apply(invoke_motion(a.editor.builtins.line_down));
925        assert_eq!(a.editor.cursor.line, 7, "second j → fold C heading");
926        a.apply(invoke_motion(a.editor.builtins.line_down));
927        assert_eq!(a.editor.cursor.line, 10, "third j → past fold C");
928    }
929
930    #[test]
931    fn line_down_skips_consecutive_closed_folds_in_one_keypress() {
932        // Wrapper / dummy: superseded by
933        // `line_down_lands_on_next_fold_heading_when_consecutive`.
934        // The historical name preserved so anyone re-running an
935        // older test list spots the rename.
936        line_down_lands_on_next_fold_heading_when_consecutive();
937    }
938
939    // --- Generalised snap covers all non-jump motions --------
940
941    #[test]
942    fn word_forward_snaps_out_of_closed_fold_body() {
943        // `w` from a closed fold's heading lands on the next word.
944        // Pre-snap, that next word might be inside the fold body
945        // (cursor at hidden line). The snap projects cursor onto a
946        // visible line so subsequent `zc` resolves correctly.
947        let src = "alpha bravo\n    charlie delta\n    echo foxtrot\nafter golf hotel\n";
948        let mut a = app_with(src, 10);
949        a.editor.folds.push(Fold {
950            start_line: 0,
951            end_line: 2,
952            closed: true,
953            identity: None,
954        });
955        a.editor.cursor = Position::new(0, 0);
956        a.apply(invoke_motion(a.editor.builtins.word_forward));
957        // Without snap the cursor would land on "bravo" (line 0,
958        // byte 6) -- still visible. Press w again: would go into
959        // hidden `charlie`. The snap kicks in there.
960        a.apply(invoke_motion(a.editor.builtins.word_forward));
961        assert!(
962            !a.line_inside_closed_fold(a.editor.cursor.line),
963            "w must not leave cursor inside a hidden fold body \
964             (cursor.line = {})",
965            a.editor.cursor.line
966        );
967    }
968
969    // display-line B4.2: `refresh_highlights_covers_buffer_lines_below_a_closed_fold`
970    // was deleted here. It asserted through the now-removed
971    // `App::refresh_highlights` + `highlights_for_buffer_line` span
972    // readers (the dead span/row cache). The fold-aware window
973    // stretch it guarded (`Editor::fold_aware_highlight_end_line` →
974    // `SyntaxRenderState::end_line_override`) still exists and is
975    // exercised by the overlay worker's `recompute_honours_*` path;
976    // syntax styling under folds now flows through the cells /
977    // `DisplayMatrix` substrate, covered by the cells worker tests.
978
979    #[test]
980    fn syntax_fold_zc_on_indented_let_with_if_else_reports_five_lines() {
981        // The user's actual scenario: the `let` form is INDENTED
982        // (inside a function body). Verify the outer-pick rule on
983        // the if/let line still resolves to the full if_expression
984        // fold even with leading whitespace, so the rendered count
985        // is 5 lines (not 3 -- the inner then-block size).
986        let src = "fn outer() -> u32 {\n    let len = if has_trailing_newline {\n        bytes - 1\n    } else {\n        bytes\n    };\n    len\n}\n";
987        let mut a = app_with(src, 20);
988        attach_test_syntax(&mut a, lattice_syntax::Lang::Rust);
989        a.set_foldmethod_for_test(FoldMethod::Syntax);
990        a.recompute_folds();
991        // Dump for diagnosis -- show the fold ranges at the live
992        // tree's current state.
993        eprintln!("FOLDS (indented let-if-else inside fn):");
994        for f in &a.editor.folds {
995            eprintln!(
996                "  ({}, {}) span={} lines",
997                f.start_line,
998                f.end_line,
999                f.end_line - f.start_line + 1
1000            );
1001        }
1002        // Cursor on line 1 -- the indented `let len = if ...`
1003        // line. zc should pick the outermost fold whose start_line
1004        // is 1 (the if_expression / let_declaration), not the
1005        // inner then-block.
1006        a.editor.cursor = Position::new(1, 0);
1007        a.apply(Action::CloseFoldAtCursor);
1008        let fold = a
1009            .fold_start_at(1)
1010            .expect("a closed fold should start at line 1");
1011        let count = fold.end_line - fold.start_line + 1;
1012        assert_eq!(
1013            count, 5,
1014            "indented if/else fold must span 5 lines (got {count}; fold = {fold:?}; all = {:?})",
1015            a.editor.folds
1016        );
1017    }
1018
1019    #[test]
1020    fn syntax_fold_zc_on_let_with_if_else_reports_five_lines() {
1021        // Full-pipeline regression for the user's scenario: a top-
1022        // level `let` binding wrapping an if/else expression. With
1023        // foldmethod=syntax, the cursor on the `let` line gets
1024        // `zc` to close the entire 5-line form and the rendered
1025        // summary shows "5 lines folded".
1026        let src = "let len = if cond {\n    bytes - 1\n} else {\n    bytes\n}\n";
1027        let mut a = app_with(src, 10);
1028        attach_test_syntax(&mut a, lattice_syntax::Lang::Rust);
1029        a.set_foldmethod_for_test(FoldMethod::Syntax);
1030        a.recompute_folds();
1031        // Cursor on line 0 (the `let` line). zc must pick the
1032        // outermost fold starting at line 0 -- the if_expression /
1033        // let_declaration spanning (0, 4) -- and close it.
1034        a.editor.cursor = Position::new(0, 0);
1035        a.apply(Action::CloseFoldAtCursor);
1036        let fold = a
1037            .fold_start_at(0)
1038            .expect("a closed fold should start at line 0 after zc");
1039        let count = fold.end_line - fold.start_line + 1;
1040        assert_eq!(
1041            count, 5,
1042            "fold at line 0 must span 5 lines (got {count}; fold = {fold:?})"
1043        );
1044    }
1045
1046    #[test]
1047    fn paragraph_motion_snaps_out_of_closed_fold_body() {
1048        // `}` (paragraph forward) from inside a fold can land
1049        // cursor on a hidden paragraph break. Snap must apply.
1050        let src = "alpha\n\n    body line one\n    body line two\n\nafter\n";
1051        let mut a = app_with(src, 10);
1052        a.editor.folds.push(Fold {
1053            start_line: 0,
1054            end_line: 4,
1055            closed: true,
1056            identity: None,
1057        });
1058        a.editor.cursor = Position::new(0, 0);
1059        a.apply(invoke_motion(a.editor.builtins.paragraph_forward));
1060        assert!(
1061            !a.line_inside_closed_fold(a.editor.cursor.line),
1062            "}} must not leave cursor inside a hidden fold body \
1063             (cursor.line = {})",
1064            a.editor.cursor.line
1065        );
1066    }
1067
1068    #[test]
1069    fn line_down_swallows_blanks_between_sibling_folds_for_zc_targeting() {
1070        // Reproduces the user's "third form" regression: with a
1071        // blank line between two closed folds, j from the first
1072        // fold's heading must land on the *next sibling's heading*,
1073        // not on the blank between them. Otherwise zc on the blank
1074        // resolves to "innermost open fold containing this line" =
1075        // the parent.
1076        //
1077        // Buffer (impl with three fns separated by blank lines):
1078        //   line 0: impl B {
1079        //   line 1:   fn a() {
1080        //   line 2:   }
1081        //   line 3:   <blank>
1082        //   line 4:   fn b() {
1083        //   line 5:   }
1084        //   line 6:   <blank>
1085        //   line 7:   fn c() {
1086        //   line 8:   }
1087        //   line 9: }
1088        let src =
1089            "impl B {\n    fn a() {\n    }\n\n    fn b() {\n    }\n\n    fn c() {\n    }\n}\n";
1090        let mut a = app_with(src, 20);
1091        // Outer impl + three function folds (skip blank-line 3 / 6).
1092        a.editor.folds.push(Fold {
1093            start_line: 0,
1094            end_line: 9,
1095            closed: false,
1096            identity: None,
1097        });
1098        a.editor.folds.push(Fold {
1099            start_line: 1,
1100            end_line: 2,
1101            closed: true,
1102            identity: None,
1103        });
1104        a.editor.folds.push(Fold {
1105            start_line: 4,
1106            end_line: 5,
1107            closed: false,
1108            identity: None,
1109        });
1110        a.editor.folds.push(Fold {
1111            start_line: 7,
1112            end_line: 8,
1113            closed: false,
1114            identity: None,
1115        });
1116        a.editor.cursor = Position::new(1, 0);
1117        // j from fn a's heading: snap over fn a's body, swallow the
1118        // blank, land on fn b's heading (line 4).
1119        a.apply(invoke_motion(a.editor.builtins.line_down));
1120        assert_eq!(
1121            a.editor.cursor.line, 4,
1122            "j after fold A must skip the blank and land on fn b"
1123        );
1124        // Close fn b, j again, land on fn c's heading (line 7).
1125        a.apply(Action::CloseFoldAtCursor);
1126        let fnb = a.editor.folds.iter().find(|f| f.start_line == 4).unwrap();
1127        assert!(fnb.closed, "zc on fn b heading closes fn b");
1128        a.apply(invoke_motion(a.editor.builtins.line_down));
1129        assert_eq!(
1130            a.editor.cursor.line, 7,
1131            "j after fold B must skip the blank and land on fn c"
1132        );
1133        // Close fn c. The outer impl must remain open.
1134        a.apply(Action::CloseFoldAtCursor);
1135        let fnc = a.editor.folds.iter().find(|f| f.start_line == 7).unwrap();
1136        let outer = a.editor.folds.iter().find(|f| f.start_line == 0).unwrap();
1137        assert!(fnc.closed, "zc on fn c heading closes fn c, not outer");
1138        assert!(
1139            !outer.closed,
1140            "outer impl must remain open through the sequence"
1141        );
1142    }
1143
1144    #[test]
1145    fn zc_on_sibling_fold_after_navigating_with_j_closes_sibling_not_parent() {
1146        // Regression: with one inner fold already closed, `j` from
1147        // its heading must put the cursor on the sibling's heading
1148        // (line 5), not inside the closed fold's body. Then `zc`
1149        // on the sibling closes the sibling -- not the outer.
1150        let mut a = app_with(&"x\n".repeat(12), 10);
1151        a.editor.folds.push(Fold {
1152            start_line: 0,
1153            end_line: 10,
1154            closed: false,
1155            identity: None,
1156        });
1157        a.editor.folds.push(Fold {
1158            start_line: 1,
1159            end_line: 4,
1160            closed: true,
1161            identity: None,
1162        });
1163        a.editor.folds.push(Fold {
1164            start_line: 5,
1165            end_line: 9,
1166            closed: false,
1167            identity: None,
1168        });
1169        a.editor.cursor = Position::new(1, 0);
1170        // Move to the sibling's heading.
1171        a.apply(invoke_motion(a.editor.builtins.line_down));
1172        assert_eq!(
1173            a.editor.cursor.line, 5,
1174            "cursor should land on sibling, not interior"
1175        );
1176        // Close the sibling.
1177        a.apply(Action::CloseFoldAtCursor);
1178        let sibling = a.editor.folds.iter().find(|f| f.start_line == 5).unwrap();
1179        let outer = a.editor.folds.iter().find(|f| f.start_line == 0).unwrap();
1180        assert!(sibling.closed, "sibling should close, not the outer");
1181        assert!(!outer.closed, "outer must remain open");
1182    }
1183
1184    #[test]
1185    fn zj_jumps_to_next_fold_start() {
1186        let mut a = app_with("a\nb\nc\nd\ne\nf", 10);
1187        a.editor.folds.push(Fold {
1188            start_line: 2,
1189            end_line: 3,
1190            closed: false,
1191            identity: None,
1192        });
1193        a.editor.folds.push(Fold {
1194            start_line: 5,
1195            end_line: 5,
1196            closed: false,
1197            identity: None,
1198        });
1199        a.editor.cursor = Position::ZERO;
1200        a.apply(Action::GotoNextFold);
1201        assert_eq!(a.editor.cursor.line, 2);
1202    }
1203
1204    #[test]
1205    fn zk_jumps_to_previous_fold_end() {
1206        let mut a = app_with("a\nb\nc\nd\ne\nf", 10);
1207        a.editor.folds.push(Fold {
1208            start_line: 1,
1209            end_line: 2,
1210            closed: false,
1211            identity: None,
1212        });
1213        a.editor.cursor = Position::new(5, 0);
1214        a.apply(Action::GotoPrevFold);
1215        assert_eq!(a.editor.cursor.line, 2);
1216    }
1217
1218    #[test]
1219    fn zj_with_no_more_folds_emits_error() {
1220        let mut a = app_with("a\nb\nc", 10);
1221        a.apply(Action::GotoNextFold);
1222        let msg = a.editor.last_message.as_ref().unwrap();
1223        assert_eq!(msg.level, EchoLevel::Error);
1224    }
1225
1226    #[test]
1227    fn line_inside_closed_fold_returns_true_for_interior() {
1228        let mut a = app_with("a\nb\nc\nd", 10);
1229        a.editor.folds.push(Fold {
1230            start_line: 1,
1231            end_line: 3,
1232            closed: true,
1233            identity: None,
1234        });
1235        a.editor.publish_render_state();
1236        assert!(!a.line_inside_closed_fold(0));
1237        // Start line is the summary, NOT inside.
1238        assert!(!a.line_inside_closed_fold(1));
1239        assert!(a.line_inside_closed_fold(2));
1240        assert!(a.line_inside_closed_fold(3));
1241    }
1242
1243    // ---- foldmethod ----
1244
1245    // ---- Computed folds (DESIGN.md §15:18, C.2) ----
1246
1247    #[test]
1248    fn foldmethod_indent_populates_folds_from_indentation() {
1249        let mut a = app_with("def f():\n    pass\n    pass\n", 10);
1250        a.editor.set_command_line_text("set foldmethod=indent");
1251        a.editor.modal = ModalState::Command;
1252        a.apply(Action::CommandLineSubmit);
1253        assert_eq!(a.foldmethod(), FoldMethod::Indent);
1254        assert!(!a.editor.folds.is_empty());
1255        let f = a
1256            .editor
1257            .folds
1258            .iter()
1259            .find(|f| f.start_line == 0)
1260            .expect("fold");
1261        assert_eq!(f.end_line, 2);
1262    }
1263
1264    #[test]
1265    fn foldmethod_indent_preserves_closed_state_across_reparse() {
1266        let mut a = app_with("a:\n    b\n    c\n", 10);
1267        a.set_foldmethod_for_test(FoldMethod::Indent);
1268        a.recompute_folds();
1269        assert_eq!(a.editor.folds.len(), 1);
1270        // Close the fold.
1271        a.editor.folds[0].closed = true;
1272        // Recompute should preserve closed state (same range).
1273        a.recompute_folds();
1274        assert!(a.editor.folds[0].closed);
1275    }
1276
1277    #[test]
1278    fn foldmethod_manual_default_does_not_recompute() {
1279        let mut a = app_with("def f():\n    pass\n", 10);
1280        a.recompute_folds();
1281        assert!(a.editor.folds.is_empty());
1282    }
1283
1284    #[test]
1285    fn foldmethod_markdown_populates_folds_from_atx_headings() {
1286        let mut a = app_with("# H1\nbody\nmore body\n", 10);
1287        a.editor.set_command_line_text("set foldmethod=markdown");
1288        a.editor.modal = ModalState::Command;
1289        a.apply(Action::CommandLineSubmit);
1290        assert_eq!(a.foldmethod(), FoldMethod::Markdown);
1291        assert!(!a.editor.folds.is_empty());
1292        let f = a
1293            .editor
1294            .folds
1295            .iter()
1296            .find(|f| f.start_line == 0)
1297            .expect("fold");
1298        assert!(f.end_line >= 2);
1299    }
1300
1301    #[test]
1302    fn foldmethod_syntax_cascades_to_indent_when_no_md_extension() {
1303        // Plain-text buffer (no `Syntax`): syntax provider returns
1304        // None and we cascade to indent.
1305        let mut a = app_with("def f():\n    pass\n    pass\n", 10);
1306        a.editor.set_command_line_text("set foldmethod=syntax");
1307        a.editor.modal = ModalState::Command;
1308        a.apply(Action::CommandLineSubmit);
1309        assert_eq!(a.foldmethod(), FoldMethod::Syntax);
1310        assert!(
1311            a.editor
1312                .folds
1313                .iter()
1314                .any(|f| f.start_line == 0 && f.end_line == 2)
1315        );
1316    }
1317
1318    #[test]
1319    fn foldmethod_syntax_uses_tree_sitter_for_rust_buffer() {
1320        // With Syntax set up for Rust, `:set foldmethod=syntax`
1321        // should produce tree-sitter folds (struct, fn, impl) rather
1322        // than indent folds.
1323        let mut a = app_with(
1324            "struct B {\n    x: u8,\n}\n\nimpl B {\n    fn n() -> Self {\n        Self { x: 0 }\n    }\n}\n",
1325            10,
1326        );
1327        // Wire up Rust syntax + parse the document so the fold
1328        // provider has a tree to query.
1329        attach_test_syntax(&mut a, lattice_syntax::Lang::Rust);
1330        a.editor.set_command_line_text("set foldmethod=syntax");
1331        a.editor.modal = ModalState::Command;
1332        a.apply(Action::CommandLineSubmit);
1333        assert_eq!(a.foldmethod(), FoldMethod::Syntax);
1334        // Tree-sitter fold for the struct (lines 0..=2).
1335        assert!(
1336            a.editor
1337                .folds
1338                .iter()
1339                .any(|f| f.start_line == 0 && f.end_line >= 2),
1340            "expected struct fold from tree-sitter: {:?}",
1341            a.editor.folds
1342        );
1343        // Tree-sitter fold for the impl (starts at line 4).
1344        assert!(
1345            a.editor.folds.iter().any(|f| f.start_line == 4),
1346            "expected impl fold from tree-sitter: {:?}",
1347            a.editor.folds
1348        );
1349    }
1350
1351    #[test]
1352    fn foldmethod_indent_identity_preserves_closed_state_after_unrelated_insert() {
1353        // Two sibling functions; close the *second* fold; insert a
1354        // new line into the *first* function (shifting line numbers
1355        // for the second fold). Identity-based matching should keep
1356        // the second fold closed despite its (start_line, end_line)
1357        // having shifted.
1358        let initial = "first:\n    a\n    b\nsecond:\n    x\n    y\n";
1359        let mut a = app_with(initial, 10);
1360        a.set_foldmethod_for_test(FoldMethod::Indent);
1361        a.recompute_folds();
1362        // Find and close the `second:` fold.
1363        let second_idx = a
1364            .editor
1365            .folds
1366            .iter()
1367            .position(|f| f.start_line == 3)
1368            .expect("second: fold exists");
1369        a.editor.folds[second_idx].closed = true;
1370        // Insert a new line inside the first function (between `a` and `b`).
1371        a.apply_edit_blocking(Edit::insert(Position::new(2, 0), "    extra\n"))
1372            .unwrap();
1373        a.recompute_folds();
1374        // The recomputed `second:` fold has start_line = 4 now, but
1375        // its identity (heading text "second:" + indent 0) matches.
1376        let new_second = a
1377            .editor
1378            .folds
1379            .iter()
1380            .find(|f| f.start_line == 4)
1381            .expect("second: fold survived insertion");
1382        assert!(
1383            new_second.closed,
1384            "closed-state should survive line shift via identity match"
1385        );
1386    }
1387
1388    #[test]
1389    fn foldmethod_rejects_unknown_value() {
1390        let mut a = app_with("a\n", 10);
1391        a.editor.set_command_line_text("set foldmethod=bogus");
1392        a.editor.modal = ModalState::Command;
1393        a.apply(Action::CommandLineSubmit);
1394        assert_eq!(a.foldmethod(), FoldMethod::Manual);
1395        assert!(a.editor.last_message.is_some());
1396    }
1397
1398    // ---- org-cycle (z<Space> / z<Tab>) ----
1399
1400    /// Nested folds: a parent heading [0..=10] with two child sub-headings
1401    /// [2..=4] and [6..=8]. Cursor on the parent heading (line 0).
1402    fn org_nested_app() -> App {
1403        let mut a = app_with(&"x\n".repeat(12), 20);
1404        for (s, e) in [(0u32, 10u32), (2, 4), (6, 8)] {
1405            a.editor.folds.push(Fold {
1406                start_line: s,
1407                end_line: e,
1408                closed: false,
1409                identity: None,
1410            });
1411        }
1412        a.editor.cursor = Position::new(0, 0);
1413        a
1414    }
1415
1416    fn closed_at(a: &App, start: u32) -> bool {
1417        a.editor
1418            .folds
1419            .iter()
1420            .find(|f| f.start_line == start)
1421            .unwrap()
1422            .closed
1423    }
1424
1425    #[test]
1426    fn org_local_cycle_folded_children_subtree() {
1427        let mut a = org_nested_app();
1428        // Fully shown → FOLDED (parent closed, hiding everything).
1429        a.apply(Action::CycleFoldAtCursor);
1430        assert!(closed_at(&a, 0), "FOLDED: parent closed");
1431        // → CHILDREN: parent open, both children closed (only their headings show).
1432        a.apply(Action::CycleFoldAtCursor);
1433        assert!(
1434            !closed_at(&a, 0) && closed_at(&a, 2) && closed_at(&a, 6),
1435            "CHILDREN: parent open, children closed"
1436        );
1437        // → SUBTREE: everything open.
1438        a.apply(Action::CycleFoldAtCursor);
1439        assert!(
1440            !closed_at(&a, 0) && !closed_at(&a, 2) && !closed_at(&a, 6),
1441            "SUBTREE: all open"
1442        );
1443        // → back to FOLDED.
1444        a.apply(Action::CycleFoldAtCursor);
1445        assert!(closed_at(&a, 0), "cycles back to FOLDED");
1446    }
1447
1448    #[test]
1449    fn org_local_cycle_on_leaf_is_a_toggle() {
1450        // A leaf heading (no descendants) degenerates to FOLDED ↔ open.
1451        let mut a = app_with(&"x\n".repeat(6), 10);
1452        a.editor.folds.push(Fold {
1453            start_line: 0,
1454            end_line: 4,
1455            closed: false,
1456            identity: None,
1457        });
1458        a.editor.cursor = Position::new(0, 0);
1459        a.apply(Action::CycleFoldAtCursor);
1460        assert!(a.editor.folds[0].closed, "leaf folds");
1461        a.apply(Action::CycleFoldAtCursor);
1462        assert!(!a.editor.folds[0].closed, "leaf re-opens");
1463    }
1464
1465    #[test]
1466    fn org_global_cycle_overview_contents_showall() {
1467        let mut a = org_nested_app(); // parent[0,10] non-leaf; [2,4]/[6,8] leaves.
1468        // Mixed/shown → OVERVIEW (all closed).
1469        a.apply(Action::CycleFoldsGlobal);
1470        assert!(
1471            a.editor.folds.iter().all(|f| f.closed),
1472            "OVERVIEW: all closed"
1473        );
1474        // → CONTENTS: structural (parent) open, leaves closed.
1475        a.apply(Action::CycleFoldsGlobal);
1476        assert!(
1477            !closed_at(&a, 0) && closed_at(&a, 2) && closed_at(&a, 6),
1478            "CONTENTS: parent open, leaves closed"
1479        );
1480        // → SHOW-ALL: all open.
1481        a.apply(Action::CycleFoldsGlobal);
1482        assert!(
1483            a.editor.folds.iter().all(|f| !f.closed),
1484            "SHOW-ALL: all open"
1485        );
1486        // → back to OVERVIEW.
1487        a.apply(Action::CycleFoldsGlobal);
1488        assert!(
1489            a.editor.folds.iter().all(|f| f.closed),
1490            "cycles back to OVERVIEW"
1491        );
1492    }
1493
1494    #[test]
1495    fn org_cycle_off_a_fold_falls_back_to_global() {
1496        // `z<Space>` is one contextual key: with the cursor NOT inside any
1497        // fold, it cycles the WHOLE buffer (global) instead of erroring.
1498        let mut a = app_with(&"x\n".repeat(10), 20);
1499        a.editor.folds.push(Fold {
1500            start_line: 2,
1501            end_line: 4,
1502            closed: false,
1503            identity: None,
1504        });
1505        a.editor.folds.push(Fold {
1506            start_line: 6,
1507            end_line: 8,
1508            closed: false,
1509            identity: None,
1510        });
1511        a.editor.cursor = Position::new(0, 0); // outside both folds
1512        a.apply(Action::CycleFoldAtCursor);
1513        // Global cycle from all-open → OVERVIEW: every fold closed.
1514        assert!(
1515            a.editor.folds.iter().all(|f| f.closed),
1516            "off-fold z<Space> runs the global cycle (OVERVIEW)"
1517        );
1518    }
1519
1520    // ---- zp: go to parent heading ----
1521
1522    #[test]
1523    fn zp_goes_to_parent_heading() {
1524        let mut a = org_nested_app(); // parent[0,10], childA[2,4], childB[6,8]
1525        a.editor.cursor = Position::new(3, 0); // inside childA's range
1526        a.apply(Action::GotoParentFold);
1527        assert_eq!(
1528            a.editor.cursor.line, 0,
1529            "zp from inside a child jumps to the parent heading"
1530        );
1531        // At the top-level heading, there's no parent: stay put + message.
1532        a.apply(Action::GotoParentFold);
1533        assert_eq!(a.editor.cursor.line, 0, "zp at the top level stays put");
1534        let msg = a.editor.last_message.as_ref().unwrap().text.clone();
1535        assert!(
1536            msg.contains("no parent"),
1537            "expected no-parent note, got {msg:?}"
1538        );
1539    }
1540
1541    #[test]
1542    fn zp_climbs_one_level_at_a_time() {
1543        // # [0..=20] ⊃ ## [5..=15] ⊃ ### [8..=12]. Cursor inside ###.
1544        let mut a = app_with(&"x\n".repeat(22), 30);
1545        for (s, e) in [(0u32, 20u32), (5, 15), (8, 12)] {
1546            a.editor.folds.push(Fold {
1547                start_line: s,
1548                end_line: e,
1549                closed: false,
1550                identity: None,
1551            });
1552        }
1553        a.editor.cursor = Position::new(10, 0); // inside ###
1554        a.apply(Action::GotoParentFold);
1555        assert_eq!(a.editor.cursor.line, 5, "### → ## (one level up)");
1556        a.apply(Action::GotoParentFold);
1557        assert_eq!(a.editor.cursor.line, 0, "## → # (one more level)");
1558    }
1559
1560    /// `foldlevel` seeds structure that ARRIVES; it must not re-fold the
1561    /// structure the user's own keystroke just made.
1562    ///
1563    /// Org declares `foldlevel=0` — its `#+STARTUP: overview` equivalent — so
1564    /// every one of these runs at the level org buffers actually live at.
1565    /// `foldmethod=markdown` stands in for org's `syntax`: both are heading
1566    /// providers, both key identity on the trimmed heading text, and this test
1567    /// module can reach markdown without a plugin in the loop.
1568    mod foldlevel_and_the_edit_path {
1569        use super::*;
1570        use crossterm::event::{KeyCode, KeyEvent};
1571
1572        /// `:set foldmethod=markdown` + `:set foldlevel=0`, in that order —
1573        /// the same cascade org's two mode-option layers drive.
1574        fn org_shaped(text: &str) -> App {
1575            let mut a = app_with(text, 20);
1576            for cmd in ["set foldmethod=markdown", "set foldlevel=0"] {
1577                a.editor.set_command_line_text(cmd);
1578                a.editor.modal = ModalState::Command;
1579                a.apply(Action::CommandLineSubmit);
1580            }
1581            a
1582        }
1583
1584        fn fold_at(a: &App, start_line: u32) -> Fold {
1585            *a.editor
1586                .folds
1587                .iter()
1588                .find(|f| f.start_line == start_line)
1589                .unwrap_or_else(|| panic!("expected a fold starting at line {start_line}"))
1590        }
1591
1592        /// **The reported bug.** `o` on a headline with nothing under it
1593        /// folded the headline the instant the line appeared.
1594        ///
1595        /// A bare headline produces NO fold (`markdown_single_line_heading_skipped`
1596        /// pins the same rule provider-side), so opening a line under it makes
1597        /// one appear for the very first time. Under the old unconditional
1598        /// `apply_fold_level_to_new` that counted as unseen structure, and at
1599        /// `foldlevel=0` unseen structure closes — around the cursor, in insert
1600        /// mode, on the line being typed.
1601        #[test]
1602        fn open_line_under_a_bare_heading_does_not_fold_it() {
1603            let mut a = org_shaped("# H1\n");
1604            assert!(a.editor.folds.is_empty(), "a bare heading has no fold yet");
1605
1606            a.editor.cursor = Position::new(0, 0);
1607            press(&mut a, KeyEvent::from(KeyCode::Char('o')));
1608            press(&mut a, KeyEvent::from(KeyCode::Char('x')));
1609
1610            assert!(
1611                !fold_at(&a, 0).closed,
1612                "the fold the user just opened a line inside must not snap shut"
1613            );
1614        }
1615
1616        /// The same mechanism one level down — the "especially in lists" half
1617        /// of the report. A nested heading created by an edit is new structure
1618        /// at depth 2, so `foldlevel=0` closed it even harder than the parent.
1619        #[test]
1620        fn a_nested_section_created_by_an_edit_arrives_open() {
1621            let mut a = org_shaped("# H1\nbody\n");
1622            a.editor.cursor = Position::new(1, 0);
1623            press(&mut a, KeyEvent::from(KeyCode::Char('o')));
1624            for ch in "## H2".chars() {
1625                press(&mut a, KeyEvent::from(KeyCode::Char(ch)));
1626            }
1627            press(&mut a, KeyEvent::from(KeyCode::Enter));
1628            press(&mut a, KeyEvent::from(KeyCode::Char('x')));
1629
1630            assert!(
1631                !fold_at(&a, 2).closed,
1632                "a sub-section the user just typed must not fold itself away"
1633            );
1634        }
1635
1636        /// The other side of the fix, and the thing org would lose if the
1637        /// `Populate` path were gated too: content that ARRIVES still obeys
1638        /// `foldlevel`. This is `#+STARTUP: overview` — open the file, see the
1639        /// outline.
1640        #[test]
1641        fn structure_that_arrives_still_obeys_the_level() {
1642            let a = org_shaped("# H1\nbody\nmore\n");
1643            assert!(
1644                fold_at(&a, 0).closed,
1645                "a buffer that opens with structure in it still opens collapsed"
1646            );
1647        }
1648
1649        /// …and an edit inside a collapsed outline does not reopen its
1650        /// siblings. The identity carry-over owns that; this pins that the new
1651        /// `Edit` cause did not quietly turn into "reopen everything".
1652        #[test]
1653        fn an_edit_leaves_the_other_sections_collapsed() {
1654            let mut a = org_shaped("# H1\nbody\n# H2\nbody\n");
1655            assert!(fold_at(&a, 2).closed, "H2 starts collapsed");
1656
1657            a.editor.cursor = Position::new(1, 0);
1658            press(&mut a, KeyEvent::from(KeyCode::Char('o')));
1659            press(&mut a, KeyEvent::from(KeyCode::Char('x')));
1660
1661            assert!(
1662                fold_at(&a, 3).closed,
1663                "H2 (now one line lower) keeps the closed state it was carried \
1664                 over with — an edit in H1 is not a reason to expand it"
1665            );
1666        }
1667
1668        /// The failure mode the `Edit` cause could have re-introduced through
1669        /// the back door: `recompute_folds_because` still stamps
1670        /// `last_folded_text_version`, so the per-tick
1671        /// `maybe_refold_after_async_population` sees a current stamp and does
1672        /// NOT re-run the same pass as `Populate` a frame later. Without the
1673        /// stamp the fold would reopen on the keystroke and close again on the
1674        /// next tick — a flicker, which is worse than the bug.
1675        #[test]
1676        fn the_tick_does_not_re_close_what_the_edit_path_left_open() {
1677            let mut a = org_shaped("# H1\n");
1678            a.editor.cursor = Position::new(0, 0);
1679            press(&mut a, KeyEvent::from(KeyCode::Char('o')));
1680            press(&mut a, KeyEvent::from(KeyCode::Char('x')));
1681            assert!(!fold_at(&a, 0).closed);
1682
1683            a.editor.maybe_refold_after_async_population();
1684
1685            assert!(
1686                !fold_at(&a, 0).closed,
1687                "the idle tick must not undo what the edit path decided"
1688            );
1689        }
1690    }
1691
1692    /// `zj` / `zk` step between *visible* fold edges.
1693    ///
1694    /// Vim: "zj — move downwards to the start of the next fold. **A closed
1695    /// fold is counted as one fold.**" The folds nested inside a closed one
1696    /// are not separate stops, because they are not on screen: they render on
1697    /// the closed fold's own row, so stepping to one moves `cursor.line`
1698    /// without moving the cursor a single row. A motion that reports success
1699    /// and visibly does nothing is worse than one that says "no more folds".
1700    mod zj_and_zk_step_between_visible_folds {
1701        use super::*;
1702
1703        /// `# [10..=30]` closed, containing `## [12..=20]`, with a visible
1704        /// sibling at `[35..=40]`. The shape of a collapsed org section that
1705        /// has sub-headings, which is where this shows up.
1706        fn nested_under_a_closed_fold() -> App {
1707            let mut a = app_with(&"x\n".repeat(45), 60);
1708            for (s, e, closed) in [(10u32, 30u32, true), (12, 20, false), (35, 40, false)] {
1709                a.editor.folds.push(Fold {
1710                    start_line: s,
1711                    end_line: e,
1712                    closed,
1713                    identity: None,
1714                });
1715            }
1716            a
1717        }
1718
1719        /// **The reported bug.** On the closed fold's head, `zj` picked the
1720        /// nested fold's start — a line hidden inside the very fold the cursor
1721        /// sits on — so the cursor did not appear to move at all.
1722        #[test]
1723        fn zj_skips_a_fold_hidden_inside_the_closed_one_it_is_on() {
1724            let mut a = nested_under_a_closed_fold();
1725            a.editor.cursor = Position::new(10, 0);
1726
1727            a.apply(Action::GotoNextFold);
1728
1729            assert_eq!(
1730                a.editor.cursor.line, 35,
1731                "the next VISIBLE fold is the sibling after the closed one, \
1732                 not the sub-fold collapsed inside it"
1733            );
1734        }
1735
1736        /// …and the closed fold itself is still a stop. Skipping hidden edges
1737        /// must not turn into skipping the fold that hides them, or `zj` would
1738        /// walk straight past every collapsed section.
1739        #[test]
1740        fn zj_still_lands_on_a_closed_folds_own_head() {
1741            let mut a = nested_under_a_closed_fold();
1742            a.editor.cursor = Position::new(5, 0);
1743
1744            a.apply(Action::GotoNextFold);
1745
1746            assert_eq!(
1747                a.editor.cursor.line, 10,
1748                "the closed fold's head is visible"
1749            );
1750        }
1751
1752        /// `zk`'s half of the same rule: from inside a closed fold, the nested
1753        /// fold's END is on the cursor's own display row, so it is not a stop.
1754        #[test]
1755        fn zk_skips_a_fold_end_hidden_on_the_cursors_own_row() {
1756            let mut a = nested_under_a_closed_fold();
1757            // An earlier, visible fold to land on once the hidden one is
1758            // rejected — otherwise "skipped it" and "found nothing" look alike.
1759            a.editor.folds.push(Fold {
1760                start_line: 2,
1761                end_line: 5,
1762                closed: false,
1763                identity: None,
1764            });
1765            a.editor.cursor = Position::new(25, 0);
1766
1767            a.apply(Action::GotoPrevFold);
1768
1769            assert_eq!(
1770                a.editor.cursor.line, 5,
1771                "`[12..=20]` ends on the same row the cursor is already \
1772                 displayed on, so the previous visible fold end is `[2..=5]`"
1773            );
1774        }
1775
1776        /// …and `zk` still steps INTO a closed fold from below. Landing on
1777        /// line 30 puts the cursor on the collapsed row — a real move from
1778        /// line 32 — which is what vim does and must not be mistaken for a
1779        /// hidden edge.
1780        #[test]
1781        fn zk_still_enters_a_closed_fold_from_below() {
1782            let mut a = nested_under_a_closed_fold();
1783            a.editor.cursor = Position::new(32, 0);
1784
1785            a.apply(Action::GotoPrevFold);
1786
1787            assert_eq!(a.editor.cursor.line, 30, "the closed fold's end is a stop");
1788        }
1789
1790        /// `:set nofoldenable` means nothing is collapsed, so every edge is
1791        /// visible and both motions go back to plain nearest-edge stepping.
1792        #[test]
1793        fn nofoldenable_makes_every_edge_visible_again() {
1794            let mut a = nested_under_a_closed_fold();
1795            a.editor.set_command_line_text("set nofoldenable");
1796            a.editor.modal = ModalState::Command;
1797            a.apply(Action::CommandLineSubmit);
1798            a.editor.cursor = Position::new(10, 0);
1799
1800            a.apply(Action::GotoNextFold);
1801
1802            assert_eq!(
1803                a.editor.cursor.line, 12,
1804                "with folding disabled the nested fold is on screen, so it is \
1805                 the next stop"
1806            );
1807        }
1808    }
1809}