Skip to main content

ochrepad_core/
indent.rs

1//! Indentation: detecting how a file is indented, and converting its
2//! leading whitespace between tabs and spaces.
3
4use std::collections::HashMap;
5
6use crate::buffer::Indent;
7use crate::changeset::{ChangeSet, Edit};
8use crate::history::EditKind;
9use crate::position::ByteOffset;
10use crate::resolve::Resolved;
11use crate::selection::SelectionSet;
12use crate::text::Document;
13
14/// Lines looked at when detecting.
15const DETECT_LINES: usize = 10_000;
16
17/// What the leading whitespace of a document says.
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19pub struct Detected {
20    /// Spaces or tabs, if any line is indented.
21    pub use_spaces: Option<bool>,
22    /// The indent width, if the space-indented lines show one (2, 4 or 8).
23    pub width: Option<usize>,
24    /// Some lines are indented with tabs and others with (two or more)
25    /// spaces.
26    pub mixed: bool,
27}
28
29impl Detected {
30    /// `base` with what was detected applied.
31    pub fn apply(&self, base: Indent) -> Indent {
32        Indent {
33            use_spaces: self.use_spaces.unwrap_or(base.use_spaces),
34            width: self.width.unwrap_or(base.width),
35        }
36    }
37}
38
39/// Looks at the first lines of `doc`: tabs or spaces, and for spaces the
40/// most common step between indent levels.
41pub fn detect(doc: &Document) -> Detected {
42    let (mut tabs, mut spaces) = (0usize, 0usize);
43    let mut steps: HashMap<usize, usize> = HashMap::new();
44    let mut prev = 0usize;
45    for line in 0..doc.line_count().min(DETECT_LINES) {
46        let text = doc.line_text(line);
47        if text.trim().is_empty() {
48            continue;
49        }
50        let lead: &str = &text[..text.len() - text.trim_start_matches([' ', '\t']).len()];
51        if lead.starts_with('\t') {
52            tabs += 1;
53        } else if lead.len() >= 2 && lead.bytes().all(|b| b == b' ') {
54            // One leading space is usually a comment's ` *`, not indentation.
55            spaces += 1;
56        }
57        if lead.bytes().all(|b| b == b' ') {
58            let n = lead.len();
59            if n > prev {
60                *steps.entry(n - prev).or_default() += 1;
61            }
62            prev = n;
63        }
64    }
65    let use_spaces = (tabs + spaces > 0).then_some(spaces > tabs);
66    let width = (use_spaces == Some(true))
67        .then(|| {
68            [4, 2, 8]
69                .into_iter()
70                .max_by_key(|w| steps.get(w).copied().unwrap_or(0))
71                .filter(|w| steps.get(w).copied().unwrap_or(0) > 0)
72        })
73        .flatten();
74    Detected {
75        use_spaces,
76        width,
77        mixed: tabs > 0 && spaces > 0,
78    }
79}
80
81/// Rewrites every line's leading whitespace as spaces, or as tabs (plus
82/// spaces for a remainder narrower than a tab), keeping its width.
83pub(crate) fn convert(
84    doc: &Document,
85    sels: &SelectionSet,
86    to_spaces: bool,
87    width: usize,
88) -> Resolved {
89    let width = width.max(1);
90    let mut edits = Vec::new();
91    for line in 0..doc.line_count() {
92        let text = doc.line_text(line);
93        let lead_len = text.len() - text.trim_start_matches([' ', '\t']).len();
94        if lead_len == 0 {
95            continue;
96        }
97        let mut cols = 0;
98        for b in text[..lead_len].bytes() {
99            cols = if b == b'\t' {
100                cols + width - cols % width
101            } else {
102                cols + 1
103            };
104        }
105        let new = if to_spaces {
106            " ".repeat(cols)
107        } else {
108            format!("{}{}", "\t".repeat(cols / width), " ".repeat(cols % width))
109        };
110        if new != text[..lead_len] {
111            let start = doc.line_start(line);
112            edits.push(Edit::new(start, ByteOffset(start.0 + lead_len), new));
113        }
114    }
115    let changes = ChangeSet::new(doc.len(), edits);
116    if changes.is_empty() {
117        return Resolved::Selections(sels.clone());
118    }
119    let after = sels.map(|s| crate::selection::Selection {
120        anchor: changes.map_position(s.anchor, crate::changeset::Bias::After),
121        head: changes.map_position(s.head, crate::changeset::Bias::After),
122        goal: None,
123    });
124    Resolved::Edit {
125        changes,
126        after,
127        kind: EditKind::Other,
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    fn detect_text(t: &str) -> Detected {
136        detect(&Document::new(t))
137    }
138
139    #[test]
140    fn detects_spaces_and_their_width() {
141        let py = "def f():\n    if x:\n        y()\n    return 1\n";
142        assert_eq!(
143            detect_text(py),
144            Detected {
145                use_spaces: Some(true),
146                width: Some(4),
147                mixed: false
148            }
149        );
150        let js = "a {\n  b {\n    c;\n  }\n}\n";
151        assert_eq!(detect_text(js).width, Some(2));
152    }
153
154    #[test]
155    fn detects_tabs_and_mixed_files() {
156        let go = "func f() {\n\tif x {\n\t\ty()\n\t}\n\t/*\n\t * doc\n\t */\n}\n";
157        assert_eq!(
158            detect_text(go),
159            Detected {
160                use_spaces: Some(false),
161                width: None,
162                mixed: false
163            }
164        );
165        let mixed = "def f():\n    a()\n\tb()\n";
166        assert!(detect_text(mixed).mixed);
167        assert_eq!(detect_text("x\ny\n"), Detected::default());
168    }
169}