Skip to main content

fresh/view/
folding.rs

1//! Folding range infrastructure
2//!
3//! Provides a marker-based system for tracking collapsed folding ranges.
4//! Fold ranges are stored as byte markers so they auto-adjust on edits.
5
6use crate::model::buffer::Buffer;
7use crate::model::marker::{MarkerId, MarkerList};
8
9/// A collapsed fold range tracked by markers.
10#[derive(Debug, Clone)]
11pub struct FoldRange {
12    /// Marker at the first hidden byte (start of line after header)
13    start_marker: MarkerId,
14    /// Marker at the end of the hidden range (start of line after fold end)
15    end_marker: MarkerId,
16    /// Optional placeholder text for the folded range
17    placeholder: Option<String>,
18}
19
20/// A resolved fold range with computed line/byte info.
21#[derive(Debug, Clone)]
22pub struct ResolvedFoldRange {
23    /// Header line number (the visible line that owns the fold)
24    pub header_line: usize,
25    /// First hidden line number (header_line + 1)
26    pub start_line: usize,
27    /// Last hidden line number (inclusive)
28    pub end_line: usize,
29    /// Start byte of hidden range
30    pub start_byte: usize,
31    /// End byte of hidden range (exclusive)
32    pub end_byte: usize,
33    /// Line-start byte of the fold header
34    pub header_byte: usize,
35    /// Optional placeholder text
36    pub placeholder: Option<String>,
37}
38
39/// Collapsed fold range represented by line numbers for persistence/cloning.
40#[derive(Debug, Clone)]
41pub struct CollapsedFoldLineRange {
42    /// Header line number (visible line that owns the fold)
43    pub header_line: usize,
44    /// Last hidden line number (inclusive)
45    pub end_line: usize,
46    /// Optional placeholder text
47    pub placeholder: Option<String>,
48    /// Header line text at the time this snapshot was taken (used by
49    /// session restore to detect stale line numbers, issue #1568).
50    pub header_text: Option<String>,
51}
52
53/// Manages collapsed fold ranges for a buffer.
54#[derive(Debug, Clone)]
55pub struct FoldManager {
56    ranges: Vec<FoldRange>,
57}
58
59impl FoldManager {
60    /// Create a new empty fold manager.
61    pub fn new() -> Self {
62        Self { ranges: Vec::new() }
63    }
64
65    /// Returns true if there are no collapsed folds.
66    pub fn is_empty(&self) -> bool {
67        self.ranges.is_empty()
68    }
69
70    /// Add a collapsed fold range.
71    pub fn add(
72        &mut self,
73        marker_list: &mut MarkerList,
74        start: usize,
75        end: usize,
76        placeholder: Option<String>,
77    ) {
78        if end <= start {
79            return;
80        }
81
82        let start_marker = marker_list.create(start, true); // left affinity
83        let end_marker = marker_list.create(end, false); // right affinity
84
85        self.ranges.push(FoldRange {
86            start_marker,
87            end_marker,
88            placeholder,
89        });
90    }
91
92    /// Remove all fold ranges and their markers.
93    pub fn clear(&mut self, marker_list: &mut MarkerList) {
94        for range in &self.ranges {
95            marker_list.delete(range.start_marker);
96            marker_list.delete(range.end_marker);
97        }
98        self.ranges.clear();
99    }
100
101    /// Remove any fold that contains the given byte position.
102    /// Returns true if a fold was removed.
103    pub fn remove_if_contains_byte(&mut self, marker_list: &mut MarkerList, byte: usize) -> bool {
104        let mut to_delete = Vec::new();
105
106        self.ranges.retain(|range| {
107            let Some(start_byte) = marker_list.get_position(range.start_marker) else {
108                return true;
109            };
110            let Some(end_byte) = marker_list.get_position(range.end_marker) else {
111                return true;
112            };
113            if start_byte <= byte && byte < end_byte {
114                to_delete.push((range.start_marker, range.end_marker));
115                false
116            } else {
117                true
118            }
119        });
120
121        for (start, end) in &to_delete {
122            marker_list.delete(*start);
123            marker_list.delete(*end);
124        }
125
126        !to_delete.is_empty()
127    }
128
129    /// Resolve all fold ranges into line/byte ranges, filtering invalid entries.
130    pub fn resolved_ranges(
131        &self,
132        buffer: &Buffer,
133        marker_list: &MarkerList,
134    ) -> Vec<ResolvedFoldRange> {
135        let mut ranges = Vec::new();
136
137        for range in &self.ranges {
138            let Some(start_byte) = marker_list.get_position(range.start_marker) else {
139                continue;
140            };
141            let Some(end_byte) = marker_list.get_position(range.end_marker) else {
142                continue;
143            };
144            if end_byte <= start_byte {
145                continue;
146            }
147
148            let start_line = buffer.get_line_number(start_byte);
149            if start_line == 0 {
150                continue;
151            }
152            let end_line = buffer.get_line_number(end_byte.saturating_sub(1));
153            if end_line < start_line {
154                continue;
155            }
156
157            let header_byte =
158                indent_folding::find_line_start_byte(buffer, start_byte.saturating_sub(1));
159
160            ranges.push(ResolvedFoldRange {
161                header_line: start_line - 1,
162                start_line,
163                end_line,
164                start_byte,
165                end_byte,
166                header_byte,
167                placeholder: range.placeholder.clone(),
168            });
169        }
170
171        ranges
172    }
173
174    /// Return a map of header_byte -> placeholder for collapsed folds.
175    pub fn collapsed_header_bytes(
176        &self,
177        buffer: &Buffer,
178        marker_list: &MarkerList,
179    ) -> std::collections::BTreeMap<usize, Option<String>> {
180        let mut map = std::collections::BTreeMap::new();
181        for range in self.resolved_ranges(buffer, marker_list) {
182            map.insert(range.header_byte, range.placeholder);
183        }
184        map
185    }
186
187    /// Remove the fold range whose header byte matches `target_header_byte`.
188    /// Returns true if a fold was removed.
189    pub fn remove_by_header_byte(
190        &mut self,
191        buffer: &Buffer,
192        marker_list: &mut MarkerList,
193        target_header_byte: usize,
194    ) -> bool {
195        let mut to_delete = Vec::new();
196
197        self.ranges.retain(|range| {
198            let Some(start_byte) = marker_list.get_position(range.start_marker) else {
199                return true;
200            };
201            let current_header =
202                indent_folding::find_line_start_byte(buffer, start_byte.saturating_sub(1));
203            if current_header == target_header_byte {
204                to_delete.push((range.start_marker, range.end_marker));
205                false
206            } else {
207                true
208            }
209        });
210
211        for (start, end) in &to_delete {
212            marker_list.delete(*start);
213            marker_list.delete(*end);
214        }
215
216        !to_delete.is_empty()
217    }
218
219    /// Return collapsed fold ranges as line-based data (for persistence/cloning).
220    ///
221    /// Each entry captures the header line's text so session restore can
222    /// detect external edits that shifted line numbers (issue #1568).
223    pub fn collapsed_line_ranges(
224        &self,
225        buffer: &Buffer,
226        marker_list: &MarkerList,
227    ) -> Vec<CollapsedFoldLineRange> {
228        self.resolved_ranges(buffer, marker_list)
229            .into_iter()
230            .map(|range| {
231                let header_text = buffer.get_line(range.header_line).map(|bytes| {
232                    String::from_utf8_lossy(&bytes)
233                        .trim_end_matches('\n')
234                        .trim_end_matches('\r')
235                        .to_string()
236                });
237                CollapsedFoldLineRange {
238                    header_line: range.header_line,
239                    end_line: range.end_line,
240                    placeholder: range.placeholder,
241                    header_text,
242                }
243            })
244            .collect()
245    }
246
247    /// Count total hidden lines for folds with headers in the given range.
248    pub fn hidden_line_count_in_range(
249        &self,
250        buffer: &Buffer,
251        marker_list: &MarkerList,
252        start_line: usize,
253        end_line: usize,
254    ) -> usize {
255        let mut hidden = 0usize;
256        for range in self.resolved_ranges(buffer, marker_list) {
257            if range.header_line >= start_line && range.header_line <= end_line {
258                hidden = hidden.saturating_add(range.end_line.saturating_sub(range.start_line) + 1);
259            }
260        }
261        hidden
262    }
263}
264
265// ---------------------------------------------------------------------------
266// LSP-provided foldable ranges, stored as markers so they auto-adjust on edits
267// ---------------------------------------------------------------------------
268
269/// One LSP fold range, tracked by byte markers that follow buffer edits.
270#[derive(Debug, Clone)]
271struct LspFoldEntry {
272    /// Marker at the first byte of the fold's header line.
273    /// Right affinity: text inserted at the line start pushes the marker down
274    /// with the content, so line_number(marker) keeps pointing at the code
275    /// that used to be at header_line.
276    start_marker: MarkerId,
277    /// Marker at the first byte of the fold's end line.
278    /// Right affinity for the same reason as start_marker.
279    end_marker: MarkerId,
280    /// Optional kind forwarded from the LSP response (comment, imports, region …).
281    kind: Option<lsp_types::FoldingRangeKind>,
282    /// Optional placeholder text shown when the fold is collapsed.
283    collapsed_text: Option<String>,
284}
285
286/// Store for LSP-provided fold ranges. Ranges are tracked as byte markers on
287/// the shared [`MarkerList`], so inserting or deleting lines around (or
288/// inside) a fold re-aligns its header line number automatically — no manual
289/// shifting required. Fixes the "fold indicator lag" from issue #1571.
290#[derive(Debug, Clone, Default)]
291pub struct LspFoldRanges {
292    ranges: Vec<LspFoldEntry>,
293}
294
295impl LspFoldRanges {
296    /// Create an empty store.
297    pub fn new() -> Self {
298        Self::default()
299    }
300
301    /// Returns true if no LSP fold ranges are currently tracked.
302    pub fn is_empty(&self) -> bool {
303        self.ranges.is_empty()
304    }
305
306    /// Number of tracked ranges.
307    pub fn len(&self) -> usize {
308        self.ranges.len()
309    }
310
311    /// Drop every tracked range and release its markers.
312    pub fn clear(&mut self, marker_list: &mut MarkerList) {
313        for range in &self.ranges {
314            marker_list.delete(range.start_marker);
315            marker_list.delete(range.end_marker);
316        }
317        self.ranges.clear();
318    }
319
320    /// Replace the tracked set with fresh LSP-provided ranges (line-based).
321    ///
322    /// Each range's start/end lines are translated to byte offsets via
323    /// [`Buffer::line_start_offset`]; ranges that can't be resolved (e.g. line
324    /// numbers past EOF) are silently dropped.
325    pub fn set_from_lsp(
326        &mut self,
327        buffer: &Buffer,
328        marker_list: &mut MarkerList,
329        ranges: impl IntoIterator<Item = lsp_types::FoldingRange>,
330    ) {
331        self.clear(marker_list);
332        for r in ranges {
333            let Some(start_byte) = buffer.line_start_offset(r.start_line as usize) else {
334                continue;
335            };
336            let Some(end_byte) = buffer.line_start_offset(r.end_line as usize) else {
337                continue;
338            };
339            // Right affinity: text inserted at the line start pushes the marker
340            // down with the content (so it keeps pointing at the same *code*,
341            // not the same *byte offset*).
342            let start_marker = marker_list.create(start_byte, false);
343            let end_marker = marker_list.create(end_byte, false);
344            self.ranges.push(LspFoldEntry {
345                start_marker,
346                end_marker,
347                kind: r.kind,
348                collapsed_text: r.collapsed_text,
349            });
350        }
351    }
352
353    /// Resolve to the current line-based LSP-style ranges (post-edit).
354    ///
355    /// Ranges whose markers have been invalidated (e.g. the header line was
356    /// deleted out from under them such that end comes before start) are
357    /// filtered out.
358    pub fn resolved(
359        &self,
360        buffer: &Buffer,
361        marker_list: &MarkerList,
362    ) -> Vec<lsp_types::FoldingRange> {
363        self.ranges
364            .iter()
365            .filter_map(|r| {
366                let start_byte = marker_list.get_position(r.start_marker)?;
367                let end_byte = marker_list.get_position(r.end_marker)?;
368                let start_line = buffer.get_line_number(start_byte);
369                let end_line = buffer.get_line_number(end_byte);
370                if end_line <= start_line {
371                    return None;
372                }
373                Some(lsp_types::FoldingRange {
374                    start_line: start_line as u32,
375                    end_line: end_line as u32,
376                    start_character: None,
377                    end_character: None,
378                    kind: r.kind.clone(),
379                    collapsed_text: r.collapsed_text.clone(),
380                })
381            })
382            .collect()
383    }
384}
385
386impl Default for FoldManager {
387    fn default() -> Self {
388        Self::new()
389    }
390}
391
392/// Indent-based folding fallback for when LSP folding ranges are not available.
393///
394/// Computes foldable ranges by analyzing indentation levels, reusing the same
395/// indent measurement logic as the auto-indent feature
396/// ([`PatternIndentCalculator::count_leading_indent`]).
397pub mod indent_folding {
398    use crate::model::buffer::Buffer;
399    use crate::primitives::indent_pattern::PatternIndentCalculator;
400
401    /// Find the byte offset of the start of the line containing `pos`.
402    /// Scans backward for `\n` (or returns 0).
403    pub fn find_line_start_byte(buffer: &Buffer, pos: usize) -> usize {
404        if pos == 0 {
405            return 0;
406        }
407        let mut p = pos.min(buffer.len()).saturating_sub(1);
408        loop {
409            match PatternIndentCalculator::byte_at(buffer, p) {
410                Some(b'\n') => return p + 1,
411                None => return 0,
412                _ => {
413                    if p == 0 {
414                        return 0;
415                    }
416                    p -= 1;
417                }
418            }
419        }
420    }
421
422    /// Measure leading indent of a line given as a byte slice (no trailing `\n`).
423    fn slice_indent(line: &[u8], tab_size: usize) -> (usize, bool) {
424        let mut indent = 0;
425        let mut all_blank = true;
426        for &b in line {
427            match b {
428                b' ' => indent += 1,
429                b'\t' => {
430                    if tab_size > 0 {
431                        indent += tab_size - (indent % tab_size);
432                    } else {
433                        indent += 1;
434                    }
435                }
436                b'\r' => {}
437                _ => {
438                    all_blank = false;
439                    break;
440                }
441            }
442        }
443        (indent, all_blank)
444    }
445
446    /// Check if the first line in the given slice is foldable.
447    /// Uses subsequent lines in the slice for lookahead.
448    pub fn is_line_foldable_in_bytes(lines: &[&[u8]], tab_size: usize) -> bool {
449        if lines.is_empty() {
450            return false;
451        }
452
453        let (header_indent, header_blank) = slice_indent(lines[0], tab_size);
454        if header_blank {
455            return false;
456        }
457
458        // Find next non-blank line within the provided lines.
459        let mut next = 1;
460        while next < lines.len() {
461            let (_, blank) = slice_indent(lines[next], tab_size);
462            if !blank {
463                break;
464            }
465            next += 1;
466        }
467
468        if next >= lines.len() {
469            return false;
470        }
471
472        let (next_indent, _) = slice_indent(lines[next], tab_size);
473        next_indent > header_indent
474    }
475
476    /// Byte-based fold-end search for a single header line.
477    ///
478    /// Reads up to `max_scan_bytes` forward from `header_byte` and determines
479    /// whether the line at that offset is foldable (next non-blank line is more
480    /// indented).  Returns `Some(end_byte)` where `end_byte` is the start of
481    /// the last non-blank line still inside the fold, or `None`.
482    pub fn indent_fold_end_byte(
483        buffer: &Buffer,
484        header_byte: usize,
485        tab_size: usize,
486        max_scan_bytes: usize,
487    ) -> Option<usize> {
488        let buf_len = buffer.len();
489        let end = buf_len.min(header_byte.saturating_add(max_scan_bytes));
490        let bytes = buffer.slice_bytes(header_byte..end);
491        if bytes.is_empty() {
492            return None;
493        }
494
495        let lines: Vec<&[u8]> = bytes.split(|&b| b == b'\n').collect();
496        if lines.is_empty() {
497            return None;
498        }
499
500        let (header_indent, header_blank) = slice_indent(lines[0], tab_size);
501        if header_blank {
502            return None;
503        }
504
505        // Find next non-blank line.
506        let mut next = 1;
507        while next < lines.len() {
508            let (_, blank) = slice_indent(lines[next], tab_size);
509            if !blank {
510                break;
511            }
512            next += 1;
513        }
514        if next >= lines.len() {
515            return None;
516        }
517
518        let (next_indent, _) = slice_indent(lines[next], tab_size);
519        if next_indent <= header_indent {
520            return None;
521        }
522
523        // Scan forward for fold boundary.
524        let mut last_non_blank_line = next;
525        let mut current = next + 1;
526        while current < lines.len() {
527            let (indent, blank) = slice_indent(lines[current], tab_size);
528            if blank {
529                current += 1;
530                continue;
531            }
532            if indent <= header_indent {
533                break;
534            }
535            last_non_blank_line = current;
536            current += 1;
537        }
538
539        if last_non_blank_line < 1 {
540            return None;
541        }
542
543        // Convert line index back to byte offset: sum lengths of lines 0..last_non_blank_line
544        // (each line was separated by a `\n`).
545        let mut byte_offset = 0;
546        for line in &lines[..last_non_blank_line] {
547            byte_offset += line.len() + 1; // +1 for the \n
548        }
549        Some(header_byte + byte_offset)
550    }
551
552    /// Find the byte offset of the start of the *next* line after `pos`.
553    /// Scans forward for `\n` and returns the byte after it. If no `\n` is
554    /// found, returns `buffer.len()`.
555    pub fn find_next_line_start_byte(buffer: &Buffer, pos: usize) -> usize {
556        let mut p = pos;
557        let len = buffer.len();
558        while p < len {
559            match PatternIndentCalculator::byte_at(buffer, p) {
560                Some(b'\n') => return p + 1,
561                None => return len,
562                _ => p += 1,
563            }
564        }
565        len
566    }
567
568    /// Byte-range of a fold that contains `target_byte`.
569    ///
570    /// Walks backward (up to `max_upward_lines` lines) from the line
571    /// containing `target_byte`, trying each candidate as a fold header via
572    /// [`indent_fold_end_byte`].  When a fold is found whose hidden range
573    /// reaches at least `target_byte`, returns `(header_byte, start_byte,
574    /// end_byte)` where:
575    ///
576    /// * `header_byte` – first byte of the fold header line
577    /// * `start_byte`  – first hidden byte (start of the line after the header)
578    /// * `end_byte`    – one past the last hidden byte (start of the line
579    ///   *after* the last hidden line, or `buffer.len()`)
580    ///
581    /// Returns `None` if no enclosing fold is found within the search limit.
582    pub fn find_fold_range_at_byte(
583        buffer: &Buffer,
584        target_byte: usize,
585        tab_size: usize,
586        max_scan_bytes: usize,
587        max_upward_lines: usize,
588    ) -> Option<(usize, usize, usize)> {
589        let mut header_byte = find_line_start_byte(buffer, target_byte);
590
591        for _ in 0..=max_upward_lines {
592            if let Some(fold_end_byte) =
593                indent_fold_end_byte(buffer, header_byte, tab_size, max_scan_bytes)
594            {
595                if fold_end_byte >= target_byte {
596                    let eb = find_next_line_start_byte(buffer, fold_end_byte);
597                    let sb = find_next_line_start_byte(buffer, header_byte);
598                    if sb < eb {
599                        return Some((header_byte, sb, eb));
600                    }
601                }
602            }
603            if header_byte == 0 {
604                break;
605            }
606            header_byte = find_line_start_byte(buffer, header_byte.saturating_sub(1));
607        }
608
609        None
610    }
611
612    #[cfg(test)]
613    mod tests {
614        use super::*;
615
616        #[test]
617        fn test_slice_indent_spaces() {
618            assert_eq!(slice_indent(b"    hello", 4), (4, false));
619            assert_eq!(slice_indent(b"hello", 4), (0, false));
620            assert_eq!(slice_indent(b"        deep", 4), (8, false));
621        }
622
623        #[test]
624        fn test_slice_indent_tabs() {
625            assert_eq!(slice_indent(b"\thello", 4), (4, false));
626            assert_eq!(slice_indent(b"\t\thello", 4), (8, false));
627            // Mixed: 2 spaces + tab (tab_size=4) → 2 + (4-2) = 4
628            assert_eq!(slice_indent(b"  \thello", 4), (4, false));
629        }
630
631        #[test]
632        fn test_slice_indent_blank() {
633            assert_eq!(slice_indent(b"", 4), (0, true));
634            assert_eq!(slice_indent(b"   ", 4), (3, true));
635            assert_eq!(slice_indent(b"  \r", 4), (2, true));
636        }
637
638        #[test]
639        fn test_is_line_foldable_basic() {
640            let lines: Vec<&[u8]> = vec![b"fn main() {", b"    println!();", b"}"];
641            assert!(is_line_foldable_in_bytes(&lines, 4));
642        }
643
644        #[test]
645        fn test_is_line_foldable_not_foldable() {
646            let lines: Vec<&[u8]> = vec![b"line1", b"line2", b"line3"];
647            assert!(!is_line_foldable_in_bytes(&lines, 4));
648        }
649
650        #[test]
651        fn test_is_line_foldable_blank_lines_skipped() {
652            let lines: Vec<&[u8]> = vec![b"fn main() {", b"", b"    println!();", b"}"];
653            assert!(is_line_foldable_in_bytes(&lines, 4));
654        }
655    }
656}