Skip to main content

oo_ide/editor/
fold.rs

1//! Code folding.
2//!
3//! A `FoldState` tracks which lines are currently folded and provides helpers
4//! for the editor to convert between logical (buffer) line numbers and visual
5//! (screen) row offsets.
6//!
7//! Two fold strategies are supported:
8//! - **Brace folding** (C-family, Rust, JS, Go): a `{` line can be folded
9//!   to hide everything up to its matching `}`.
10//! - **Indent folding** (Python, YAML, Markdown): a dedented line ends a fold.
11//!
12//! `FoldState` is kept simple — it stores a sorted list of folded ranges
13//! `(start, end)` where `start` is the line with the opening delimiter and
14//! `end` is the last hidden line (the closing delimiter stays visible).
15
16/// A half-open range `[start, end)` of **hidden** lines.
17///
18/// The line at `start` contains the opening delimiter (and is **visible**).
19/// Lines `start+1 ..= end-1` are hidden.
20/// The line at `end` (closing delimiter) is **visible**.
21#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
22pub struct FoldRange {
23    /// The line that was folded (contains `{` or the header).
24    pub header: usize,
25    /// Last hidden line (inclusive).  The line after this is visible again.
26    pub last_hidden: usize,
27}
28
29impl FoldRange {
30    /// Number of lines hidden by this fold.
31    pub fn hidden_count(&self) -> usize {
32        self.last_hidden.saturating_sub(self.header)
33    }
34
35    /// Is `line` hidden by this fold?
36    pub fn hides(&self, line: usize) -> bool {
37        line > self.header && line <= self.last_hidden
38    }
39}
40
41#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
42pub struct FoldState {
43    /// Sorted by `header`, non-overlapping.
44    folds: Vec<FoldRange>,
45}
46
47impl FoldState {
48    /// True if line `row` is completely hidden (not the fold header itself).
49    pub fn is_hidden(&self, row: usize) -> bool {
50        self.folds.iter().any(|f| f.hides(row))
51    }
52
53    /// Remove folds that reference lines beyond `line_count`.
54    pub fn prune_invalid(&mut self, line_count: usize) {
55        self.folds.retain(|f| f.header < line_count);
56    }
57
58    /// True if line `row` is the header of an active fold.
59    pub fn is_folded_header(&self, row: usize) -> bool {
60        self.folds.iter().any(|f| f.header == row)
61    }
62
63    /// Toggle a fold at `header`.  If already folded, remove; otherwise
64    /// detect the range from `lines` and add.
65    pub fn toggle(&mut self, header: usize, lines: &[String]) {
66        // Remove if already folded at this header.
67        if let Some(pos) = self.folds.iter().position(|f| f.header == header) {
68            self.folds.remove(pos);
69            return;
70        }
71
72        // Detect fold range.
73        if let Some(range) = detect_range(header, lines) {
74            // Insert maintaining sorted order and removing overlaps.
75            self.folds
76                .retain(|f| !(f.header > range.header && f.header <= range.last_hidden));
77            let pos = self.folds.partition_point(|f| f.header < range.header);
78            self.folds.insert(pos, range);
79        }
80    }
81
82    /// Convert a logical buffer line number to a visual row index (0-based),
83    /// accounting for all active folds above it.
84    ///
85    /// Returns `None` if the line itself is hidden.
86    #[allow(dead_code)]
87    pub fn logical_to_visual(&self, row: usize) -> Option<usize> {
88        if self.is_hidden(row) {
89            return None;
90        }
91        let mut visual = row;
92        for f in &self.folds {
93            if f.header >= row {
94                break;
95            }
96            visual -= f.hidden_count();
97        }
98        Some(visual)
99    }
100
101    /// Convert a visual row back to a logical line number.
102    #[allow(dead_code)]
103    pub fn visual_to_logical(&self, visual: usize) -> usize {
104        let mut logical = visual;
105        for f in &self.folds {
106            if f.header >= logical {
107                break;
108            }
109            logical += f.hidden_count();
110            // If we jumped past this fold's header, all its hidden lines count.
111        }
112        logical
113    }
114
115    /// Collect all visible line indices from `lines`, in order.
116    pub fn visible_lines<'a>(&self, lines: &'a [String]) -> Vec<(usize, &'a str)> {
117        lines
118            .iter()
119            .enumerate()
120            .filter(|(i, _)| !self.is_hidden(*i))
121            .map(|(i, s)| (i, s.as_str()))
122            .collect()
123    }
124
125    /// All active fold ranges (for gutter rendering).
126    pub fn folds(&self) -> &[FoldRange] {
127        &self.folds
128    }
129}
130
131/// Detect the foldable range starting at `header` line.
132///
133/// Strategy:
134///   1. If the header line ends with `{`, scan forward for the matching `}`.
135///   2. Otherwise fall back to indent folding: continue while the next
136///      non-empty line has greater indentation.
137fn detect_range(header: usize, lines: &[String]) -> Option<FoldRange> {
138    let header_line = lines.get(header)?;
139    let trimmed = header_line.trim_end();
140
141    if trimmed.ends_with('{') || trimmed.ends_with('(') || trimmed.ends_with('[') {
142        brace_range(header, lines)
143    } else {
144        indent_range(header, lines)
145    }
146}
147
148fn brace_range(header: usize, lines: &[String]) -> Option<FoldRange> {
149    let open = lines[header].trim_end().chars().last()?;
150    let close = match open {
151        '{' => '}',
152        '(' => ')',
153        '[' => ']',
154        _ => return None,
155    };
156
157    let mut depth = 0i32;
158    for (i, line) in lines.iter().enumerate().skip(header) {
159        for ch in line.chars() {
160            if ch == open {
161                depth += 1;
162            }
163            if ch == close {
164                depth -= 1;
165                if depth == 0 && i > header {
166                    // Hide everything between header and the closing line.
167                    return Some(FoldRange {
168                        header,
169                        last_hidden: i.saturating_sub(1),
170                    });
171                }
172            }
173        }
174    }
175    None
176}
177
178fn indent_range(header: usize, lines: &[String]) -> Option<FoldRange> {
179    let header_indent = indent_level(&lines[header]);
180    let mut last = header;
181    for (i, line) in lines.iter().enumerate().skip(header + 1) {
182        if line.trim().is_empty() {
183            continue;
184        }
185        if indent_level(line) > header_indent {
186            last = i;
187        } else {
188            break;
189        }
190    }
191    if last == header {
192        return None;
193    }
194    Some(FoldRange {
195        header,
196        last_hidden: last,
197    })
198}
199
200fn indent_level(line: &str) -> usize {
201    line.chars().take_while(|c| *c == ' ').count()
202        + line.chars().take_while(|c| *c == '\t').count() * 4
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn lines(text: &str) -> Vec<String> {
210        text.lines().map(|l| l.to_string()).collect()
211    }
212
213    #[test]
214    fn brace_fold_hides_inner_lines() {
215        let src = lines("fn foo() {\n    let x = 1;\n    x\n}");
216        let mut fs = FoldState::default();
217        fs.toggle(0, &src);
218        assert!(!fs.is_hidden(0)); // header visible
219        assert!(fs.is_hidden(1));
220        assert!(fs.is_hidden(2));
221        assert!(!fs.is_hidden(3)); // closing `}` visible
222    }
223
224    #[test]
225    fn toggle_twice_unfolds() {
226        let src = lines("fn foo() {\n    x\n}");
227        let mut fs = FoldState::default();
228        fs.toggle(0, &src);
229        assert!(fs.is_hidden(1));
230        fs.toggle(0, &src);
231        assert!(!fs.is_hidden(1));
232    }
233
234    #[test]
235    fn logical_to_visual_skips_hidden() {
236        let src = lines("fn foo() {\n    x\n    y\n}");
237        let mut fs = FoldState::default();
238        fs.toggle(0, &src);
239        // Line 0 → visual 0, lines 1-2 hidden, line 3 → visual 1.
240        assert_eq!(fs.logical_to_visual(0), Some(0));
241        assert_eq!(fs.logical_to_visual(1), None);
242        assert_eq!(fs.logical_to_visual(3), Some(1));
243    }
244
245    #[test]
246    fn visual_to_logical_round_trips() {
247        let src = lines("fn foo() {\n    x\n    y\n}\nlet z = 1;");
248        let mut fs = FoldState::default();
249        fs.toggle(0, &src);
250        // visual 0 → logical 0, visual 1 → logical 3, visual 2 → logical 4
251        assert_eq!(fs.visual_to_logical(0), 0);
252        assert_eq!(fs.visual_to_logical(1), 3);
253        assert_eq!(fs.visual_to_logical(2), 4);
254    }
255
256    #[test]
257    fn indent_fold() {
258        let src = lines("class Foo:\n    def bar(self):\n        pass\n\nclass Bar:");
259        let mut fs = FoldState::default();
260        fs.toggle(0, &src);
261        assert!(fs.is_hidden(1));
262        assert!(fs.is_hidden(2));
263        assert!(!fs.is_hidden(4));
264    }
265
266    #[test]
267    fn prune_invalid_removes_folds_beyond_line_count() {
268        let src = lines("fn foo() {\n    x\n}\nfn bar() {\n    y\n}");
269        let mut fs = FoldState::default();
270        fs.toggle(0, &src);
271        fs.toggle(3, &src);
272        assert!(fs.is_hidden(1));
273        assert!(fs.is_hidden(4));
274        fs.prune_invalid(3);
275        assert!(fs.is_hidden(1), "fold at 0 should still exist and hide line 1");
276        assert!(!fs.is_hidden(4), "fold at 3 should be removed since line_count is 3");
277    }
278}