#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndentSource {
Manual,
Detected,
Configured,
}
impl IndentSource {
pub fn label(self) -> &'static str {
match self {
IndentSource::Manual => "manual",
IndentSource::Detected => "detected",
IndentSource::Configured => "configured",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Indent {
pub style: crate::config::IndentStyle,
pub width: usize,
pub style_source: IndentSource,
pub width_source: IndentSource,
}
impl Default for Indent {
fn default() -> Self {
Self {
style: crate::config::IndentStyle::Spaces,
width: 4,
style_source: IndentSource::Configured,
width_source: IndentSource::Configured,
}
}
}
impl Indent {
pub fn unit(&self) -> String {
match self.style {
crate::config::IndentStyle::Spaces => " ".repeat(self.width),
crate::config::IndentStyle::Tabs => "\t".into(),
}
}
pub fn label(&self) -> String {
let style = match self.style {
crate::config::IndentStyle::Spaces => "Spaces",
crate::config::IndentStyle::Tabs => "Tabs",
};
format!("{style}:{}", self.width)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct IndentOverride {
pub width: Option<usize>,
pub style: Option<crate::config::IndentStyle>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Confidence {
High,
Low,
}
impl Confidence {
pub fn label(self) -> &'static str {
match self {
Confidence::High => "high",
Confidence::Low => "low",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DetectionFailure {
InsufficientEvidence,
MixedStyles,
Ambiguous,
NoConsensus,
AmbiguousLiteral,
}
impl DetectionFailure {
pub fn reason(self) -> &'static str {
match self {
DetectionFailure::InsufficientEvidence => "too little indentation evidence",
DetectionFailure::MixedStyles => "tab- and space-indented lines both common",
DetectionFailure::Ambiguous => "two widths fit the evidence equally well",
DetectionFailure::NoConsensus => "no width covers the space indentation",
DetectionFailure::AmbiguousLiteral => {
"raw-string whitespace is ambiguous without language context"
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Detection {
Unknown(DetectionFailure),
Tabs {
evidence: usize,
confidence: Confidence,
},
Spaces {
width: usize,
evidence: usize,
confidence: Confidence,
},
}
const SAMPLE_LINES: usize = 1000;
const SAMPLE_BYTES: usize = 256 * 1024;
const MIN_EVIDENCE: usize = 4;
const MAX_ATTESTED: usize = 32;
const UNITS: [usize; 4] = [2, 3, 4, 8];
#[derive(Clone, Copy)]
enum SampleState {
Code,
BlockComment,
Quoted(u8, usize),
}
impl SampleState {
fn prose_line(&mut self, line: ropey::RopeSlice<'_>) -> Result<bool, DetectionFailure> {
let began_in_prose = !matches!(self, Self::Code);
let mut at = 0;
let same = |at: usize, byte: u8, count: usize| {
at + count <= line.len_bytes() && (at..at + count).all(|index| line.byte(index) == byte)
};
while at < line.len_bytes() {
let byte = line.byte(at);
let next = (at + 1 < line.len_bytes()).then(|| line.byte(at + 1));
match *self {
Self::BlockComment => {
if byte == b'*' && next == Some(b'/') {
*self = Self::Code;
at += 2;
} else {
at += 1;
}
}
Self::Quoted(delimiter, count) => {
if byte == b'\\' {
at += 2;
} else if same(at, delimiter, count) {
*self = Self::Code;
at += count;
} else {
at += 1;
}
}
Self::Code => {
if byte == b'/' && next == Some(b'/') || byte == b'#' {
break;
}
if byte == b'/' && next == Some(b'*') {
*self = Self::BlockComment;
at += 2;
} else if matches!(byte, b'r' | b'R') && {
let mut end = at + 1;
while end < line.len_bytes() && line.byte(end) == b'#' {
end += 1;
}
end < line.len_bytes() && line.byte(end) == b'"'
} {
return Err(DetectionFailure::AmbiguousLiteral);
} else if matches!(byte, b'"' | b'\'') && same(at, byte, 3) {
*self = Self::Quoted(byte, 3);
at += 3;
} else if matches!(byte, b'"' | b'`') {
*self = Self::Quoted(byte, 1);
at += 1;
} else {
at += 1;
}
}
}
}
Ok(began_in_prose || !matches!(self, Self::Code))
}
}
pub fn detect_indent(text: &ropey::Rope) -> Detection {
let mut tabs = 0usize;
let mut spaced = 0usize;
let mut attested = [0usize; MAX_ATTESTED + 1];
let mut covered = [0usize; 9]; let mut continuation = false;
let mut prev_indent = 0usize;
let mut sampled = 0usize;
let mut sample_state = SampleState::Code;
for line in text.lines().take(SAMPLE_LINES) {
sampled += line.len_bytes();
if sampled > SAMPLE_BYTES {
break;
}
match sample_state.prose_line(line) {
Ok(true) => continue,
Ok(false) => {}
Err(reason) => return Detection::Unknown(reason),
}
let mut it = line.bytes();
let (mut spaces, mut tabs_lead) = (0usize, 0usize);
let first = loop {
match it.next() {
Some(b' ') => spaces += 1,
Some(b'\t') => tabs_lead += 1,
other => break other,
}
};
let Some(first) = first else {
continue; };
if first == b'\n' || first == b'\r' {
continue; }
let second = it.next().filter(|b| *b != b'\n' && *b != b'\r');
if comment_start(first, second) {
continue; }
let mut tail = None;
for i in (0..line.len_bytes()).rev() {
let b = line.byte(i);
if !matches!(b, b'\n' | b'\r' | b' ' | b'\t') {
tail = Some(b);
break;
}
}
let opens = matches!(
tail,
Some(
b'(' | b'[' | b',' | b'=' | b'+' | b'-' | b'*' | b'/' | b'&' | b'|' | b'?' | b'\\'
)
);
let leading = spaces + tabs_lead;
let infix = matches!(first, b'.' | b',');
let evidence = !(continuation && leading > prev_indent) && !infix;
continuation = opens;
prev_indent = leading;
if !evidence {
continue;
}
if spaces > 0 && tabs_lead > 0 {
continue; }
if tabs_lead > 0 {
tabs += 1;
continue;
}
if spaces == 0 {
continue; }
spaced += 1;
if spaces <= MAX_ATTESTED {
attested[spaces] += 1;
}
for unit in UNITS {
if spaces % unit == 0 {
covered[unit] += 1;
}
}
}
let evidence = tabs + spaced;
if evidence < MIN_EVIDENCE {
return Detection::Unknown(DetectionFailure::InsufficientEvidence);
}
let confidence = |share: usize, base: usize| {
if share * 10 >= base * 9 {
Confidence::High
} else {
Confidence::Low
}
};
if tabs * 3 >= evidence * 2 {
return Detection::Tabs {
evidence: tabs,
confidence: confidence(tabs, evidence),
};
}
if spaced * 3 < evidence * 2 {
return Detection::Unknown(DetectionFailure::MixedStyles);
}
let mut best: Option<(usize, usize)> = None; let mut tied = false;
for unit in UNITS {
if attested[unit] == 0 || covered[unit] * 5 < spaced * 3 {
continue;
}
match best {
None => {
best = Some((unit, covered[unit]));
tied = false;
}
Some((_, share)) if covered[unit] > share => {
best = Some((unit, covered[unit]));
tied = false;
}
Some((_, share)) if covered[unit] == share => tied = true,
_ => {}
}
}
if tied {
return Detection::Unknown(DetectionFailure::Ambiguous);
}
match best {
Some((width, share)) => Detection::Spaces {
width,
evidence: spaced,
confidence: confidence(share, spaced),
},
None => Detection::Unknown(DetectionFailure::NoConsensus),
}
}
fn comment_start(first: u8, second: Option<u8>) -> bool {
match first {
b'/' => matches!(second, Some(b'/') | Some(b'*')),
b'*' | b'#' | b';' => true,
b'-' => second == Some(b'-'),
_ => false,
}
}
#[cfg(test)]
mod detect_tests {
use super::*;
fn detect(text: &str) -> Detection {
detect_indent(&ropey::Rope::from_str(text))
}
fn spaces(text: &str) -> (usize, Confidence) {
match detect(text) {
Detection::Spaces {
width, confidence, ..
} => (width, confidence),
other => panic!("expected spaces, got {other:?}"),
}
}
#[test]
fn common_widths() {
let two = "fn f() {\n a;\n b;\n c;\n d;\n}\n";
assert_eq!(spaces(two).0, 2);
let three = "fn f() {\n a;\n b;\n c;\n d;\n}\n";
assert_eq!(spaces(three).0, 3);
let four = "fn f() {\n a;\n b;\n c;\n d;\n}\n";
assert_eq!(spaces(four).0, 4);
let eight = "fn f() {\n a;\n b;\n c;\n d;\n}\n";
assert_eq!(spaces(eight).0, 8);
}
#[test]
fn tabs_carry_no_width() {
match detect("fn f() {\n\ta;\n\tb;\n\t\tc;\n\td;\n}\n") {
Detection::Tabs { evidence, .. } => assert_eq!(evidence, 4),
other => panic!("expected tabs, got {other:?}"),
}
}
#[test]
fn deep_nesting_is_evidence() {
let text = "fn f() {\n a;\n b;\n c;\n d;\n e;\n}\n";
let (width, confidence) = spaces(text);
assert_eq!(width, 4);
assert_eq!(confidence, Confidence::High);
}
#[test]
fn comments_are_not_evidence() {
let text = "fn f() {\n a;\n // note\n # shell-ish\n * doc body\n b;\n c;\n d;\n}\n";
let (width, confidence) = spaces(text);
assert_eq!(width, 4);
assert_eq!(
confidence,
Confidence::High,
"comments stayed out of the denominator"
);
let text = "// a\n // b\n // c\n // d\nx;\n";
assert_eq!(
detect(text),
Detection::Unknown(DetectionFailure::InsufficientEvidence)
);
}
#[test]
fn comma_siblings_at_the_same_depth_are_evidence() {
let text = "{\n \"a\": 1,\n \"b\": 2,\n \"c\": 3,\n \"d\": 4\n}\n";
let (width, confidence) = spaces(text);
assert_eq!(width, 2);
assert_eq!(confidence, Confidence::High);
}
#[test]
fn continuation_alignment_is_not_evidence() {
let text =
"fn f() {\n let x = foo(a,\n b);\n c;\n d;\n e;\n}\n";
let (width, confidence) = spaces(text);
assert_eq!(width, 4);
assert_eq!(confidence, Confidence::High);
let text =
"fn f() {\n let x = foo\n .a()\n .b();\n c;\n d;\n e;\n}\n";
let (width, confidence) = spaces(text);
assert_eq!(width, 4);
assert_eq!(confidence, Confidence::High);
}
#[test]
fn mixed_styles_are_unknown() {
let text = "fn f() {\n\ta;\n\tb;\n c;\n d;\n\te;\n f;\n}\n";
assert_eq!(
detect(text),
Detection::Unknown(DetectionFailure::MixedStyles)
);
}
#[test]
fn thin_samples_are_unknown() {
assert_eq!(
detect("\t\tx = 1\n"),
Detection::Unknown(DetectionFailure::InsufficientEvidence)
);
assert_eq!(
detect("no indent here\nat all\nreally\nnone\n"),
Detection::Unknown(DetectionFailure::InsufficientEvidence)
);
}
#[test]
fn divisor_ties_are_ambiguous() {
let text = "fn f() {\n a;\n b;\n c;\n d;\n}\n";
assert_eq!(
detect(text),
Detection::Unknown(DetectionFailure::Ambiguous)
);
}
#[test]
fn blank_and_top_level_lines_are_ignored() {
let text = "top;\n\n a;\n \n b;\nmore;\n c;\n d;\n";
let (width, _) = spaces(text);
assert_eq!(width, 4);
}
#[test]
fn sampling_is_bounded_by_lines() {
let mut text = String::from("fn f() {\n");
for _ in 0..1000 {
text.push_str(" a;\n");
}
for _ in 0..1500 {
text.push_str("\tb;\n");
}
assert_eq!(spaces(&text).0, 4);
}
#[test]
fn sampling_is_bounded_by_bytes() {
let mut text = String::from("fn f() {\n");
let long = format!(" {}\n", "x".repeat(600));
for _ in 0..600 {
text.push_str(&long);
}
for _ in 0..500 {
text.push_str("\tb;\n");
}
const { assert!(600 * 605 > SAMPLE_BYTES, "the fixture exceeds the budget") }
assert_eq!(spaces(&text).0, 4);
}
#[test]
fn multiline_prose_never_votes_for_a_code_indent() {
let python = "def f():\n a = 1\n text = \"\"\"\n prose\n prose\n prose\n prose\n \"\"\"\n b = 2\n c = 3\n d = 4\n";
assert_eq!(spaces(python).0, 2);
let comment = "fn f() {\n a;\n/*\n prose\n prose\n prose\n prose\n*/\n b;\n c;\n d;\n}\n";
assert_eq!(spaces(comment).0, 4);
}
}