use std::collections::HashMap;
use crate::buffer::Indent;
use crate::changeset::{ChangeSet, Edit};
use crate::history::EditKind;
use crate::position::ByteOffset;
use crate::resolve::Resolved;
use crate::selection::SelectionSet;
use crate::text::Document;
const DETECT_LINES: usize = 10_000;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Detected {
pub use_spaces: Option<bool>,
pub width: Option<usize>,
pub mixed: bool,
}
impl Detected {
pub fn apply(&self, base: Indent) -> Indent {
Indent {
use_spaces: self.use_spaces.unwrap_or(base.use_spaces),
width: self.width.unwrap_or(base.width),
}
}
}
pub fn detect(doc: &Document) -> Detected {
let (mut tabs, mut spaces) = (0usize, 0usize);
let mut steps: HashMap<usize, usize> = HashMap::new();
let mut prev = 0usize;
for line in 0..doc.line_count().min(DETECT_LINES) {
let text = doc.line_text(line);
if text.trim().is_empty() {
continue;
}
let lead: &str = &text[..text.len() - text.trim_start_matches([' ', '\t']).len()];
if lead.starts_with('\t') {
tabs += 1;
} else if lead.len() >= 2 && lead.bytes().all(|b| b == b' ') {
spaces += 1;
}
if lead.bytes().all(|b| b == b' ') {
let n = lead.len();
if n > prev {
*steps.entry(n - prev).or_default() += 1;
}
prev = n;
}
}
let use_spaces = (tabs + spaces > 0).then_some(spaces > tabs);
let width = (use_spaces == Some(true))
.then(|| {
[4, 2, 8]
.into_iter()
.max_by_key(|w| steps.get(w).copied().unwrap_or(0))
.filter(|w| steps.get(w).copied().unwrap_or(0) > 0)
})
.flatten();
Detected {
use_spaces,
width,
mixed: tabs > 0 && spaces > 0,
}
}
pub(crate) fn convert(
doc: &Document,
sels: &SelectionSet,
to_spaces: bool,
width: usize,
) -> Resolved {
let width = width.max(1);
let mut edits = Vec::new();
for line in 0..doc.line_count() {
let text = doc.line_text(line);
let lead_len = text.len() - text.trim_start_matches([' ', '\t']).len();
if lead_len == 0 {
continue;
}
let mut cols = 0;
for b in text[..lead_len].bytes() {
cols = if b == b'\t' {
cols + width - cols % width
} else {
cols + 1
};
}
let new = if to_spaces {
" ".repeat(cols)
} else {
format!("{}{}", "\t".repeat(cols / width), " ".repeat(cols % width))
};
if new != text[..lead_len] {
let start = doc.line_start(line);
edits.push(Edit::new(start, ByteOffset(start.0 + lead_len), new));
}
}
let changes = ChangeSet::new(doc.len(), edits);
if changes.is_empty() {
return Resolved::Selections(sels.clone());
}
let after = sels.map(|s| crate::selection::Selection {
anchor: changes.map_position(s.anchor, crate::changeset::Bias::After),
head: changes.map_position(s.head, crate::changeset::Bias::After),
goal: None,
});
Resolved::Edit {
changes,
after,
kind: EditKind::Other,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn detect_text(t: &str) -> Detected {
detect(&Document::new(t))
}
#[test]
fn detects_spaces_and_their_width() {
let py = "def f():\n if x:\n y()\n return 1\n";
assert_eq!(
detect_text(py),
Detected {
use_spaces: Some(true),
width: Some(4),
mixed: false
}
);
let js = "a {\n b {\n c;\n }\n}\n";
assert_eq!(detect_text(js).width, Some(2));
}
#[test]
fn detects_tabs_and_mixed_files() {
let go = "func f() {\n\tif x {\n\t\ty()\n\t}\n\t/*\n\t * doc\n\t */\n}\n";
assert_eq!(
detect_text(go),
Detected {
use_spaces: Some(false),
width: None,
mixed: false
}
);
let mixed = "def f():\n a()\n\tb()\n";
assert!(detect_text(mixed).mixed);
assert_eq!(detect_text("x\ny\n"), Detected::default());
}
}