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}