Skip to main content

xei_core/
fold.rs

1//! Indent-based code folding.
2
3use std::collections::{HashMap, HashSet};
4
5/// Inclusive line range that can collapse.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct FoldRange {
8    pub start: usize,
9    pub end: usize, // inclusive
10}
11
12#[derive(Debug, Clone, Default)]
13pub struct FoldState {
14    /// All detected fold ranges (start → end).
15    pub ranges: Vec<FoldRange>,
16    /// Start lines of currently closed folds.
17    pub closed: HashSet<usize>,
18    /// Derived O(1) lookup: fold-start row → widest range starting there.
19    /// Rebuilt whenever `ranges` changes; keeps `fold_at`/`closed_count` off the
20    /// per-row-per-frame linear scan that dominated large-file rendering.
21    starts: HashMap<usize, FoldRange>,
22    /// Derived O(1) lookup: every row currently hidden inside a closed fold.
23    /// Recomputed only when `closed` or `ranges` changes.
24    hidden: HashSet<usize>,
25}
26
27impl FoldState {
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    pub fn clear(&mut self) {
33        self.ranges.clear();
34        self.closed.clear();
35        self.starts.clear();
36        self.hidden.clear();
37    }
38
39    /// Rebuild the `starts` index from `ranges` (keeps the widest fold per row).
40    fn reindex_starts(&mut self) {
41        self.starts.clear();
42        for r in &self.ranges {
43            self.starts
44                .entry(r.start)
45                .and_modify(|cur| {
46                    if r.end > cur.end {
47                        *cur = *r;
48                    }
49                })
50                .or_insert(*r);
51        }
52    }
53
54    /// Recompute the `hidden` set from the closed folds. O(closed × span) once
55    /// per fold-state change instead of O(ranges) per row per frame.
56    fn recompute_hidden(&mut self) {
57        self.hidden.clear();
58        for start in &self.closed {
59            if let Some(r) = self.starts.get(start) {
60                for row in (r.start + 1)..=r.end {
61                    self.hidden.insert(row);
62                }
63            }
64        }
65    }
66
67    /// Rebuild indent folds from buffer lines.
68    pub fn rebuild(&mut self, lines: &[String], tab_width: usize) {
69        let old_closed = self.closed.clone();
70        self.ranges.clear();
71        self.closed.clear();
72
73        let n = lines.len();
74        if n < 2 {
75            return;
76        }
77        let indents: Vec<usize> = lines.iter().map(|l| line_indent(l, tab_width)).collect();
78
79        for i in 0..n.saturating_sub(1) {
80            // Skip blank lines as fold starts
81            if lines[i].trim().is_empty() {
82                continue;
83            }
84            let base = indents[i];
85            // Look for a block that starts with increased indent after this line
86            let mut j = i + 1;
87            while j < n && lines[j].trim().is_empty() {
88                j += 1;
89            }
90            if j >= n || indents[j] <= base {
91                continue;
92            }
93            // Extend while indent > base (or blank)
94            let mut end = j;
95            let mut k = j;
96            while k < n {
97                if lines[k].trim().is_empty() {
98                    k += 1;
99                    continue;
100                }
101                if indents[k] > base {
102                    end = k;
103                    k += 1;
104                } else {
105                    break;
106                }
107            }
108            if end > i {
109                self.ranges.push(FoldRange {
110                    start: i,
111                    end,
112                });
113            }
114        }
115
116        // Restore closed state for ranges that still exist
117        for r in &self.ranges {
118            if old_closed.contains(&r.start) {
119                self.closed.insert(r.start);
120            }
121        }
122        self.reindex_starts();
123        self.recompute_hidden();
124    }
125
126    pub fn fold_at(&self, row: usize) -> Option<FoldRange> {
127        self.starts.get(&row).copied()
128    }
129
130    pub fn is_closed(&self, start: usize) -> bool {
131        self.closed.contains(&start)
132    }
133
134    /// True if `row` is hidden inside a closed fold (not the header line).
135    pub fn is_hidden(&self, row: usize) -> bool {
136        self.hidden.contains(&row)
137    }
138
139    pub fn toggle(&mut self, row: usize) -> Option<&'static str> {
140        // Prefer fold starting at row; else enclosing fold start
141        let start = if self.starts.contains_key(&row) {
142            row
143        } else {
144            self.ranges
145                .iter()
146                .filter(|r| row > r.start && row <= r.end)
147                .max_by_key(|r| r.start)
148                .map(|r| r.start)?
149        };
150        let msg = if self.closed.contains(&start) {
151            self.closed.remove(&start);
152            Some("opened fold")
153        } else if self.starts.contains_key(&start) {
154            self.closed.insert(start);
155            Some("closed fold")
156        } else {
157            None
158        };
159        if msg.is_some() {
160            self.recompute_hidden();
161        }
162        msg
163    }
164
165    pub fn close_at(&mut self, row: usize) -> bool {
166        if !self.starts.contains_key(&row) {
167            return false;
168        }
169        self.closed.insert(row);
170        self.recompute_hidden();
171        true
172    }
173
174    pub fn open_at(&mut self, row: usize) -> bool {
175        let removed = self.closed.remove(&row);
176        if removed {
177            self.recompute_hidden();
178        }
179        removed
180    }
181
182    pub fn close_all(&mut self) {
183        for r in &self.ranges {
184            self.closed.insert(r.start);
185        }
186        self.recompute_hidden();
187    }
188
189    pub fn open_all(&mut self) {
190        self.closed.clear();
191        self.hidden.clear();
192    }
193
194    /// Lines hidden under a closed fold starting at `start`.
195    pub fn closed_count(&self, start: usize) -> usize {
196        self.fold_at(start)
197            .filter(|_| self.is_closed(start))
198            .map(|r| r.end - r.start)
199            .unwrap_or(0)
200    }
201}
202
203fn line_indent(line: &str, tab_width: usize) -> usize {
204    let mut n = 0;
205    for c in line.chars() {
206        match c {
207            ' ' => n += 1,
208            '\t' => n += tab_width,
209            _ => break,
210        }
211    }
212    n
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn indent_fold_fn_body() {
221        let lines = vec![
222            "fn main() {".into(),
223            "    let x = 1;".into(),
224            "    let y = 2;".into(),
225            "}".into(),
226        ];
227        let mut f = FoldState::new();
228        f.rebuild(&lines, 4);
229        assert!(!f.ranges.is_empty());
230        let r = f.fold_at(0).expect("fold on fn line");
231        assert_eq!(r.start, 0);
232        assert!(r.end >= 2);
233        f.toggle(0);
234        assert!(f.is_hidden(1));
235        assert!(!f.is_hidden(0));
236    }
237
238    #[test]
239    fn close_all_open_all_updates_hidden_index() {
240        let lines = vec![
241            "fn a() {".into(),
242            "    let x = 1;".into(),
243            "}".into(),
244            "fn b() {".into(),
245            "    let y = 2;".into(),
246            "}".into(),
247        ];
248        let mut f = FoldState::new();
249        f.rebuild(&lines, 4);
250        f.close_all();
251        assert!(f.is_hidden(1));
252        assert!(f.is_hidden(4));
253        assert!(!f.is_hidden(0));
254        assert!(!f.is_hidden(3));
255        f.open_all();
256        assert!(!f.is_hidden(1));
257        assert!(!f.is_hidden(4));
258        // open_at only recomputes when something was actually closed
259        f.close_at(0);
260        assert!(f.is_hidden(1));
261        f.open_at(0);
262        assert!(!f.is_hidden(1));
263    }
264}