Skip to main content

strop_syntax/
guides.rs

1//! Indent scopes, not a tab-stop lattice. Equal-indent text without an opener
2//! has no rail; jumps introduce only the actual parent column. Work is performed
3//! against a frozen rope by the analysis worker, never during row rendering.
4use imbl::Vector;
5use ropey::Rope;
6use strop_core::id::DisplayColumn;
7
8struct Run {
9    first: usize,
10    end: usize,
11    columns: Vector<DisplayColumn>,
12}
13#[derive(Default)]
14pub struct IndentGuides {
15    runs: Vec<Run>,
16    prefixes: Vec<usize>,
17}
18#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
19pub struct GuideFrame {
20    pub first_line: usize,
21    pub rows: Vec<Vec<DisplayColumn>>,
22}
23impl GuideFrame {
24    pub fn columns(&self, line: usize) -> &[DisplayColumn] {
25        line.checked_sub(self.first_line)
26            .and_then(|line| self.rows.get(line))
27            .map_or(&[], Vec::as_slice)
28    }
29}
30impl IndentGuides {
31    pub fn build(rope: &Rope, tab: usize, cancelled: impl Fn() -> bool) -> Option<Self> {
32        let mut runs: Vec<Run> = Vec::new();
33        let mut prefixes = Vec::with_capacity(rope.len_lines());
34        let mut scopes = Vector::new();
35        let mut previous = None;
36        let mut blank_start = None;
37        let tab = tab.max(1);
38        for (row, line) in rope.lines().enumerate() {
39            if row % 128 == 0 && cancelled() {
40                return None;
41            }
42            let mut indent = 0usize;
43            let mut content = false;
44            let mut prefix = 0;
45            for ch in line.chars() {
46                if prefix % 2048 == 0 && cancelled() {
47                    return None;
48                }
49                match ch {
50                    ' ' => indent = indent.saturating_add(1),
51                    '\t' => indent = indent.saturating_add(tab - indent % tab),
52                    '\n' | '\r' => break,
53                    _ => {
54                        content = true;
55                        break;
56                    }
57                }
58                prefix += ch.len_utf8();
59            }
60            prefixes.push(prefix);
61            if !content {
62                blank_start.get_or_insert(row);
63                continue;
64            }
65            while scopes
66                .back()
67                .is_some_and(|column: &DisplayColumn| column.get() >= indent)
68            {
69                scopes.pop_back();
70            }
71            if let Some(parent) = previous.filter(|parent| *parent < indent) {
72                let column = DisplayColumn::new(parent);
73                if scopes.back() != Some(&column) {
74                    scopes.push_back(column);
75                }
76            }
77            previous = Some(indent);
78            let first = blank_start.take().unwrap_or(row);
79            if let Some(last) = runs.last_mut().filter(|last| last.columns == scopes) {
80                last.end = row + 1;
81            } else {
82                runs.push(Run {
83                    first,
84                    end: row + 1,
85                    columns: scopes.clone(),
86                });
87            }
88        }
89        if let Some(first) = blank_start {
90            runs.push(Run {
91                first,
92                end: rope.len_lines(),
93                columns: Vector::new(),
94            });
95        }
96        (!cancelled()).then_some(Self { runs, prefixes })
97    }
98    /// A same-line edit strictly after the first content byte cannot change
99    /// nesting. Most ordinary typing reuses the complete guide index.
100    pub fn unaffected_by(&self, edit: &strop_core::InputEdit) -> bool {
101        edit.start_point.0 == edit.old_end_point.0
102            && edit.start_point.0 == edit.new_end_point.0
103            && self
104                .prefixes
105                .get(edit.start_point.0)
106                .is_some_and(|prefix| edit.start_point.1 > *prefix)
107    }
108    pub fn frame(&self, first: usize, end: usize, left: usize, right: usize) -> GuideFrame {
109        let mut result = GuideFrame {
110            first_line: first,
111            rows: Vec::with_capacity(end.saturating_sub(first)),
112        };
113        let mut run = self.runs.partition_point(|run| run.end <= first);
114        for line in first..end {
115            while self.runs.get(run).is_some_and(|run| run.end <= line) {
116                run += 1;
117            }
118            result.rows.push(
119                self.runs
120                    .get(run)
121                    .filter(|run| run.first <= line)
122                    .map_or_else(Vec::new, |run| {
123                        run.columns
124                            .iter()
125                            .copied()
126                            .filter(|column| column.get() >= left && column.get() < right)
127                            .collect()
128                    }),
129            );
130        }
131        result
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    #[test]
139    fn alignment_does_not_invent_intermediate_rails() {
140        let rope = Rope::from_str("    flat\n    aligned\n            deeper\n\n    back\n");
141        let index = IndentGuides::build(&rope, 4, || false).unwrap();
142        let frame = index.frame(0, 5, 0, 40);
143        assert_eq!(
144            frame.rows,
145            vec![vec![], vec![], vec![DisplayColumn::new(4)], vec![], vec![]]
146        );
147    }
148    #[test]
149    fn nested_tabs_and_dedents_use_display_columns() {
150        let rope = Rope::from_str("root\n\tchild\n\t\tinner\n\tpeer\nend\n");
151        let frame = IndentGuides::build(&rope, 4, || false)
152            .unwrap()
153            .frame(0, 5, 0, 8);
154        assert_eq!(
155            frame.rows,
156            vec![
157                vec![],
158                vec![DisplayColumn::new(0)],
159                vec![DisplayColumn::new(0), DisplayColumn::new(4)],
160                vec![DisplayColumn::new(0)],
161                vec![]
162            ]
163        );
164    }
165    #[test]
166    fn blank_rows_follow_the_surrounding_scope_not_the_previous_indent() {
167        let rope = Rope::from_str("root\n\n    child\n        nested\n\n    peer\n\nend\n");
168        let frame = IndentGuides::build(&rope, 4, || false)
169            .unwrap()
170            .frame(0, 8, 0, 20);
171        assert_eq!(frame.columns(1), &[DisplayColumn::new(0)]);
172        assert_eq!(frame.columns(4), &[DisplayColumn::new(0)]);
173        assert!(frame.columns(6).is_empty());
174    }
175}