Skip to main content

lattice_host/
indent_guides.rs

1//! Indentation guides — the per-pane layer both renderers paint from.
2//!
3//! See `docs/dev/architecture/indent-guides.md` (design) and
4//! `docs/dev/operations/slice-plans/indent-guides.md` (slices).
5//!
6//! ## What this is
7//!
8//! [`IndentGuides`] is the resolved answer to "which columns carry a guide on
9//! which rows, and which block is the cursor in". It is built by
10//! `cells_worker` in the pass that builds the pane's
11//! [`DisplayMatrix`](crate::display_matrix::DisplayMatrix), from the same
12//! snapshot and stamped with the same [`MatrixVersion`] — so the two cannot
13//! disagree and guides need no staleness axis of their own.
14//!
15//! ## Why the paint predicate is resolved here
16//!
17//! [`IndentBlock::paints_on`] decides whether a guide may occupy a column, and
18//! getting it wrong means painting over text. Applying it in the producer
19//! rather than in each renderer means there is exactly one implementation: a
20//! bug surfaces as a failing test here rather than as corrupted text in one
21//! peer and not the other. What each renderer still owns is the *mechanism* —
22//! the TUI substitutes a glyph into a cell, the GPU peer paints a hairline
23//! quad — because a terminal cell cannot hold a one-pixel rule.
24//!
25//! ## Why blocks are published alongside the per-row marks
26//!
27//! The *active* guide is the innermost block containing the cursor, and the
28//! cursor moves at keystroke rate. Publishing extents rather than a
29//! precomputed "is active" flag lets each renderer pick the active block
30//! per frame from the cursor row it already holds — an integer scan over the
31//! blocks in the window — so cursor motion costs the worker nothing and the
32//! highlight has zero lag.
33
34use std::sync::Arc;
35
36use lattice_cells::MatrixVersion;
37use lattice_core::indent::IndentUnit;
38use lattice_core::indent_blocks::{IndentBlock, LineIndent, indent_blocks};
39
40/// One guide occupying one column of one row.
41///
42/// `block` indexes [`IndentGuides::blocks`], which is what lets a renderer
43/// style the active guide differently without a second lookup.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub struct GuideMark {
46    /// Display column the guide occupies.
47    pub col: u16,
48    /// Index into [`IndentGuides::blocks`].
49    pub block: u16,
50}
51
52/// How far above the covered window the block walk starts looking for the
53/// blocks that enclose it.
54///
55/// A block cannot span a non-blank line at column 0, so starting the walk at
56/// the nearest such line above the window is *exact*, not approximate — it
57/// finds every block that reaches into the window. The cap bounds the scan on
58/// a file that is indented for thousands of consecutive lines (minified JSON,
59/// generated code); past it the outermost guides may be missing from a
60/// scrolled-into window, which is a missing hairline rather than a wrong one.
61const MAX_LOOKBACK: u32 = 2000;
62
63/// The per-buffer inputs a guide layer is built from, resolved once per
64/// publish and carried on [`crate::render_state::PaneCellsInputs`].
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub struct IndentGuideInputs {
67    /// The buffer's resolved indent level. `step()` is the guide spacing;
68    /// `tab_width()` is what makes a tab-indented line's depth comparable
69    /// to a space-indented one's.
70    pub unit: IndentUnit,
71    /// `display.indent-guides`.
72    pub enabled: bool,
73    /// The `MatrixVersion::indent` stamp these inputs produce.
74    pub version: u64,
75}
76
77/// Hash the inputs that change the guides' geometry.
78///
79/// Only `shiftwidth` and `enabled`: `expandtab` never affects an existing
80/// line's rendered indentation, and `tabstop` already bumps the `whitespace`
81/// axis, which invalidates the same matrix. Carrying an input on two axes is
82/// carrying an input that can drift.
83pub fn indent_axis_version(unit: &IndentUnit, enabled: bool) -> u64 {
84    use std::hash::{Hash, Hasher};
85    let mut h = std::collections::hash_map::DefaultHasher::new();
86    unit.step().hash(&mut h);
87    enabled.hash(&mut h);
88    h.finish()
89}
90
91/// The per-pane guide layer.
92#[derive(Clone, Debug)]
93pub struct IndentGuides {
94    /// Every block reaching into the covered window, ordered by opener then
95    /// column. Retained alongside [`Self::rows`] because the active-block pick
96    /// needs extents, not just painted columns.
97    pub blocks: Arc<[IndentBlock]>,
98    /// Painted marks per covered source line: `rows[line - covered_start]`.
99    /// Already filtered through [`IndentBlock::paints_on`], so every mark
100    /// lands on a blank cell.
101    pub rows: Arc<[Arc<[GuideMark]>]>,
102    /// Source line `rows[0]` describes.
103    pub covered_start: u32,
104    /// The stamp of the display matrix built alongside this layer.
105    pub version: MatrixVersion,
106}
107
108impl Default for IndentGuides {
109    fn default() -> Self {
110        Self::empty()
111    }
112}
113
114impl IndentGuides {
115    pub fn empty() -> Self {
116        Self {
117            blocks: Arc::from([] as [IndentBlock; 0]),
118            rows: Arc::from([] as [Arc<[GuideMark]>; 0]),
119            covered_start: 0,
120            version: MatrixVersion::ZERO,
121        }
122    }
123
124    pub fn is_empty(&self) -> bool {
125        self.rows.is_empty()
126    }
127
128    /// Marks painted on `source_line`, or `&[]` when the line is outside the
129    /// covered window. Outside-the-window is the normal answer during a
130    /// scroll that has outrun the worker, not an error.
131    pub fn marks_for_line(&self, source_line: u32) -> &[GuideMark] {
132        let Some(idx) = source_line.checked_sub(self.covered_start) else {
133            return &[];
134        };
135        self.rows.get(idx as usize).map(|r| &r[..]).unwrap_or(&[])
136    }
137
138    /// Index of the innermost block containing `cursor_line`, or `None` when
139    /// the cursor is not inside any block (top-level code).
140    ///
141    /// Innermost is the greatest column: nesting deeper always means a guide
142    /// further right, whatever the indent widths involved.
143    pub fn active_block(&self, cursor_line: u32) -> Option<u16> {
144        self.blocks
145            .iter()
146            .enumerate()
147            .filter(|(_, b)| b.contains(cursor_line))
148            .max_by_key(|(_, b)| b.col)
149            .map(|(i, _)| i as u16)
150    }
151}
152
153/// Build the guide layer for the source lines `[lo, hi)`.
154///
155/// `shapes_from(i)` yields source line `i` and every line after it; the caller
156/// supplies it so this stays testable without a rope and so the worker can
157/// reuse whatever line access it already holds. `hi` is clamped by the caller
158/// to the buffer's content line count.
159///
160/// **Why a stream and not `line(i)`.** The walk covers `[walk_start, hi)`,
161/// which below `cells_worker`'s window cap is the whole document, and it runs
162/// on every publish — every keystroke. Reading that range one index at a time
163/// costs one `O(log n)` rope descent per line; reading it from a single stream
164/// costs one descent plus a linear walk. The only random access left is the
165/// look-back probe, which is bounded by [`MAX_LOOKBACK`] and in practice stops
166/// within a few lines.
167///
168/// The walk starts above `lo` (see [`MAX_LOOKBACK`]) so a block opened off the
169/// top of the window still paints inside it. It does *not* extend below `hi`:
170/// a block still open at the end of the walk is closed at the last content
171/// line, which is at or below the last visible row, so painting and the
172/// cursor-membership test are both unaffected.
173pub fn build_indent_guides<F, I>(
174    shapes_from: F,
175    line_count: u32,
176    unit: &IndentUnit,
177    lo: u32,
178    hi: u32,
179    version: MatrixVersion,
180) -> IndentGuides
181where
182    F: Fn(u32) -> I,
183    I: Iterator<Item = lattice_core::LineShape>,
184{
185    let hi = hi.min(line_count);
186    let lo = lo.min(hi);
187    if lo == hi {
188        return IndentGuides::empty();
189    }
190
191    let walk_start = scan_back_to_top_level(&shapes_from, lo);
192    // A stream that runs dry before `hi` reads as blank lines — the same
193    // answer the per-index read gave for a line past the end.
194    let indents: Vec<LineIndent> = shapes_from(walk_start)
195        .chain(std::iter::repeat(lattice_core::LineShape {
196            blank: true,
197            columns: 0,
198            closer: false,
199            unindented: false,
200        }))
201        .take((hi - walk_start) as usize)
202        .map(|shape| LineIndent {
203            depth: (!shape.blank).then_some(shape.columns),
204            closer: shape.closer,
205        })
206        .collect();
207
208    let blocks: Vec<IndentBlock> = indent_blocks(&indents, unit.step())
209        .into_iter()
210        .map(|b| IndentBlock {
211            col: b.col,
212            start_line: b.start_line + walk_start,
213            end_line: b.end_line + walk_start,
214        })
215        .collect();
216
217    // One shared empty row, cloned by refcount for every line that
218    // paints no guide. This is the common case by a wide margin — every
219    // top-level line, every blank line, every line in an unnested file —
220    // and the layer is rebuilt over its whole covered range on each
221    // keystroke, so allocating a fresh `Arc<[GuideMark]>` per line was
222    // thousands of allocations per keystroke to represent nothing.
223    let empty_row: Arc<[GuideMark]> = Arc::from([] as [GuideMark; 0]);
224    let mut marks: Vec<GuideMark> = Vec::new();
225    // Sweep, not scan. Testing every block on every row is
226    // `O(covered lines × blocks)`, and both factors grow with the file:
227    // a 3 000-line source has ~800 blocks, so the naive form is ~2.4 M
228    // predicate calls per keystroke and its per-line cost rises with
229    // file size — the shape a keystroke path must never have.
230    //
231    // `blocks` is sorted by opener, so one forward cursor admits each
232    // block exactly once and `active` holds only those still open. Its
233    // length is the *nesting depth* at this row, single digits in real
234    // code, which is what makes the pass linear in covered lines.
235    //
236    // `active` inherits `blocks`' `(start_line, col)` order and both
237    // `push` and `retain` preserve it, so a row's marks come out in the
238    // same order the scan produced — outermost guide first.
239    let mut active: Vec<usize> = Vec::new();
240    let mut admitted = 0usize;
241    let rows: Vec<Arc<[GuideMark]>> = (lo..hi)
242        .map(|line| {
243            while let Some(b) = blocks.get(admitted).filter(|b| b.start_line <= line) {
244                // A block that both opened and closed above the window
245                // is admitted and dropped in the same step.
246                if b.end_line >= line {
247                    active.push(admitted);
248                }
249                admitted += 1;
250            }
251            active.retain(|&i| blocks[i].end_line >= line);
252
253            let depth = indents[(line - walk_start) as usize].depth;
254            // Reused across iterations: `Arc::from(&marks[..])` copies
255            // out, so the buffer's capacity survives to the next line
256            // instead of being reallocated per row.
257            marks.clear();
258            marks.extend(
259                active
260                    .iter()
261                    .map(|&i| (i, blocks[i]))
262                    // Still the shared predicate rather than an inlined
263                    // depth test: `paints_on` is what guarantees a mark
264                    // never lands on a column holding text, and one
265                    // implementation of that is worth more than the
266                    // redundant range check it re-does here.
267                    .filter(|(_, b)| b.paints_on(line, depth))
268                    .map(|(i, b)| GuideMark {
269                        col: b.col,
270                        block: i as u16,
271                    }),
272            );
273            if marks.is_empty() {
274                Arc::clone(&empty_row)
275            } else {
276                Arc::from(&marks[..])
277            }
278        })
279        .collect();
280
281    IndentGuides {
282        blocks: Arc::from(blocks.into_boxed_slice()),
283        rows: Arc::from(rows.into_boxed_slice()),
284        covered_start: lo,
285        version,
286    }
287}
288
289/// Nearest non-blank line at column 0 at or above `lo`, bounded by
290/// [`MAX_LOOKBACK`]. No block can span such a line, so the walk starting there
291/// sees every block that reaches into the window.
292///
293/// This is the one caller that reads lines out of order, and it pays a fresh
294/// descent per probe (`shapes_from(i).next()`). That is the right trade here
295/// and the wrong one for the forward walk: the search runs *backward* and
296/// almost always stops within a handful of lines, so a stream would be built
297/// and thrown away, whereas the forward walk reads thousands of lines in order.
298fn scan_back_to_top_level<F, I>(shapes_from: &F, lo: u32) -> u32
299where
300    F: Fn(u32) -> I,
301    I: Iterator<Item = lattice_core::LineShape>,
302{
303    let floor = lo.saturating_sub(MAX_LOOKBACK);
304    let mut i = lo;
305    while i > floor {
306        i -= 1;
307        if shapes_from(i).next().is_some_and(|s| s.unindented) {
308            return i;
309        }
310    }
311    floor
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    fn unit() -> IndentUnit {
319        IndentUnit::new(4, true, 4)
320    }
321
322    /// A `shapes_from` over a line vector: the same contract the rope's
323    /// `Buffer::line_shapes_from` fulfils, without the rope.
324    fn shapes_of(lines: &[String]) -> impl Fn(u32) -> std::vec::IntoIter<lattice_core::LineShape> {
325        let shapes: Vec<lattice_core::LineShape> = lines
326            .iter()
327            .map(|l| lattice_core::LineShape::from_line(l, &unit()))
328            .collect();
329        move |start: u32| {
330            shapes
331                .get((start as usize).min(shapes.len())..)
332                .unwrap_or(&[])
333                .to_vec()
334                .into_iter()
335        }
336    }
337
338    fn build(src: &str) -> IndentGuides {
339        let lines: Vec<String> = src.split('\n').map(|s| s.to_string()).collect();
340        let n = lines.len() as u32;
341        build_indent_guides(shapes_of(&lines), n, &unit(), 0, n, MatrixVersion::ZERO)
342    }
343
344    fn cols(g: &IndentGuides, line: u32) -> Vec<u16> {
345        g.marks_for_line(line).iter().map(|m| m.col).collect()
346    }
347
348    const NESTED: &str = "fn f() {\n\n    if c {\n        work();\n\n    }\n}\n\nfn g() {";
349
350    #[test]
351    fn marks_match_the_paint_predicate() {
352        let g = build(NESTED);
353        assert_eq!(cols(&g, 0), Vec::<u16>::new(), "opener column holds text");
354        assert_eq!(cols(&g, 1), vec![0], "blank inside the outer block");
355        assert_eq!(cols(&g, 2), vec![0]);
356        assert_eq!(cols(&g, 3), vec![0, 4]);
357        assert_eq!(cols(&g, 4), vec![0, 4], "blank inside both blocks");
358        assert_eq!(cols(&g, 5), vec![0], "inner closer column holds a brace");
359        assert_eq!(cols(&g, 6), Vec::<u16>::new());
360        assert_eq!(cols(&g, 7), Vec::<u16>::new(), "between blocks");
361    }
362
363    #[test]
364    fn active_block_is_the_innermost_containing_the_cursor() {
365        let g = build(NESTED);
366        let col_of = |line: u32| g.active_block(line).map(|i| g.blocks[i as usize].col);
367        assert_eq!(col_of(3), Some(4), "inside the inner block");
368        assert_eq!(col_of(2), Some(4), "on the inner block's opener");
369        assert_eq!(col_of(5), Some(4), "on the inner block's closer");
370        assert_eq!(col_of(1), Some(0), "outer block only");
371        assert_eq!(col_of(6), Some(0), "on the outer closer");
372        assert_eq!(col_of(7), None, "between blocks");
373        assert_eq!(col_of(8), None, "top level");
374    }
375
376    #[test]
377    fn every_mark_lands_on_a_blank_column() {
378        // The invariant both renderers rely on: no mark may occupy a column
379        // that holds a character.
380        let src = "fn f() {\n    if c {\n        work();\n    }\n}\n\tmixed\n";
381        let lines: Vec<&str> = src.split('\n').collect();
382        let g = build(src);
383        for (i, line) in lines.iter().enumerate() {
384            let chars: Vec<char> = line.chars().collect();
385            for mark in g.marks_for_line(i as u32) {
386                let at = chars.get(mark.col as usize).copied();
387                assert!(
388                    matches!(at, None | Some(' ') | Some('\t')),
389                    "line {i} col {} holds {at:?}",
390                    mark.col
391                );
392            }
393        }
394    }
395
396    #[test]
397    fn windowed_build_indexes_from_covered_start() {
398        let src = "fn f() {\n    a\n    b\n    c\n    d\n}";
399        let lines: Vec<String> = src.split('\n').map(|s| s.to_string()).collect();
400        let n = lines.len() as u32;
401        let g = build_indent_guides(shapes_of(&lines), n, &unit(), 2, 5, MatrixVersion::ZERO);
402        assert_eq!(g.covered_start, 2);
403        assert_eq!(g.rows.len(), 3);
404        // The block opened at line 0 — above the window — still paints inside
405        // it, which is the whole point of the look-back.
406        assert_eq!(cols(&g, 2), vec![0]);
407        assert_eq!(cols(&g, 4), vec![0]);
408        assert_eq!(cols(&g, 1), Vec::<u16>::new(), "below covered_start");
409        assert_eq!(cols(&g, 9), Vec::<u16>::new(), "past the window");
410    }
411
412    #[test]
413    fn look_back_stops_at_the_nearest_top_level_line() {
414        // Two sibling blocks; a window inside the second must not inherit the
415        // first one's extent.
416        let src = "fn f() {\n    a\n}\nfn g() {\n    b\n}";
417        let lines: Vec<String> = src.split('\n').map(|s| s.to_string()).collect();
418        let g = build_indent_guides(shapes_of(&lines), 6, &unit(), 4, 6, MatrixVersion::ZERO);
419        assert_eq!(cols(&g, 4), vec![0]);
420        assert_eq!(g.blocks.len(), 1, "only the enclosing block is walked");
421        assert_eq!(g.blocks[0].start_line, 3);
422    }
423
424    #[test]
425    fn empty_window_yields_the_empty_layer() {
426        let g = build_indent_guides(
427            |_| std::iter::empty::<lattice_core::LineShape>(),
428            0,
429            &unit(),
430            0,
431            0,
432            MatrixVersion::ZERO,
433        );
434        assert!(g.is_empty());
435        assert_eq!(g.marks_for_line(0), &[]);
436        assert_eq!(g.active_block(0), None);
437    }
438
439    #[test]
440    fn the_forward_walk_opens_exactly_one_stream() {
441        // The perf property, pinned as behaviour. Every `shapes_from`
442        // call is a rope descent, so the covered range must be read from
443        // ONE stream — a build that opens one per line is `O(n log n)`
444        // where a walk is `O(n)`, and it regresses silently because the
445        // output is identical. Only the bounded look-back may probe.
446        let lines: Vec<String> = (0..500)
447            .map(|i| {
448                if i % 5 == 0 {
449                    format!("fn f{i}() {{")
450                } else {
451                    "    body".into()
452                }
453            })
454            .collect();
455        let inner = shapes_of(&lines);
456        let opened = std::cell::Cell::new(0u32);
457        let counting = |start: u32| {
458            opened.set(opened.get() + 1);
459            inner(start)
460        };
461
462        // `lo` sits one line below a top-level line, so the look-back
463        // stops on its first probe and the count is unambiguous.
464        let g = build_indent_guides(counting, 500, &unit(), 401, 500, MatrixVersion::ZERO);
465        assert_eq!(g.rows.len(), 99);
466        assert_eq!(
467            opened.get(),
468            2,
469            "one stream for the walk plus a look-back that stops within a \
470             line or two, got {} streams",
471            opened.get()
472        );
473    }
474
475    #[test]
476    fn the_row_sweep_agrees_with_testing_every_block() {
477        // The sweep's whole justification is that it visits fewer blocks
478        // per row while producing the same rows — including their ORDER,
479        // which renderers see. So the reference implementation is the
480        // definition: apply `paints_on` to every published block, in
481        // published order, on every covered line.
482        //
483        // The corpus nests three deep, closes siblings at every level and
484        // carries blank lines inside blocks, because those are the rows
485        // where "still open" and "opens here" disagree.
486        let mut src: Vec<String> = Vec::new();
487        for i in 0..40 {
488            src.push(format!("fn f{i}() {{"));
489            src.push("    let mut n = 0;".into());
490            src.push(String::new());
491            src.push("    if n > 0 {".into());
492            src.push("        while n > 0 {".into());
493            src.push("            n -= 1;".into());
494            src.push(String::new());
495            src.push("        }".into());
496            src.push("    }".into());
497            src.push("}".into());
498            src.push(String::new());
499        }
500        let n = src.len() as u32;
501
502        for (lo, hi) in [(0, n), (0, 25), (137, n), (150, 260), (n - 1, n)] {
503            let g = build_indent_guides(shapes_of(&src), n, &unit(), lo, hi, MatrixVersion::ZERO);
504            let shapes = shapes_of(&src);
505            for line in lo..hi {
506                let depth = shapes(line)
507                    .next()
508                    .and_then(|s| (!s.blank).then_some(s.columns));
509                let expected: Vec<GuideMark> = g
510                    .blocks
511                    .iter()
512                    .enumerate()
513                    .filter(|(_, b)| b.paints_on(line, depth))
514                    .map(|(i, b)| GuideMark {
515                        col: b.col,
516                        block: i as u16,
517                    })
518                    .collect();
519                assert_eq!(
520                    g.marks_for_line(line),
521                    &expected[..],
522                    "window {lo}..{hi}, line {line}"
523                );
524            }
525        }
526    }
527
528    #[test]
529    fn tab_indented_file_matches_its_space_indented_twin() {
530        let tabbed = build("fn f() {\n\tif c {\n\t\twork();\n\t}\n}");
531        let spaced = build("fn f() {\n    if c {\n        work();\n    }\n}");
532        for line in 0..5 {
533            assert_eq!(cols(&tabbed, line), cols(&spaced, line), "line {line}");
534        }
535    }
536
537    #[test]
538    fn version_is_carried_through_unchanged() {
539        let v = MatrixVersion {
540            text: 7,
541            indent: 3,
542            ..MatrixVersion::ZERO
543        };
544        let lines: Vec<String> = vec!["a:".into(), "    b".into()];
545        let g = build_indent_guides(shapes_of(&lines), 2, &unit(), 0, 2, v);
546        assert_eq!(g.version, v);
547    }
548}