Skip to main content

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}