lattice_core/indent.rs
1//! Indentation units and methods.
2//!
3//! [`IndentUnit`] is the resolved answer to "what is one level of
4//! indent, in this buffer, right now" — `shiftwidth` + `expandtab` +
5//! `tabstop` collapsed into one `Copy` value. It lives here rather than
6//! in `lattice-indent` (the tree-sitter engine, IN.2) because the `>` /
7//! `<` operators in `lattice-grammar` consume it, and
8//! `lattice-syntax` → `lattice-grammar` means an engine-side home would
9//! be a dependency cycle. This crate is the shared floor both sides
10//! already stand on, and it is where the sibling [`crate::FoldMethod`]
11//! lives for the same reason.
12//!
13//! Nothing here reads config. The host resolves the options (including
14//! any buffer-local `:setlocal` override) and hands the resulting value
15//! down; the grammar layer stays config-agnostic.
16//!
17//! See `docs/dev/architecture/auto-indent.md` §3.
18
19crate::labeled_enum! {
20 /// `:set indentmethod=...`. Which source decides where a newly
21 /// created line starts.
22 ///
23 /// A cascade with a named floor, in the shape of
24 /// [`crate::FoldMethod`] — and for the same reason, which is a
25 /// property rather than a resemblance: both name a *structural*
26 /// source that can be unavailable at the moment it is asked (no
27 /// query for this language, a parse that has not landed, a grammar
28 /// that failed to load). Degrading to documented vim behaviour
29 /// beats a silent wrong answer, and it gives a user whose language
30 /// has a bad query a one-word escape hatch.
31 ///
32 /// `Keep` is not merely `Syntax`'s fallback — it is a setting some
33 /// users prefer outright, so the fallback path is exercised by
34 /// choice rather than only by failure.
35 pub enum IndentMethod {
36 /// New lines start at column 0.
37 None = "none"
38 => "New lines start at column 0",
39 /// Copy the previous non-blank line's indent (vim's
40 /// `autoindent`).
41 Keep = "keep"
42 => "Copy the previous line's indent (vim autoindent)",
43 /// Tree-sitter `indents.scm`, falling back to `Keep` when the
44 /// language has no query or the parse is unavailable.
45 #[default]
46 Syntax = "syntax"
47 => "Indent from the tree-sitter syntax tree",
48 }
49}
50
51/// One level of indentation, resolved for a specific buffer.
52///
53/// `width` is `shiftwidth` — **columns per indent level**. `tabstop` is
54/// the display width of a literal tab byte. They are deliberately
55/// separate: conflating them means "change my indent size" silently
56/// reflows every file containing a hard tab, including content the user
57/// never edited.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct IndentUnit {
60 /// `shiftwidth` — columns added or removed per indent level.
61 /// Clamped to at least 1 at use; a zero-width level would make
62 /// `>` a no-op and `columns → levels` a division by zero.
63 pub width: u8,
64 /// `expandtab` — render indentation as spaces rather than tabs.
65 pub expand_tabs: bool,
66 /// `tabstop` — display columns a literal tab advances to.
67 /// Clamped to at least 1 at use.
68 pub tabstop: u8,
69}
70
71impl Default for IndentUnit {
72 /// Matches the registered option defaults (`shiftwidth=4`,
73 /// `expandtab`, `tabstop=4`) so a context that never resolved
74 /// config behaves like an unconfigured buffer rather than like
75 /// something arbitrary.
76 fn default() -> Self {
77 Self {
78 width: 4,
79 expand_tabs: true,
80 tabstop: 4,
81 }
82 }
83}
84
85impl IndentUnit {
86 /// Build a unit from raw option values: `shiftwidth`, `expandtab`,
87 /// `tabstop`. Zero widths are stored as given and floored to 1 by
88 /// [`Self::step`] / [`Self::tab_width`].
89 pub fn new(width: u8, expand_tabs: bool, tabstop: u8) -> Self {
90 Self {
91 width,
92 expand_tabs,
93 tabstop,
94 }
95 }
96
97 /// `shiftwidth`, floored at 1. See [`Self::width`].
98 #[inline]
99 pub fn step(&self) -> u16 {
100 self.width.max(1) as u16
101 }
102
103 /// `tabstop`, floored at 1. See [`Self::tabstop`].
104 #[inline]
105 pub fn tab_width(&self) -> u16 {
106 self.tabstop.max(1) as u16
107 }
108
109 /// Byte length of `line`'s leading whitespace run.
110 ///
111 /// Whitespace here is space and tab only — not `\r`, and not
112 /// Unicode space separators. A line that is entirely whitespace
113 /// returns its full length.
114 pub fn indent_len(line: &str) -> usize {
115 line.bytes()
116 .take_while(|b| *b == b' ' || *b == b'\t')
117 .count()
118 }
119
120 /// Display columns occupied by `line`'s leading whitespace.
121 ///
122 /// A tab advances to the next multiple of `tabstop`, which is why
123 /// this cannot be a byte count: `"\t"` and `" "` are the same
124 /// indent at `tabstop=4` and must compare equal.
125 pub fn columns_of(&self, line: &str) -> u16 {
126 let tab = self.tab_width();
127 let mut col: u16 = 0;
128 for b in line.bytes() {
129 match b {
130 b' ' => col = col.saturating_add(1),
131 b'\t' => col = (col / tab).saturating_add(1).saturating_mul(tab),
132 _ => break,
133 }
134 }
135 col
136 }
137
138 /// The whitespace string that renders as `columns` display columns.
139 ///
140 /// With `expandtab` off this is tabs plus a space remainder, which
141 /// is what vim produces and what keeps `shiftwidth` values that are
142 /// not multiples of `tabstop` representable.
143 pub fn render(&self, columns: u16) -> String {
144 if columns == 0 {
145 return String::new();
146 }
147 if self.expand_tabs {
148 return " ".repeat(columns as usize);
149 }
150 let tab = self.tab_width();
151 let tabs = (columns / tab) as usize;
152 let spaces = (columns % tab) as usize;
153 let mut out = String::with_capacity(tabs + spaces);
154 out.extend(std::iter::repeat_n('\t', tabs));
155 out.extend(std::iter::repeat_n(' ', spaces));
156 out
157 }
158
159 /// `columns` shifted by `levels` indent steps, clamped at zero.
160 pub fn shift(&self, columns: u16, levels: i32) -> u16 {
161 let delta = self.step() as i32 * levels;
162 let shifted = columns as i32 + delta;
163 shifted.clamp(0, u16::MAX as i32) as u16
164 }
165
166 /// Whether `line` has no non-whitespace content.
167 ///
168 /// Blank lines are skipped by `>` / `<`: vim does not indent them,
169 /// and doing so leaves trailing whitespace on lines the user never
170 /// typed into.
171 pub fn is_blank(line: &str) -> bool {
172 line.bytes().all(|b| b == b' ' || b == b'\t' || b == b'\r')
173 }
174
175 /// The replacement leading-whitespace for `line` shifted by
176 /// `levels`, or `None` when nothing should change.
177 ///
178 /// `None` covers both "blank line" and "the rendered result is
179 /// byte-identical to what is already there" — the second matters
180 /// because emitting a no-op edit still costs an undo entry and a
181 /// render invalidation.
182 pub fn reindented_prefix(&self, line: &str, levels: i32) -> Option<(usize, String)> {
183 if Self::is_blank(line) {
184 return None;
185 }
186 let len = Self::indent_len(line);
187 let target = self.shift(self.columns_of(line), levels);
188 let rendered = self.render(target);
189 if rendered.as_bytes() == &line.as_bytes()[..len] {
190 return None;
191 }
192 Some((len, rendered))
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 fn spaces4() -> IndentUnit {
201 IndentUnit::new(4, true, 4)
202 }
203 fn tabs4() -> IndentUnit {
204 IndentUnit::new(4, false, 4)
205 }
206
207 #[test]
208 fn columns_counts_spaces() {
209 assert_eq!(spaces4().columns_of(" hi"), 2);
210 assert_eq!(spaces4().columns_of("hi"), 0);
211 }
212
213 #[test]
214 fn a_tab_advances_to_the_next_tabstop_not_by_one() {
215 // The whole reason indent is measured in columns rather than
216 // bytes: at tabstop=4 these are the same indent.
217 assert_eq!(spaces4().columns_of("\thi"), 4);
218 assert_eq!(spaces4().columns_of(" hi"), 4);
219 // A partial run then a tab still lands on the stop.
220 assert_eq!(spaces4().columns_of(" \thi"), 4);
221 assert_eq!(spaces4().columns_of("\t\thi"), 8);
222 }
223
224 #[test]
225 fn tabstop_is_independent_of_shiftwidth() {
226 // shiftwidth=2, tabstop=8: a tab is still 8 columns wide.
227 let u = IndentUnit::new(2, true, 8);
228 assert_eq!(u.columns_of("\thi"), 8);
229 assert_eq!(u.step(), 2);
230 }
231
232 #[test]
233 fn render_expands_or_tabs_per_expandtab() {
234 assert_eq!(spaces4().render(6), " ");
235 // 6 columns at tabstop 4 = one tab + two spaces.
236 assert_eq!(tabs4().render(6), "\t ");
237 assert_eq!(tabs4().render(8), "\t\t");
238 assert_eq!(tabs4().render(0), "");
239 }
240
241 #[test]
242 fn render_round_trips_through_columns_of() {
243 for cols in 0u16..40 {
244 for u in [spaces4(), tabs4(), IndentUnit::new(3, false, 8)] {
245 let s = u.render(cols);
246 assert_eq!(u.columns_of(&s), cols, "unit {u:?} cols {cols}");
247 }
248 }
249 }
250
251 #[test]
252 fn shift_clamps_at_zero() {
253 assert_eq!(spaces4().shift(2, -1), 0);
254 assert_eq!(spaces4().shift(0, -3), 0);
255 assert_eq!(spaces4().shift(4, 1), 8);
256 }
257
258 #[test]
259 fn zero_width_is_treated_as_one_not_as_a_divide_by_zero() {
260 let u = IndentUnit::new(0, true, 0);
261 assert_eq!(u.step(), 1);
262 assert_eq!(u.tab_width(), 1);
263 assert_eq!(u.columns_of("\t\t"), 2);
264 }
265
266 #[test]
267 fn blank_lines_are_left_alone() {
268 assert!(IndentUnit::is_blank(""));
269 assert!(IndentUnit::is_blank(" "));
270 assert!(IndentUnit::is_blank("\t \t"));
271 assert!(!IndentUnit::is_blank(" x"));
272 assert_eq!(spaces4().reindented_prefix(" ", 1), None);
273 assert_eq!(spaces4().reindented_prefix("", 1), None);
274 }
275
276 #[test]
277 fn a_no_op_reindent_produces_no_edit() {
278 // Already at column 0 and dedenting: nothing to write.
279 assert_eq!(spaces4().reindented_prefix("hi", -1), None);
280 // Already rendered exactly as the unit would render it.
281 assert_eq!(spaces4().reindented_prefix(" hi", 0), None);
282 }
283
284 #[test]
285 fn reindent_replaces_the_whole_prefix_normalising_style() {
286 // Tab-indented line, expandtab on: the prefix is rewritten as
287 // spaces, which is what vim's >> does.
288 let (len, s) = spaces4().reindented_prefix("\thi", 1).unwrap();
289 assert_eq!(len, 1);
290 assert_eq!(s, " ");
291
292 // Space-indented line, expandtab off: rewritten as tabs.
293 let (len, s) = tabs4().reindented_prefix(" hi", 1).unwrap();
294 assert_eq!(len, 4);
295 assert_eq!(s, "\t\t");
296 }
297
298 #[test]
299 fn indent_method_parses_its_labels() {
300 assert_eq!(IndentMethod::parse_label("keep"), Ok(IndentMethod::Keep));
301 assert_eq!(
302 IndentMethod::parse_label("syntax"),
303 Ok(IndentMethod::Syntax)
304 );
305 assert!(IndentMethod::parse_label("bogus").is_err());
306 assert_eq!(IndentMethod::default(), IndentMethod::Syntax);
307 }
308}