Skip to main content

ochrepad_core/
fold.rs

1//! Code folding (PLAN_v0.2.0 N3): where regions are, and which are folded
2//! in a view.
3//!
4//! Regions come from indentation, or from headings in Markdown. A line is a
5//! fold header when the next non-blank line is indented deeper; its region
6//! runs to the last line before the next non-blank line indented no deeper,
7//! leaving trailing blank lines visible (so a closing `}` at the header's
8//! indent stays visible, as in VS Code). Detection never scans the whole
9//! document except for the fold-all and level commands.
10//!
11//! A [`FoldSet`] is per view. Each fold is stored as a byte range so it maps
12//! through edits like selections do; the rules for edits and carets live in
13//! `Editor` (PLAN_v0.2.0 3.6).
14//!
15//! A view's filter (PLAN_v0.3.0 P6: show only the lines that match, or
16//! only those that do not) hides lines too, so it lives here: its hidden
17//! line ranges are merged with the folds' into the one list the display
18//! map reads. Edits shift them by line (lines an edit touches are shown).
19
20use std::ops::Range;
21
22use serde::{Deserialize, Serialize};
23
24use crate::buffer::LineEdit;
25use crate::changeset::{Bias, ChangeSet};
26use crate::position::{ByteOffset, byte_to_visual_col};
27use crate::text::Document;
28
29/// How a buffer's fold regions are found. Chosen by the app from the
30/// buffer's language.
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
32pub enum FoldStrategy {
33    #[default]
34    Indent,
35    /// Headings fold to the next heading of the same or a higher level.
36    Markdown,
37}
38
39/// Blank lines skipped when looking for the line after a header; past this
40/// a line does not count as a header (keeps the gutter cheap).
41const MAX_BLANK_RUN: usize = 1000;
42/// Lines looked back through to tell whether a `#` line is inside a fenced
43/// code block. A fence opened further up is missed (documented).
44const FENCE_LOOKBACK: usize = 200;
45
46/// The indentation width of `line` in cells, or `None` for a blank line.
47fn indent(doc: &Document, line: usize, tab_width: usize) -> Option<usize> {
48    let text = doc.line_text(line);
49    let rest = text.trim_start_matches([' ', '\t']);
50    if rest.is_empty() {
51        return None;
52    }
53    let lead = text.len() - rest.len();
54    Some(byte_to_visual_col(doc, ByteOffset(doc.line_start(line).0 + lead), tab_width).0)
55}
56
57/// The first non-blank line after `line` and its indent, looking at most
58/// `limit` lines ahead.
59fn next_non_blank(
60    doc: &Document,
61    line: usize,
62    tab_width: usize,
63    limit: usize,
64) -> Option<(usize, usize)> {
65    (line + 1..doc.line_count().min((line + 1).saturating_add(limit)))
66        .find_map(|l| indent(doc, l, tab_width).map(|w| (l, w)))
67}
68
69/// A Markdown ATX heading's level (1–6).
70fn heading_level(text: &str) -> Option<usize> {
71    let t = text.strip_prefix("   ").or_else(|| text.strip_prefix("  "));
72    let t = t.or_else(|| text.strip_prefix(' ')).unwrap_or(text);
73    let hashes = t.len() - t.trim_start_matches('#').len();
74    let after = &t[hashes..];
75    ((1..=6).contains(&hashes) && (after.is_empty() || after.starts_with([' ', '\t'])))
76        .then_some(hashes)
77}
78
79fn is_fence(text: &str) -> bool {
80    let t = text.trim_start_matches(' ');
81    text.len() - t.len() <= 3 && (t.starts_with("```") || t.starts_with("~~~"))
82}
83
84/// Whether `line` is inside a fenced code block, judged from the fences in
85/// the [`FENCE_LOOKBACK`] lines above it.
86fn in_fence(doc: &Document, line: usize) -> bool {
87    let fences = (line.saturating_sub(FENCE_LOOKBACK)..line)
88        .filter(|&l| is_fence(&doc.line_text(l)))
89        .count();
90    fences % 2 == 1
91}
92
93/// The heading level of `line` if it is a heading outside a code fence.
94fn markdown_heading(doc: &Document, line: usize) -> Option<usize> {
95    heading_level(&doc.line_text(line)).filter(|_| !in_fence(doc, line))
96}
97
98/// The last line `header` folds, or `None` if it is not a fold header. The
99/// region is `header + 1 ..= last`.
100pub fn region(
101    doc: &Document,
102    header: usize,
103    strategy: FoldStrategy,
104    tab_width: usize,
105) -> Option<usize> {
106    if header >= doc.line_count() {
107        return None;
108    }
109    let mut last = header;
110    match strategy {
111        FoldStrategy::Indent => {
112            let w = indent(doc, header, tab_width)?;
113            for l in header + 1..doc.line_count() {
114                match indent(doc, l, tab_width) {
115                    None => {}
116                    Some(i) if i <= w => break,
117                    Some(_) => last = l,
118                }
119            }
120        }
121        FoldStrategy::Markdown => {
122            let level = markdown_heading(doc, header)?;
123            let mut fenced = false;
124            for l in header + 1..doc.line_count() {
125                let text = doc.line_text(l);
126                if is_fence(&text) {
127                    fenced = !fenced;
128                } else if !fenced && heading_level(&text).is_some_and(|h| h <= level) {
129                    break;
130                }
131                if fenced || !text.trim().is_empty() {
132                    last = l;
133                }
134            }
135        }
136    }
137    (last > header).then_some(last)
138}
139
140/// Whether `line` starts a fold region. Cheaper than [`region`]: it only
141/// looks at the next non-blank line.
142pub fn is_header(doc: &Document, line: usize, strategy: FoldStrategy, tab_width: usize) -> bool {
143    if line >= doc.line_count() {
144        return false;
145    }
146    match strategy {
147        FoldStrategy::Indent => indent(doc, line, tab_width).is_some_and(|w| {
148            next_non_blank(doc, line, tab_width, MAX_BLANK_RUN).is_some_and(|(_, n)| n > w)
149        }),
150        FoldStrategy::Markdown => {
151            let Some(level) = heading_level(&doc.line_text(line)) else {
152                return false;
153            };
154            // Folds unless the next non-blank line is a heading that ends it.
155            let next = next_non_blank(doc, line, tab_width, MAX_BLANK_RUN);
156            next.is_some_and(|(l, _)| {
157                let text = doc.line_text(l);
158                is_fence(&text) || heading_level(&text).is_none_or(|h| h > level)
159            }) && !in_fence(doc, line)
160        }
161    }
162}
163
164/// A fold region: its header line, its level (the nesting depth for
165/// indentation, 1 being outermost; the heading level for Markdown) and its
166/// last line.
167#[derive(Clone, Copy, Debug, PartialEq, Eq)]
168pub struct Region {
169    pub header: usize,
170    pub level: usize,
171    pub last: usize,
172}
173
174/// Every fold region, in header order. Scans the whole document once.
175pub fn regions(doc: &Document, strategy: FoldStrategy, tab_width: usize) -> Vec<Region> {
176    let mut out = Vec::new();
177    match strategy {
178        FoldStrategy::Indent => {
179            // Regions still open: (indent, index into `out`).
180            let mut open: Vec<(usize, usize)> = Vec::new();
181            let mut last_non_blank = 0;
182            let mut prev: Option<(usize, usize)> = None;
183            let close = |open: &mut Vec<(usize, usize)>, out: &mut Vec<Region>, w, last| {
184                while let Some(&(o, i)) = open.last() {
185                    if o < w {
186                        break;
187                    }
188                    out[i].last = last;
189                    open.pop();
190                }
191            };
192            for l in 0..doc.line_count() {
193                let Some(w) = indent(doc, l, tab_width) else {
194                    continue;
195                };
196                // The previous non-blank line heads a region if this one is
197                // indented deeper.
198                if let Some((pl, pw)) = prev
199                    && w > pw
200                {
201                    out.push(Region {
202                        header: pl,
203                        level: open.len() + 1,
204                        last: pl,
205                    });
206                    open.push((pw, out.len() - 1));
207                }
208                close(&mut open, &mut out, w, last_non_blank);
209                last_non_blank = l;
210                prev = Some((l, w));
211            }
212            close(&mut open, &mut out, 0, last_non_blank);
213        }
214        FoldStrategy::Markdown => {
215            let mut fenced = false;
216            for l in 0..doc.line_count() {
217                let text = doc.line_text(l);
218                if is_fence(&text) {
219                    fenced = !fenced;
220                } else if !fenced
221                    && let Some(level) = heading_level(&text)
222                    && let Some(last) = region(doc, l, strategy, tab_width)
223                {
224                    out.push(Region {
225                        header: l,
226                        level,
227                        last,
228                    });
229                }
230            }
231        }
232    }
233    out
234}
235
236/// The innermost region holding `line`: its header (`line` itself if it is
237/// one) and last line.
238pub fn enclosing(
239    doc: &Document,
240    line: usize,
241    strategy: FoldStrategy,
242    tab_width: usize,
243) -> Option<(usize, usize)> {
244    if let Some(last) = region(doc, line, strategy, tab_width) {
245        return Some((line, last));
246    }
247    match strategy {
248        FoldStrategy::Indent => {
249            // A blank line belongs with the line after it.
250            let w = indent(doc, line, tab_width)
251                .or_else(|| next_non_blank(doc, line, tab_width, usize::MAX).map(|(_, w)| w))?;
252            let header = (0..line)
253                .rev()
254                .find(|&l| indent(doc, l, tab_width).is_some_and(|i| i < w))?;
255            let last = region(doc, header, strategy, tab_width)?;
256            (last >= line).then_some((header, last))
257        }
258        FoldStrategy::Markdown => (0..line).rev().find_map(|l| {
259            let last = region(doc, l, strategy, tab_width)?;
260            (last >= line).then_some((l, last))
261        }),
262    }
263}
264
265#[derive(Clone, Debug, PartialEq, Eq)]
266struct Fold {
267    /// The hidden text: from the end of the header line (its line break) to
268    /// the end of the last hidden line.
269    range: Range<ByteOffset>,
270    header: usize,
271    last: usize,
272}
273
274/// The folded regions of one view, possibly nested, and its filter.
275#[derive(Clone, Debug, Default, PartialEq, Eq)]
276pub struct FoldSet {
277    /// Sorted by start; any two are nested or disjoint.
278    folds: Vec<Fold>,
279    /// The lines the filter hides: sorted, disjoint inclusive ranges.
280    /// `None` without a filter.
281    filter: Option<Vec<(usize, usize)>>,
282    /// The hidden lines: the union of the folds' regions and the filter's
283    /// ranges as sorted, disjoint inclusive ranges, with the hidden lines
284    /// before each.
285    hidden: Vec<(usize, usize, usize)>,
286    /// The lines the folds alone hide (each range follows its header).
287    fold_hidden: Vec<(usize, usize)>,
288    /// Mapped through an edit since the line numbers were computed.
289    stale: bool,
290}
291
292impl FoldSet {
293    /// Whether there are no folds (a filter may still hide lines).
294    pub fn is_empty(&self) -> bool {
295        self.folds.is_empty()
296    }
297
298    /// Whether a filter is on.
299    pub fn has_filter(&self) -> bool {
300        self.filter.is_some()
301    }
302
303    /// Lines the filter hides (0 without one).
304    pub fn filter_hidden_count(&self) -> usize {
305        self.filter
306            .as_ref()
307            .map_or(0, |f| f.iter().map(|r| r.1 + 1 - r.0).sum())
308    }
309
310    /// Whether the filter hides `line`.
311    pub fn filter_hides(&self, line: usize) -> bool {
312        self.filter.as_ref().is_some_and(|f| {
313            let i = f.partition_point(|r| r.1 < line);
314            f.get(i).is_some_and(|r| r.0 <= line)
315        })
316    }
317
318    /// The range of lines a fold hides holding `line` (not the filter's).
319    pub fn fold_hidden_range(&self, line: usize) -> Option<(usize, usize)> {
320        let i = self.fold_hidden.partition_point(|h| h.1 < line);
321        self.fold_hidden.get(i).copied().filter(|h| h.0 <= line)
322    }
323
324    /// Hides every line of `line_count` but `shown` (sorted), or none with
325    /// `None`.
326    pub(crate) fn set_filter(&mut self, shown: Option<&[usize]>, line_count: usize) {
327        self.filter = shown.map(|shown| {
328            let mut ranges = Vec::new();
329            let mut next = 0;
330            for &l in shown.iter().filter(|&&l| l < line_count) {
331                if l > next {
332                    ranges.push((next, l - 1));
333                }
334                next = next.max(l + 1);
335            }
336            if next < line_count {
337                ranges.push((next, line_count - 1));
338            }
339            ranges
340        });
341        self.rebuild();
342    }
343
344    /// Hides every line from `from` on but `shown` (sorted), leaving the
345    /// lines before `from` as they are: the lines a followed file gained.
346    pub(crate) fn set_filter_from(&mut self, from: usize, shown: &[usize], line_count: usize) {
347        let Some(f) = &mut self.filter else {
348            return;
349        };
350        f.retain_mut(|r| {
351            r.1 = r.1.min(from.saturating_sub(1));
352            r.0 < from
353        });
354        let mut next = from;
355        let mut push = |a: usize, b: usize| match f.last_mut() {
356            Some(last) if last.1 + 1 == a => last.1 = b,
357            _ => f.push((a, b)),
358        };
359        for &l in shown.iter().filter(|&&l| l >= from && l < line_count) {
360            if l > next {
361                push(next, l - 1);
362            }
363            next = next.max(l + 1);
364        }
365        if next < line_count {
366            push(next, line_count - 1);
367        }
368        self.rebuild();
369    }
370
371    /// Shows the filter's hidden lines among `lines` again.
372    pub(crate) fn unfilter(&mut self, lines: &[usize]) {
373        let Some(f) = &mut self.filter else {
374            return;
375        };
376        let mut changed = false;
377        for &l in lines {
378            let i = f.partition_point(|r| r.1 < l);
379            let Some(&(a, b)) = f.get(i).filter(|r| r.0 <= l) else {
380                continue;
381            };
382            changed = true;
383            let mut parts = Vec::with_capacity(2);
384            if a < l {
385                parts.push((a, l - 1));
386            }
387            if l < b {
388                parts.push((l + 1, b));
389            }
390            f.splice(i..=i, parts);
391        }
392        if changed {
393            self.rebuild();
394        }
395    }
396
397    /// Moves the filter's ranges through edits to the lines (in the order
398    /// they were made): later lines shift, and the lines an edit touched
399    /// are shown.
400    pub(crate) fn filter_line_edits(&mut self, edits: &[LineEdit]) {
401        let Some(f) = &mut self.filter else {
402            return;
403        };
404        if edits.is_empty() {
405            return;
406        }
407        for e in edits {
408            *f = map_filter_ranges(f, *e);
409        }
410        // Mapped folds have stale lines until `relines`, which rebuilds.
411        if !self.stale {
412            self.rebuild();
413        }
414    }
415
416    /// The folded header lines, in order.
417    pub fn headers(&self) -> impl Iterator<Item = usize> + '_ {
418        self.folds.iter().map(|f| f.header)
419    }
420
421    pub fn is_folded(&self, header: usize) -> bool {
422        self.folds.iter().any(|f| f.header == header)
423    }
424
425    /// The hidden line range (inclusive) holding `line`.
426    pub fn hidden_range(&self, line: usize) -> Option<(usize, usize)> {
427        let i = self.hidden.partition_point(|h| h.1 < line);
428        self.hidden
429            .get(i)
430            .filter(|h| h.0 <= line)
431            .map(|h| (h.0, h.1))
432    }
433
434    pub fn is_hidden(&self, line: usize) -> bool {
435        self.hidden_range(line).is_some()
436    }
437
438    /// Hidden lines in total.
439    pub fn hidden_count(&self) -> usize {
440        self.hidden.last().map_or(0, |h| h.2 + h.1 + 1 - h.0)
441    }
442
443    /// Hidden lines before `line`.
444    pub fn hidden_before(&self, line: usize) -> usize {
445        let i = self.hidden.partition_point(|h| h.0 < line);
446        match i.checked_sub(1).map(|i| self.hidden[i]) {
447            None => 0,
448            Some((start, end, before)) => before + end.min(line - 1) + 1 - start,
449        }
450    }
451
452    /// The line that is the `index`-th visible line (0-based), or the last
453    /// visible one if there are fewer.
454    pub fn visible_line(&self, index: usize, line_count: usize) -> usize {
455        // A hidden range comes after `start - before` visible lines; the
456        // ranges before the wanted line are those it comes after.
457        let i = self.hidden.partition_point(|h| h.0 - h.2 <= index);
458        let line = match i.checked_sub(1).map(|i| self.hidden[i]) {
459            None => index,
460            Some((start, end, before)) => index + before + end + 1 - start,
461        };
462        line.min(line_count.saturating_sub(1))
463    }
464
465    /// Folds each `(header, last)`: lines `header + 1 ..= last`.
466    pub(crate) fn insert(
467        &mut self,
468        doc: &Document,
469        regions: impl IntoIterator<Item = (usize, usize)>,
470    ) {
471        for (header, last) in regions {
472            if last > header && last < doc.line_count() {
473                self.folds.push(Fold {
474                    range: doc.line_end(header)..doc.line_end(last),
475                    header,
476                    last,
477                });
478            }
479        }
480        self.folds.sort_by_key(|f| f.range.start);
481        self.folds.dedup_by_key(|f| f.header);
482        self.rebuild();
483    }
484
485    /// Unfolds the fold at `header`; returns whether there was one.
486    pub(crate) fn remove(&mut self, header: usize) -> bool {
487        let before = self.folds.len();
488        self.folds.retain(|f| f.header != header);
489        self.rebuild();
490        self.folds.len() != before
491    }
492
493    /// Keeps the folds whose (header, last) pass `keep`.
494    pub(crate) fn retain(&mut self, mut keep: impl FnMut(usize, usize) -> bool) {
495        self.folds.retain(|f| keep(f.header, f.last));
496        self.rebuild();
497    }
498
499    /// Maps the folds through an edit. A fold whose hidden text the edit
500    /// touches is dropped; typing at the end of a header line is not inside
501    /// it. Line numbers are stale until [`relines`](Self::relines).
502    pub(crate) fn map(&mut self, changes: &ChangeSet) {
503        self.stale = true;
504        self.folds.retain_mut(|f| {
505            let touched = changes.edits().iter().any(|e| {
506                if e.start == e.end {
507                    f.range.start < e.start && e.start <= f.range.end
508                } else {
509                    e.start <= f.range.end && e.end > f.range.start
510                }
511            });
512            f.range = changes.map_position(f.range.start, Bias::After)
513                ..changes.map_position(f.range.end, Bias::Before);
514            !touched
515        });
516    }
517
518    /// Recomputes line numbers after [`map`](Self::map).
519    pub(crate) fn relines(&mut self, doc: &Document) {
520        if !std::mem::take(&mut self.stale) {
521            return;
522        }
523        for f in &mut self.folds {
524            f.header = doc.line_of(f.range.start);
525            f.last = doc.line_of(f.range.end);
526        }
527        self.folds.retain(|f| f.last > f.header);
528        self.rebuild();
529    }
530
531    fn rebuild(&mut self) {
532        self.fold_hidden.clear();
533        for f in &self.folds {
534            let (a, b) = (f.header + 1, f.last);
535            match self.fold_hidden.last_mut() {
536                Some(last) if a <= last.1 + 1 => last.1 = last.1.max(b),
537                _ => self.fold_hidden.push((a, b)),
538            }
539        }
540        // Both lists are sorted: merge them in one pass.
541        let filter: &[(usize, usize)] = self.filter.as_deref().unwrap_or(&[]);
542        let (mut i, mut j) = (0, 0);
543        let mut merged: Vec<(usize, usize)> =
544            Vec::with_capacity(self.fold_hidden.len() + filter.len());
545        while i < self.fold_hidden.len() || j < filter.len() {
546            let take_fold = j >= filter.len()
547                || (i < self.fold_hidden.len() && self.fold_hidden[i].0 <= filter[j].0);
548            let (a, b) = if take_fold {
549                i += 1;
550                self.fold_hidden[i - 1]
551            } else {
552                j += 1;
553                filter[j - 1]
554            };
555            match merged.last_mut() {
556                Some(last) if a <= last.1 + 1 => last.1 = last.1.max(b),
557                _ => merged.push((a, b)),
558            }
559        }
560        self.hidden.clear();
561        let mut hidden_so_far = 0;
562        for (a, b) in merged {
563            self.hidden.push((a, b, hidden_so_far));
564            hidden_so_far += b + 1 - a;
565        }
566    }
567}
568
569/// `ranges` after one edit to the lines: lines `e.start..=e.old_end` became
570/// `e.start..=e.new_end`, which are shown; later lines move.
571fn map_filter_ranges(ranges: &[(usize, usize)], e: LineEdit) -> Vec<(usize, usize)> {
572    let mut out = Vec::with_capacity(ranges.len() + 1);
573    if e.old_end == usize::MAX {
574        // Anything from `start` on may have changed: show it all.
575        for &(a, b) in ranges {
576            if a < e.start {
577                out.push((a, b.min(e.start - 1)));
578            }
579        }
580        return out;
581    }
582    let shift = |l: usize| (l as isize + e.new_end as isize - e.old_end as isize) as usize;
583    for &(a, b) in ranges {
584        if b < e.start {
585            out.push((a, b));
586        } else if a > e.old_end {
587            out.push((shift(a), shift(b)));
588        } else {
589            if a < e.start {
590                out.push((a, e.start - 1));
591            }
592            if b > e.old_end {
593                out.push((shift(e.old_end + 1), shift(b)));
594            }
595        }
596    }
597    out
598}