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
15use std::ops::Range;
16
17use serde::{Deserialize, Serialize};
18
19use crate::changeset::{Bias, ChangeSet};
20use crate::position::{ByteOffset, byte_to_visual_col};
21use crate::text::Document;
22
23/// How a buffer's fold regions are found. Chosen by the app from the
24/// buffer's language.
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
26pub enum FoldStrategy {
27    #[default]
28    Indent,
29    /// Headings fold to the next heading of the same or a higher level.
30    Markdown,
31}
32
33/// Blank lines skipped when looking for the line after a header; past this
34/// a line does not count as a header (keeps the gutter cheap).
35const MAX_BLANK_RUN: usize = 1000;
36/// Lines looked back through to tell whether a `#` line is inside a fenced
37/// code block. A fence opened further up is missed (documented).
38const FENCE_LOOKBACK: usize = 200;
39
40/// The indentation width of `line` in cells, or `None` for a blank line.
41fn indent(doc: &Document, line: usize, tab_width: usize) -> Option<usize> {
42    let text = doc.line_text(line);
43    let rest = text.trim_start_matches([' ', '\t']);
44    if rest.is_empty() {
45        return None;
46    }
47    let lead = text.len() - rest.len();
48    Some(byte_to_visual_col(doc, ByteOffset(doc.line_start(line).0 + lead), tab_width).0)
49}
50
51/// The first non-blank line after `line` and its indent, looking at most
52/// `limit` lines ahead.
53fn next_non_blank(
54    doc: &Document,
55    line: usize,
56    tab_width: usize,
57    limit: usize,
58) -> Option<(usize, usize)> {
59    (line + 1..doc.line_count().min((line + 1).saturating_add(limit)))
60        .find_map(|l| indent(doc, l, tab_width).map(|w| (l, w)))
61}
62
63/// A Markdown ATX heading's level (1–6).
64fn heading_level(text: &str) -> Option<usize> {
65    let t = text.strip_prefix("   ").or_else(|| text.strip_prefix("  "));
66    let t = t.or_else(|| text.strip_prefix(' ')).unwrap_or(text);
67    let hashes = t.len() - t.trim_start_matches('#').len();
68    let after = &t[hashes..];
69    ((1..=6).contains(&hashes) && (after.is_empty() || after.starts_with([' ', '\t'])))
70        .then_some(hashes)
71}
72
73fn is_fence(text: &str) -> bool {
74    let t = text.trim_start_matches(' ');
75    text.len() - t.len() <= 3 && (t.starts_with("```") || t.starts_with("~~~"))
76}
77
78/// Whether `line` is inside a fenced code block, judged from the fences in
79/// the [`FENCE_LOOKBACK`] lines above it.
80fn in_fence(doc: &Document, line: usize) -> bool {
81    let fences = (line.saturating_sub(FENCE_LOOKBACK)..line)
82        .filter(|&l| is_fence(&doc.line_text(l)))
83        .count();
84    fences % 2 == 1
85}
86
87/// The heading level of `line` if it is a heading outside a code fence.
88fn markdown_heading(doc: &Document, line: usize) -> Option<usize> {
89    heading_level(&doc.line_text(line)).filter(|_| !in_fence(doc, line))
90}
91
92/// The last line `header` folds, or `None` if it is not a fold header. The
93/// region is `header + 1 ..= last`.
94pub fn region(
95    doc: &Document,
96    header: usize,
97    strategy: FoldStrategy,
98    tab_width: usize,
99) -> Option<usize> {
100    if header >= doc.line_count() {
101        return None;
102    }
103    let mut last = header;
104    match strategy {
105        FoldStrategy::Indent => {
106            let w = indent(doc, header, tab_width)?;
107            for l in header + 1..doc.line_count() {
108                match indent(doc, l, tab_width) {
109                    None => {}
110                    Some(i) if i <= w => break,
111                    Some(_) => last = l,
112                }
113            }
114        }
115        FoldStrategy::Markdown => {
116            let level = markdown_heading(doc, header)?;
117            let mut fenced = false;
118            for l in header + 1..doc.line_count() {
119                let text = doc.line_text(l);
120                if is_fence(&text) {
121                    fenced = !fenced;
122                } else if !fenced && heading_level(&text).is_some_and(|h| h <= level) {
123                    break;
124                }
125                if fenced || !text.trim().is_empty() {
126                    last = l;
127                }
128            }
129        }
130    }
131    (last > header).then_some(last)
132}
133
134/// Whether `line` starts a fold region. Cheaper than [`region`]: it only
135/// looks at the next non-blank line.
136pub fn is_header(doc: &Document, line: usize, strategy: FoldStrategy, tab_width: usize) -> bool {
137    if line >= doc.line_count() {
138        return false;
139    }
140    match strategy {
141        FoldStrategy::Indent => indent(doc, line, tab_width).is_some_and(|w| {
142            next_non_blank(doc, line, tab_width, MAX_BLANK_RUN).is_some_and(|(_, n)| n > w)
143        }),
144        FoldStrategy::Markdown => {
145            let Some(level) = heading_level(&doc.line_text(line)) else {
146                return false;
147            };
148            // Folds unless the next non-blank line is a heading that ends it.
149            let next = next_non_blank(doc, line, tab_width, MAX_BLANK_RUN);
150            next.is_some_and(|(l, _)| {
151                let text = doc.line_text(l);
152                is_fence(&text) || heading_level(&text).is_none_or(|h| h > level)
153            }) && !in_fence(doc, line)
154        }
155    }
156}
157
158/// A fold region: its header line, its level (the nesting depth for
159/// indentation, 1 being outermost; the heading level for Markdown) and its
160/// last line.
161#[derive(Clone, Copy, Debug, PartialEq, Eq)]
162pub struct Region {
163    pub header: usize,
164    pub level: usize,
165    pub last: usize,
166}
167
168/// Every fold region, in header order. Scans the whole document once.
169pub fn regions(doc: &Document, strategy: FoldStrategy, tab_width: usize) -> Vec<Region> {
170    let mut out = Vec::new();
171    match strategy {
172        FoldStrategy::Indent => {
173            // Regions still open: (indent, index into `out`).
174            let mut open: Vec<(usize, usize)> = Vec::new();
175            let mut last_non_blank = 0;
176            let mut prev: Option<(usize, usize)> = None;
177            let close = |open: &mut Vec<(usize, usize)>, out: &mut Vec<Region>, w, last| {
178                while let Some(&(o, i)) = open.last() {
179                    if o < w {
180                        break;
181                    }
182                    out[i].last = last;
183                    open.pop();
184                }
185            };
186            for l in 0..doc.line_count() {
187                let Some(w) = indent(doc, l, tab_width) else {
188                    continue;
189                };
190                // The previous non-blank line heads a region if this one is
191                // indented deeper.
192                if let Some((pl, pw)) = prev
193                    && w > pw
194                {
195                    out.push(Region {
196                        header: pl,
197                        level: open.len() + 1,
198                        last: pl,
199                    });
200                    open.push((pw, out.len() - 1));
201                }
202                close(&mut open, &mut out, w, last_non_blank);
203                last_non_blank = l;
204                prev = Some((l, w));
205            }
206            close(&mut open, &mut out, 0, last_non_blank);
207        }
208        FoldStrategy::Markdown => {
209            let mut fenced = false;
210            for l in 0..doc.line_count() {
211                let text = doc.line_text(l);
212                if is_fence(&text) {
213                    fenced = !fenced;
214                } else if !fenced
215                    && let Some(level) = heading_level(&text)
216                    && let Some(last) = region(doc, l, strategy, tab_width)
217                {
218                    out.push(Region {
219                        header: l,
220                        level,
221                        last,
222                    });
223                }
224            }
225        }
226    }
227    out
228}
229
230/// The innermost region holding `line`: its header (`line` itself if it is
231/// one) and last line.
232pub fn enclosing(
233    doc: &Document,
234    line: usize,
235    strategy: FoldStrategy,
236    tab_width: usize,
237) -> Option<(usize, usize)> {
238    if let Some(last) = region(doc, line, strategy, tab_width) {
239        return Some((line, last));
240    }
241    match strategy {
242        FoldStrategy::Indent => {
243            // A blank line belongs with the line after it.
244            let w = indent(doc, line, tab_width)
245                .or_else(|| next_non_blank(doc, line, tab_width, usize::MAX).map(|(_, w)| w))?;
246            let header = (0..line)
247                .rev()
248                .find(|&l| indent(doc, l, tab_width).is_some_and(|i| i < w))?;
249            let last = region(doc, header, strategy, tab_width)?;
250            (last >= line).then_some((header, last))
251        }
252        FoldStrategy::Markdown => (0..line).rev().find_map(|l| {
253            let last = region(doc, l, strategy, tab_width)?;
254            (last >= line).then_some((l, last))
255        }),
256    }
257}
258
259#[derive(Clone, Debug, PartialEq, Eq)]
260struct Fold {
261    /// The hidden text: from the end of the header line (its line break) to
262    /// the end of the last hidden line.
263    range: Range<ByteOffset>,
264    header: usize,
265    last: usize,
266}
267
268/// The folded regions of one view, possibly nested.
269#[derive(Clone, Debug, Default, PartialEq, Eq)]
270pub struct FoldSet {
271    /// Sorted by start; any two are nested or disjoint.
272    folds: Vec<Fold>,
273    /// The hidden lines: the union of the folds' regions as sorted, disjoint
274    /// inclusive ranges, with the hidden lines before each.
275    hidden: Vec<(usize, usize, usize)>,
276    /// Mapped through an edit since the line numbers were computed.
277    stale: bool,
278}
279
280impl FoldSet {
281    pub fn is_empty(&self) -> bool {
282        self.folds.is_empty()
283    }
284
285    /// The folded header lines, in order.
286    pub fn headers(&self) -> impl Iterator<Item = usize> + '_ {
287        self.folds.iter().map(|f| f.header)
288    }
289
290    pub fn is_folded(&self, header: usize) -> bool {
291        self.folds.iter().any(|f| f.header == header)
292    }
293
294    /// The hidden line range (inclusive) holding `line`.
295    pub fn hidden_range(&self, line: usize) -> Option<(usize, usize)> {
296        let i = self.hidden.partition_point(|h| h.1 < line);
297        self.hidden
298            .get(i)
299            .filter(|h| h.0 <= line)
300            .map(|h| (h.0, h.1))
301    }
302
303    pub fn is_hidden(&self, line: usize) -> bool {
304        self.hidden_range(line).is_some()
305    }
306
307    /// Hidden lines in total.
308    pub fn hidden_count(&self) -> usize {
309        self.hidden.last().map_or(0, |h| h.2 + h.1 + 1 - h.0)
310    }
311
312    /// Hidden lines before `line`.
313    pub fn hidden_before(&self, line: usize) -> usize {
314        let i = self.hidden.partition_point(|h| h.0 < line);
315        match i.checked_sub(1).map(|i| self.hidden[i]) {
316            None => 0,
317            Some((start, end, before)) => before + end.min(line - 1) + 1 - start,
318        }
319    }
320
321    /// The line that is the `index`-th visible line (0-based), or the last
322    /// visible one if there are fewer.
323    pub fn visible_line(&self, index: usize, line_count: usize) -> usize {
324        // A hidden range comes after `start - before` visible lines; the
325        // ranges before the wanted line are those it comes after.
326        let i = self.hidden.partition_point(|h| h.0 - h.2 <= index);
327        let line = match i.checked_sub(1).map(|i| self.hidden[i]) {
328            None => index,
329            Some((start, end, before)) => index + before + end + 1 - start,
330        };
331        line.min(line_count.saturating_sub(1))
332    }
333
334    /// Folds each `(header, last)`: lines `header + 1 ..= last`.
335    pub(crate) fn insert(
336        &mut self,
337        doc: &Document,
338        regions: impl IntoIterator<Item = (usize, usize)>,
339    ) {
340        for (header, last) in regions {
341            if last > header && last < doc.line_count() {
342                self.folds.push(Fold {
343                    range: doc.line_end(header)..doc.line_end(last),
344                    header,
345                    last,
346                });
347            }
348        }
349        self.folds.sort_by_key(|f| f.range.start);
350        self.folds.dedup_by_key(|f| f.header);
351        self.rebuild();
352    }
353
354    /// Unfolds the fold at `header`; returns whether there was one.
355    pub(crate) fn remove(&mut self, header: usize) -> bool {
356        let before = self.folds.len();
357        self.folds.retain(|f| f.header != header);
358        self.rebuild();
359        self.folds.len() != before
360    }
361
362    /// Keeps the folds whose (header, last) pass `keep`.
363    pub(crate) fn retain(&mut self, mut keep: impl FnMut(usize, usize) -> bool) {
364        self.folds.retain(|f| keep(f.header, f.last));
365        self.rebuild();
366    }
367
368    /// Maps the folds through an edit. A fold whose hidden text the edit
369    /// touches is dropped; typing at the end of a header line is not inside
370    /// it. Line numbers are stale until [`relines`](Self::relines).
371    pub(crate) fn map(&mut self, changes: &ChangeSet) {
372        self.stale = true;
373        self.folds.retain_mut(|f| {
374            let touched = changes.edits().iter().any(|e| {
375                if e.start == e.end {
376                    f.range.start < e.start && e.start <= f.range.end
377                } else {
378                    e.start <= f.range.end && e.end > f.range.start
379                }
380            });
381            f.range = changes.map_position(f.range.start, Bias::After)
382                ..changes.map_position(f.range.end, Bias::Before);
383            !touched
384        });
385    }
386
387    /// Recomputes line numbers after [`map`](Self::map).
388    pub(crate) fn relines(&mut self, doc: &Document) {
389        if !std::mem::take(&mut self.stale) {
390            return;
391        }
392        for f in &mut self.folds {
393            f.header = doc.line_of(f.range.start);
394            f.last = doc.line_of(f.range.end);
395        }
396        self.folds.retain(|f| f.last > f.header);
397        self.rebuild();
398    }
399
400    fn rebuild(&mut self) {
401        self.hidden.clear();
402        let mut hidden_so_far = 0;
403        let mut merged: Vec<(usize, usize)> = Vec::new();
404        for f in &self.folds {
405            let (a, b) = (f.header + 1, f.last);
406            match merged.last_mut() {
407                Some(last) if a <= last.1 + 1 => last.1 = last.1.max(b),
408                _ => merged.push((a, b)),
409            }
410        }
411        for (a, b) in merged {
412            self.hidden.push((a, b, hidden_so_far));
413            hidden_so_far += b + 1 - a;
414        }
415    }
416}