Skip to main content

lattice_core/
indent_blocks.rs

1//! Indent blocks — the shared answer to "what block is this line in".
2//!
3//! [`IndentUnit`](crate::indent::IndentUnit) answers *how wide is one level*.
4//! This module answers *where do the levels begin and end*, and it has two
5//! consumers that must not disagree:
6//!
7//! - **Indentation guides** — a guide is a block's column drawn down the
8//!   block's extent (`docs/dev/architecture/indent-guides.md`).
9//! - **`foldmethod=indent`** — a fold is a block's extent
10//!   (`lattice-host/src/folds.rs`).
11//!
12//! They are two views of one question, and a user who folds a block and sees a
13//! different extent than the one that was just highlighted has been told two
14//! different things by the same editor. So the walk lives here, once, and both
15//! call it.
16//!
17//! Nothing here reads config or touches a rope: [`line_indents`] projects lines
18//! to [`LineIndent`], and [`indent_blocks`] is a pure function of that
19//! projection. That is what lets the subtle cases — blank runs, closer
20//! inclusion, multi-level jumps, tabs — be argued against a unit test rather
21//! than against a screenshot.
22
23use crate::indent::IndentUnit;
24
25/// One line's contribution to the block walk.
26///
27/// The two fields travel together because the walk needs both at the same
28/// moment: `depth` decides whether a block closes here, and `closer` decides
29/// whether the closing line is swallowed into the block it closes. Passing two
30/// parallel slices instead would let a caller hand over mismatched lengths.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub struct LineIndent {
33    /// Leading whitespace in display columns, or `None` when the line is
34    /// blank. Blank lines are *transparent* to the walk — they neither break a
35    /// block nor extend one past its last content line.
36    pub depth: Option<u16>,
37    /// The line is nothing but closing brackets. See [`is_closer_line`].
38    pub closer: bool,
39}
40
41/// A contiguous region of indented lines: everything between an opener and
42/// its closer.
43///
44/// The structural unit. `foldmethod=indent` folds one of these; indentation
45/// guides draw one rule per grid column inside one of these
46/// ([`IndentBlock`]). Keeping them separate is what lets both consumers share
47/// the walk without either bending to the other's shape — a fold does not
48/// want two entries for a double-indent jump, and a guide does.
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub struct IndentRegion {
51    /// Display column of the line that opens the region.
52    pub opener_depth: u16,
53    /// Display column of its first deeper line.
54    pub body_depth: u16,
55    /// Opener line, inclusive.
56    pub start_line: u32,
57    /// Closer line, inclusive.
58    pub end_line: u32,
59}
60
61/// One indent guide: a display column, and the inclusive line range it spans.
62///
63/// `start_line` is the region's **opener** and `end_line` its **closer**, both
64/// included. Neither is necessarily painted — [`IndentBlock::paints_on`] tests
65/// that — but both belong to the range because the *active* block under a
66/// cursor sitting on `if c {` is the block that line opens, and the block under
67/// a cursor on the matching `}` is the one it closes. One range serves the
68/// extent question and the membership question.
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70pub struct IndentBlock {
71    /// Display column the guide occupies.
72    pub col: u16,
73    /// Opener line, inclusive.
74    pub start_line: u32,
75    /// Closer line, inclusive.
76    pub end_line: u32,
77}
78
79impl IndentBlock {
80    /// Whether this block's guide is drawn on `line`.
81    ///
82    /// The single predicate the whole feature rests on:
83    ///
84    /// > in range, **and** (the line is blank **or** the guide's column is
85    /// > strictly left of where the line's text starts).
86    ///
87    /// The second arm is what keeps a guide out of a cell that holds text — on
88    /// `fn f() {` the column-0 guide fails `0 < 0`, and on the matching `}` it
89    /// fails for the same reason. The first arm is what carries a guide through
90    /// a blank line inside a block.
91    ///
92    /// Because the producer applies this before publishing, **a published guide
93    /// mark always lands on a blank cell**, and no renderer needs a
94    /// don't-overwrite-text guard.
95    #[inline]
96    pub fn paints_on(&self, line: u32, line_depth: Option<u16>) -> bool {
97        if line < self.start_line || line > self.end_line {
98            return false;
99        }
100        match line_depth {
101            None => true,
102            Some(depth) => self.col < depth,
103        }
104    }
105
106    /// Whether `line` is inside this block at all, painted or not. The
107    /// membership test the active-block pick uses.
108    #[inline]
109    pub fn contains(&self, line: u32) -> bool {
110        self.start_line <= line && line <= self.end_line
111    }
112}
113
114/// Upper bound on emitted regions.
115///
116/// Mirrors the `MAX_FOLDS` this replaced. The walk itself is linear, so this
117/// is not a complexity guard — it bounds *memory* on a pathological file (one
118/// whose indentation increases on every line produces one region per line).
119const MAX_REGIONS: usize = 5000;
120
121/// Upper bound on grid columns emitted for a single indent jump.
122///
123/// An opener at column 0 followed by a body at column 60 000 would otherwise
124/// emit thousands of guides from one line pair. Sixty-four levels is deeper
125/// than any code a guide would help with; past that the guides are the problem.
126const MAX_LEVELS_PER_REGION: u16 = 64;
127
128/// Project lines to the walk's input.
129///
130/// Depth is measured in **display columns** (`IndentUnit::columns_of`, so a tab
131/// advances to the next `tabstop`), not in leading whitespace characters. A
132/// tab-indented file and its space-indented twin must produce identical blocks;
133/// counting characters is what made them differ.
134pub fn line_indents<'a>(
135    lines: impl Iterator<Item = &'a str>,
136    unit: &IndentUnit,
137) -> Vec<LineIndent> {
138    lines
139        .map(|line| LineIndent {
140            depth: if IndentUnit::is_blank(line) {
141                None
142            } else {
143                Some(unit.columns_of(line))
144            },
145            closer: is_closer_line(line),
146        })
147        .collect()
148}
149
150/// Whether `line` is a pure closing-bracket line.
151///
152/// Most brace languages dedent the closing delimiter back to the parent's
153/// indent (`}`, `};`, `})`, `})?;`), which leaves the closer *outside* the
154/// block its own body belongs to. Swallowing it keeps a fold's summary line
155/// ending on the brace instead of orphaning it, and keeps the cursor "inside"
156/// the block while it sits on the closer.
157///
158/// The heuristic is deliberately narrow: bracket characters plus the
159/// punctuation that trails them. `} else {` is not a closer — it opens a new
160/// block, and swallowing it would merge two sibling blocks into one.
161pub fn is_closer_line(line: &str) -> bool {
162    let trimmed = line.trim();
163    if trimmed.is_empty() {
164        return false;
165    }
166    trimmed
167        .chars()
168        .all(|c| matches!(c, ')' | ']' | '}' | ',' | ';' | '?'))
169}
170
171/// The only three facts the indent-guide builder needs about a line,
172/// in a `Copy` struct so it can be produced without materialising the
173/// line's text.
174///
175/// Replaces three separate whole-line predicates
176/// ([`IndentUnit::is_blank`], [`IndentUnit::columns_of`],
177/// [`is_closer_line`]) that each took `&str` — which forced the caller
178/// to allocate a `String` per line. On the keystroke path that was one
179/// allocation per *covered* line per keystroke, and coverage is the
180/// whole document below `WINDOW_CAP_LINES`.
181#[derive(Clone, Copy, Debug, PartialEq, Eq)]
182pub struct LineShape {
183    /// Every character is ' ', '\t' or '\r' — [`IndentUnit::is_blank`].
184    pub blank: bool,
185    /// Display columns of leading whitespace — [`IndentUnit::columns_of`].
186    pub columns: u16,
187    /// Non-blank and every non-whitespace character closes something —
188    /// [`is_closer_line`].
189    pub closer: bool,
190    /// Non-blank and starting hard against column 0. The
191    /// `scan_back_to_top_level` test; equivalent to
192    /// `!blank && columns == 0`, precomputed for clarity at the call
193    /// site.
194    pub unindented: bool,
195}
196
197impl LineShape {
198    /// Stream `chars` (one line, trailing newline optional) into a
199    /// shape. Allocation-free, and **breaks early**: once the line is
200    /// known non-blank and known not-all-closers, no later character
201    /// can change any field, which is the common case after one or two
202    /// content characters.
203    ///
204    /// Deliberately mirrors the three predicates byte-for-byte rather
205    /// than approximating them:
206    /// - indent scanning stops at the first char that is not ' ' or
207    ///   '\t', matching `columns_of`'s `break`;
208    /// - "blank" tests membership of `{' ', '\t', '\r'}` rather than
209    ///   Unicode whitespace, matching `is_blank` (so U+00A0 is content);
210    /// - closer-ness ignores whitespace and tests the same six
211    ///   characters `is_closer_line` does.
212    pub fn from_chars<I: Iterator<Item = char>>(chars: I, unit: &IndentUnit) -> Self {
213        let tab = unit.tab_width().max(1);
214        let mut columns: u16 = 0;
215        let mut in_indent = true;
216        let mut saw_content = false;
217        let mut all_closers = true;
218        for c in chars {
219            if c == '\n' {
220                break;
221            }
222            if in_indent {
223                match c {
224                    ' ' => {
225                        columns = columns.saturating_add(1);
226                        continue;
227                    }
228                    '\t' => {
229                        columns = (columns / tab).saturating_add(1).saturating_mul(tab);
230                        continue;
231                    }
232                    _ => in_indent = false,
233                }
234            }
235            if c == ' ' || c == '\t' || c == '\r' {
236                continue;
237            }
238            saw_content = true;
239            if !matches!(c, ')' | ']' | '}' | ',' | ';' | '?') {
240                all_closers = false;
241                // Nothing later can change `blank`, `closer` or
242                // `columns` now — stop reading the line.
243                break;
244            }
245        }
246        Self {
247            blank: !saw_content,
248            columns,
249            closer: saw_content && all_closers,
250            unindented: saw_content && columns == 0,
251        }
252    }
253
254    /// Convenience for callers that already hold the text (tests, and
255    /// any path where the line was materialised for another reason).
256    pub fn from_line(line: &str, unit: &IndentUnit) -> Self {
257        Self::from_chars(line.chars(), unit)
258    }
259}
260
261/// Walk `lines` and emit every indent region, ordered by opener line.
262///
263/// A region opens at line `p` when the next non-blank line is strictly deeper
264/// than `p`, and closes at the first non-blank line no deeper than `p` — with
265/// that line swallowed when it is a [closer](is_closer_line) at exactly `p`'s
266/// depth. Blank lines are transparent throughout: they neither break a region
267/// nor extend one past its last content line.
268///
269/// Linear in `lines.len()`: a stack of open regions, popped when a line closes
270/// them. The direct transcription of the "walk forward to find the end"
271/// formulation is quadratic on deeply nested files, and this runs on every
272/// publish.
273pub fn indent_regions(lines: &[LineIndent]) -> Vec<IndentRegion> {
274    let mut regions: Vec<IndentRegion> = Vec::new();
275    // (opener line, opener depth, body depth) for each region still open.
276    let mut open: Vec<(usize, u16, u16)> = Vec::new();
277    // Last non-blank line seen, and its depth. A region that closes at line
278    // `k` ends at this line, because every line between it and `k` was blank
279    // or deeper.
280    let mut prev: Option<(usize, u16)> = None;
281
282    for (k, line) in lines.iter().enumerate() {
283        let Some(depth) = line.depth else {
284            continue; // blank lines are transparent
285        };
286
287        while let Some(&(opener, opener_depth, body_depth)) = open.last() {
288            if depth > opener_depth {
289                break;
290            }
291            open.pop();
292            // `k - 1` would be wrong — the line before `k` may be blank, and
293            // a region does not extend into the trailing blank run that
294            // follows its last content line.
295            let mut end = prev.map(|(line, _)| line).unwrap_or(opener);
296            if depth == opener_depth && line.closer {
297                end = k;
298            }
299            regions.push(IndentRegion {
300                opener_depth,
301                body_depth,
302                start_line: opener as u32,
303                end_line: end as u32,
304            });
305            if regions.len() >= MAX_REGIONS {
306                regions.sort_by_key(|r| r.start_line);
307                return regions;
308            }
309        }
310
311        if let Some((prev_line, prev_depth)) = prev
312            && depth > prev_depth
313        {
314            open.push((prev_line, prev_depth, depth));
315        }
316        prev = Some((k, depth));
317    }
318
319    // End of input closes whatever is still open, at the last content line.
320    let end = prev.map(|(line, _)| line).unwrap_or(0);
321    while let Some((opener, opener_depth, body_depth)) = open.pop() {
322        regions.push(IndentRegion {
323            opener_depth,
324            body_depth,
325            start_line: opener as u32,
326            end_line: end as u32,
327        });
328        if regions.len() >= MAX_REGIONS {
329            break;
330        }
331    }
332
333    regions.sort_by_key(|r| r.start_line);
334    regions
335}
336
337/// Expand every region into one guide per grid column it spans.
338///
339/// An indent jump of more than one level (opener at column 0, body at column
340/// 8, `step` 4) emits a guide at each intervening column over the same line
341/// range: there is no structure between those levels to give them different
342/// extents. Columns are laid on a grid anchored at the **opener's** column
343/// rather than at 0, so continuation-line indentation (an opener at column 7)
344/// still produces guides that line up with the code.
345pub fn indent_blocks(lines: &[LineIndent], step: u16) -> Vec<IndentBlock> {
346    let step = step.max(1);
347    let mut blocks: Vec<IndentBlock> = Vec::new();
348    for region in indent_regions(lines) {
349        let mut col = region.opener_depth;
350        let mut levels = 0u16;
351        while col < region.body_depth && levels < MAX_LEVELS_PER_REGION {
352            blocks.push(IndentBlock {
353                col,
354                start_line: region.start_line,
355                end_line: region.end_line,
356            });
357            col = col.saturating_add(step);
358            levels += 1;
359        }
360    }
361    blocks.sort_by_key(|b| (b.start_line, b.col));
362    blocks
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    fn unit(width: u8, tabstop: u8) -> IndentUnit {
370        IndentUnit::new(width, true, tabstop)
371    }
372
373    /// Build blocks from source text laid out as it would appear on screen.
374    fn blocks_of(src: &str, width: u8, tabstop: u8) -> Vec<IndentBlock> {
375        let unit = unit(width, tabstop);
376        let lines = line_indents(src.split('\n'), &unit);
377        indent_blocks(&lines, unit.step())
378    }
379
380    /// The columns actually painted on each line — the feature's observable
381    /// output, and what every behavioural test below asserts against.
382    fn painted(src: &str, width: u8, tabstop: u8) -> Vec<Vec<u16>> {
383        let unit = unit(width, tabstop);
384        let lines = line_indents(src.split('\n'), &unit);
385        let blocks = indent_blocks(&lines, unit.step());
386        lines
387            .iter()
388            .enumerate()
389            .map(|(i, line)| {
390                let mut cols: Vec<u16> = blocks
391                    .iter()
392                    .filter(|b| b.paints_on(i as u32, line.depth))
393                    .map(|b| b.col)
394                    .collect();
395                cols.sort_unstable();
396                cols.dedup();
397                cols
398            })
399            .collect()
400    }
401
402    #[test]
403    fn flat_file_has_no_blocks() {
404        assert!(blocks_of("a\nb\nc", 4, 4).is_empty());
405    }
406
407    #[test]
408    fn empty_and_blank_inputs_are_empty() {
409        assert!(blocks_of("", 4, 4).is_empty());
410        assert!(blocks_of("\n\n\n", 4, 4).is_empty());
411        assert!(indent_blocks(&[], 4).is_empty());
412    }
413
414    #[test]
415    fn single_block_spans_opener_through_closer() {
416        let b = blocks_of("fn f() {\n    body\n}", 4, 4);
417        assert_eq!(
418            b,
419            vec![IndentBlock {
420                col: 0,
421                start_line: 0,
422                end_line: 2
423            }],
424            "closer at the opener's depth is swallowed"
425        );
426    }
427
428    #[test]
429    fn guide_never_lands_on_a_column_holding_text() {
430        // The design fragment's worked picture, asserted column by column.
431        let src = "fn f() {\n\n    if c {\n        work();\n\n    }\n}\n\nfn g() {";
432        assert_eq!(
433            painted(src, 4, 4),
434            vec![
435                vec![],     // fn f() {      — column 0 holds `f`
436                vec![0],    // (blank)       — inside the outer block
437                vec![0],    //     if c {    — column 4 holds `i`
438                vec![0, 4], //         work();
439                vec![0, 4], // (blank)       — inside both blocks
440                vec![0],    //     }         — column 4 holds `}`
441                vec![],     // }             — column 0 holds `}`
442                vec![],     // (blank)       — between blocks
443                vec![],     // fn g() {
444            ]
445        );
446    }
447
448    #[test]
449    fn blank_between_blocks_carries_nothing() {
450        let src = "fn f() {\n    a\n}\n\nfn g() {\n    b\n}";
451        let p = painted(src, 4, 4);
452        assert_eq!(p[3], Vec::<u16>::new(), "blank between two blocks");
453        assert_eq!(p[1], vec![0]);
454        assert_eq!(p[5], vec![0]);
455    }
456
457    #[test]
458    fn trailing_blank_run_does_not_extend_a_block() {
459        // No closer: the block ends at its last content line, and the blank
460        // run after it belongs to nobody.
461        let src = "if x:\n    body\n\n\ny = 1";
462        let p = painted(src, 4, 4);
463        assert_eq!(p[1], vec![0]);
464        assert_eq!(p[2], Vec::<u16>::new());
465        assert_eq!(p[3], Vec::<u16>::new());
466    }
467
468    #[test]
469    fn interior_blanks_keep_the_guide_without_a_closer() {
470        // Python: the block ends at its last deeper line, but blanks *inside*
471        // it stay covered.
472        let src = "def f():\n    a\n\n    b\n\ndef g():";
473        let p = painted(src, 4, 4);
474        assert_eq!(p[2], vec![0], "blank between two body lines");
475        assert_eq!(p[4], Vec::<u16>::new(), "blank after the last body line");
476    }
477
478    #[test]
479    fn closer_inclusion_requires_a_pure_bracket_line() {
480        assert!(is_closer_line("}"));
481        assert!(is_closer_line("  });"));
482        assert!(is_closer_line("})?;"));
483        assert!(!is_closer_line("} else {"));
484        assert!(!is_closer_line(""));
485        assert!(!is_closer_line("   "));
486
487        // `} else {` closes one block and opens another; swallowing it would
488        // merge the two into one guide run.
489        let src = "if a {\n    x\n} else {\n    y\n}";
490        let b = blocks_of(src, 4, 4);
491        assert_eq!(
492            b,
493            vec![
494                IndentBlock {
495                    col: 0,
496                    start_line: 0,
497                    end_line: 1
498                },
499                IndentBlock {
500                    col: 0,
501                    start_line: 2,
502                    end_line: 4
503                },
504            ]
505        );
506    }
507
508    #[test]
509    fn nested_blocks_close_independently() {
510        let src = "a:\n  b:\n    c\n  d\ne";
511        let b = blocks_of(src, 2, 2);
512        assert_eq!(
513            b,
514            vec![
515                IndentBlock {
516                    col: 0,
517                    start_line: 0,
518                    end_line: 3
519                },
520                IndentBlock {
521                    col: 2,
522                    start_line: 1,
523                    end_line: 2
524                },
525            ]
526        );
527    }
528
529    #[test]
530    fn multi_level_jump_emits_a_guide_per_grid_column() {
531        // Opener at 0, body at 8, shiftwidth 4 — two levels, one range.
532        let src = "fn f() {\n        deep();\n}";
533        let b = blocks_of(src, 4, 4);
534        assert_eq!(b.len(), 2);
535        assert_eq!(b[0].col, 0);
536        assert_eq!(b[1].col, 4);
537        assert_eq!((b[0].start_line, b[0].end_line), (0, 2));
538        assert_eq!((b[1].start_line, b[1].end_line), (0, 2));
539        // Both paint on the body line; neither paints on the opener or closer.
540        assert_eq!(painted(src, 4, 4)[1], vec![0, 4]);
541    }
542
543    #[test]
544    fn tabs_and_spaces_produce_identical_blocks() {
545        let tabbed = "fn f() {\n\tif c {\n\t\twork();\n\t}\n}";
546        let spaced = "fn f() {\n    if c {\n        work();\n    }\n}";
547        assert_eq!(
548            blocks_of(tabbed, 4, 4),
549            blocks_of(spaced, 4, 4),
550            "a tab at tabstop=4 is four columns, not one character"
551        );
552    }
553
554    #[test]
555    fn tabstop_changes_the_grid_a_tab_lands_on() {
556        let tabbed = "fn f() {\n\tbody\n}";
557        // At tabstop=8 the body sits at column 8, so shiftwidth=4 puts two
558        // guides under it rather than one.
559        assert_eq!(blocks_of(tabbed, 4, 8).len(), 2);
560        assert_eq!(blocks_of(tabbed, 4, 4).len(), 1);
561    }
562
563    #[test]
564    fn step_not_dividing_the_indent_still_anchors_on_the_opener() {
565        // Opener at 0, body at 6, step 4 → guides at 0 and 4, both < 6.
566        let src = "x:\n      deep\ny";
567        let b = blocks_of(src, 4, 4);
568        assert_eq!(b.iter().map(|b| b.col).collect::<Vec<_>>(), vec![0, 4]);
569    }
570
571    #[test]
572    fn continuation_indent_anchors_the_grid_on_the_opener_column() {
573        // A block opened by an already-indented line puts its guide at that
574        // line's column, not at a multiple of `step` from zero.
575        let src = "   odd:\n       body\n   done";
576        let b = blocks_of(src, 4, 4);
577        assert_eq!(b.iter().map(|b| b.col).collect::<Vec<_>>(), vec![3]);
578    }
579
580    #[test]
581    fn zero_step_is_clamped_rather_than_looping() {
582        let lines = line_indents("a:\n    b\nc".split('\n'), &unit(4, 4));
583        let b = indent_blocks(&lines, 0);
584        assert_eq!(b.len(), 4, "step clamped to 1 → guides at 0,1,2,3");
585    }
586
587    #[test]
588    fn unterminated_block_ends_at_the_last_content_line() {
589        let src = "fn f() {\n    body";
590        assert_eq!(
591            blocks_of(src, 4, 4),
592            vec![IndentBlock {
593                col: 0,
594                start_line: 0,
595                end_line: 1
596            }]
597        );
598    }
599
600    #[test]
601    fn regions_and_blocks_agree_on_extents() {
602        // The two views of one walk: a region is what a fold takes, a block is
603        // what a guide takes. A single-level region yields exactly one block
604        // over the same range; a double-level one yields two.
605        let unit = unit(4, 4);
606        let lines = line_indents("fn f() {\n    a\n}".split('\n'), &unit);
607        let regions = indent_regions(&lines);
608        let blocks = indent_blocks(&lines, unit.step());
609        assert_eq!(regions.len(), 1);
610        assert_eq!(blocks.len(), 1);
611        assert_eq!(
612            (regions[0].start_line, regions[0].end_line),
613            (blocks[0].start_line, blocks[0].end_line)
614        );
615
616        let lines = line_indents("fn f() {\n        a\n}".split('\n'), &unit);
617        assert_eq!(indent_regions(&lines).len(), 1, "one region for a fold");
618        assert_eq!(indent_blocks(&lines, 4).len(), 2, "two guides for the grid");
619    }
620
621    #[test]
622    fn block_count_is_capped() {
623        // Every line deeper than the last: one block opens per line and none
624        // close until EOF.
625        let src: String = (0..8000)
626            .map(|i| format!("{}x", " ".repeat(i)))
627            .collect::<Vec<_>>()
628            .join("\n");
629        assert!(blocks_of(&src, 1, 1).len() <= MAX_REGIONS);
630    }
631
632    #[test]
633    fn single_jump_level_count_is_capped() {
634        let src = format!("x:\n{}deep\ny", " ".repeat(10_000));
635        assert_eq!(blocks_of(&src, 1, 1).len(), MAX_LEVELS_PER_REGION as usize);
636    }
637
638    #[test]
639    fn contains_covers_opener_and_closer() {
640        let b = IndentBlock {
641            col: 0,
642            start_line: 2,
643            end_line: 6,
644        };
645        assert!(b.contains(2));
646        assert!(b.contains(6));
647        assert!(!b.contains(1));
648        assert!(!b.contains(7));
649        // Membership is wider than painting: the opener is in the block but
650        // its column holds text.
651        assert!(!b.paints_on(2, Some(0)));
652        assert!(b.paints_on(3, Some(4)));
653    }
654}