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    /// Find the exclusive byte offset just past the line containing `pos`
423    /// (i.e. one byte past its terminating `\n`, or the buffer length if the
424    /// line has no trailing newline). Scans forward for `\n`.
425    pub fn find_line_end_byte(buffer: &Buffer, pos: usize) -> usize {
426        let buf_len = buffer.len();
427        let mut p = pos;
428        while p < buf_len {
429            match PatternIndentCalculator::byte_at(buffer, p) {
430                Some(b'\n') => return p + 1,
431                None => return buf_len,
432                _ => p += 1,
433            }
434        }
435        buf_len
436    }
437
438    /// Measure leading indent of a line given as a byte slice (no trailing `\n`).
439    fn slice_indent(line: &[u8], tab_size: usize) -> (usize, bool) {
440        let mut indent = 0;
441        let mut all_blank = true;
442        for &b in line {
443            match b {
444                b' ' => indent += 1,
445                b'\t' => {
446                    if tab_size > 0 {
447                        indent += tab_size - (indent % tab_size);
448                    } else {
449                        indent += 1;
450                    }
451                }
452                b'\r' => {}
453                _ => {
454                    all_blank = false;
455                    break;
456                }
457            }
458        }
459        (indent, all_blank)
460    }
461
462    /// Check if the first line in the given slice is foldable.
463    /// Uses subsequent lines in the slice for lookahead.
464    pub fn is_line_foldable_in_bytes(lines: &[&[u8]], tab_size: usize) -> bool {
465        if lines.is_empty() {
466            return false;
467        }
468
469        let (header_indent, header_blank) = slice_indent(lines[0], tab_size);
470        if header_blank {
471            return false;
472        }
473
474        // Find next non-blank line within the provided lines.
475        let mut next = 1;
476        while next < lines.len() {
477            let (_, blank) = slice_indent(lines[next], tab_size);
478            if !blank {
479                break;
480            }
481            next += 1;
482        }
483
484        if next >= lines.len() {
485            return false;
486        }
487
488        let (next_indent, _) = slice_indent(lines[next], tab_size);
489        next_indent > header_indent
490    }
491
492    /// Byte-based fold-end search for a single header line.
493    ///
494    /// Reads up to `max_scan_bytes` forward from `header_byte` and determines
495    /// whether the line at that offset is foldable (next non-blank line is more
496    /// indented).  Returns `Some(end_byte)` where `end_byte` is the start of
497    /// the last non-blank line still inside the fold, or `None`.
498    pub fn indent_fold_end_byte(
499        buffer: &Buffer,
500        header_byte: usize,
501        tab_size: usize,
502        max_scan_bytes: usize,
503    ) -> Option<usize> {
504        let buf_len = buffer.len();
505        let end = buf_len.min(header_byte.saturating_add(max_scan_bytes));
506        let bytes = buffer.slice_bytes(header_byte..end);
507        if bytes.is_empty() {
508            return None;
509        }
510
511        let lines: Vec<&[u8]> = bytes.split(|&b| b == b'\n').collect();
512        if lines.is_empty() {
513            return None;
514        }
515
516        let (header_indent, header_blank) = slice_indent(lines[0], tab_size);
517        if header_blank {
518            return None;
519        }
520
521        // Find next non-blank line.
522        let mut next = 1;
523        while next < lines.len() {
524            let (_, blank) = slice_indent(lines[next], tab_size);
525            if !blank {
526                break;
527            }
528            next += 1;
529        }
530        if next >= lines.len() {
531            return None;
532        }
533
534        let (next_indent, _) = slice_indent(lines[next], tab_size);
535        if next_indent <= header_indent {
536            return None;
537        }
538
539        // Scan forward for fold boundary.
540        let mut last_non_blank_line = next;
541        let mut current = next + 1;
542        while current < lines.len() {
543            let (indent, blank) = slice_indent(lines[current], tab_size);
544            if blank {
545                current += 1;
546                continue;
547            }
548            if indent <= header_indent {
549                break;
550            }
551            last_non_blank_line = current;
552            current += 1;
553        }
554
555        if last_non_blank_line < 1 {
556            return None;
557        }
558
559        // Convert line index back to byte offset: sum lengths of lines 0..last_non_blank_line
560        // (each line was separated by a `\n`).
561        let mut byte_offset = 0;
562        for line in &lines[..last_non_blank_line] {
563            byte_offset += line.len() + 1; // +1 for the \n
564        }
565        Some(header_byte + byte_offset)
566    }
567
568    /// Find the byte offset of the start of the *next* line after `pos`.
569    /// Scans forward for `\n` and returns the byte after it. If no `\n` is
570    /// found, returns `buffer.len()`.
571    pub fn find_next_line_start_byte(buffer: &Buffer, pos: usize) -> usize {
572        let mut p = pos;
573        let len = buffer.len();
574        while p < len {
575            match PatternIndentCalculator::byte_at(buffer, p) {
576                Some(b'\n') => return p + 1,
577                None => return len,
578                _ => p += 1,
579            }
580        }
581        len
582    }
583
584    /// Byte-range of a fold that contains `target_byte`.
585    ///
586    /// Walks backward (up to `max_upward_lines` lines) from the line
587    /// containing `target_byte`, trying each candidate as a fold header via
588    /// [`indent_fold_end_byte`].  When a fold is found whose hidden range
589    /// reaches at least `target_byte`, returns `(header_byte, start_byte,
590    /// end_byte)` where:
591    ///
592    /// * `header_byte` – first byte of the fold header line
593    /// * `start_byte`  – first hidden byte (start of the line after the header)
594    /// * `end_byte`    – one past the last hidden byte (start of the line
595    ///   *after* the last hidden line, or `buffer.len()`)
596    ///
597    /// Returns `None` if no enclosing fold is found within the search limit.
598    pub fn find_fold_range_at_byte(
599        buffer: &Buffer,
600        target_byte: usize,
601        tab_size: usize,
602        max_scan_bytes: usize,
603        max_upward_lines: usize,
604    ) -> Option<(usize, usize, usize)> {
605        let mut header_byte = find_line_start_byte(buffer, target_byte);
606
607        for _ in 0..=max_upward_lines {
608            if let Some(fold_end_byte) =
609                indent_fold_end_byte(buffer, header_byte, tab_size, max_scan_bytes)
610            {
611                if fold_end_byte >= target_byte {
612                    let eb = find_next_line_start_byte(buffer, fold_end_byte);
613                    let sb = find_next_line_start_byte(buffer, header_byte);
614                    if sb < eb {
615                        return Some((header_byte, sb, eb));
616                    }
617                }
618            }
619            if header_byte == 0 {
620                break;
621            }
622            header_byte = find_line_start_byte(buffer, header_byte.saturating_sub(1));
623        }
624
625        None
626    }
627
628    #[cfg(test)]
629    mod tests {
630        use super::*;
631
632        #[test]
633        fn test_slice_indent_spaces() {
634            assert_eq!(slice_indent(b"    hello", 4), (4, false));
635            assert_eq!(slice_indent(b"hello", 4), (0, false));
636            assert_eq!(slice_indent(b"        deep", 4), (8, false));
637        }
638
639        #[test]
640        fn test_slice_indent_tabs() {
641            assert_eq!(slice_indent(b"\thello", 4), (4, false));
642            assert_eq!(slice_indent(b"\t\thello", 4), (8, false));
643            // Mixed: 2 spaces + tab (tab_size=4) → 2 + (4-2) = 4
644            assert_eq!(slice_indent(b"  \thello", 4), (4, false));
645        }
646
647        #[test]
648        fn test_slice_indent_blank() {
649            assert_eq!(slice_indent(b"", 4), (0, true));
650            assert_eq!(slice_indent(b"   ", 4), (3, true));
651            assert_eq!(slice_indent(b"  \r", 4), (2, true));
652        }
653
654        #[test]
655        fn test_is_line_foldable_basic() {
656            let lines: Vec<&[u8]> = vec![b"fn main() {", b"    println!();", b"}"];
657            assert!(is_line_foldable_in_bytes(&lines, 4));
658        }
659
660        #[test]
661        fn test_is_line_foldable_not_foldable() {
662            let lines: Vec<&[u8]> = vec![b"line1", b"line2", b"line3"];
663            assert!(!is_line_foldable_in_bytes(&lines, 4));
664        }
665
666        #[test]
667        fn test_is_line_foldable_blank_lines_skipped() {
668            let lines: Vec<&[u8]> = vec![b"fn main() {", b"", b"    println!();", b"}"];
669            assert!(is_line_foldable_in_bytes(&lines, 4));
670        }
671    }
672}