1use 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
14const DETECT_LINES: usize = 10_000;
16
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19pub struct Detected {
20 pub use_spaces: Option<bool>,
22 pub width: Option<usize>,
24 pub mixed: bool,
27}
28
29impl Detected {
30 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
39pub 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 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
81pub(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}