use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq)]
pub struct IndentStyle {
pub spaces: usize,
pub uses_tabs: bool,
pub width: usize,
}
impl Default for IndentStyle {
fn default() -> Self {
Self {
spaces: 4,
uses_tabs: false,
width: 4,
}
}
}
impl IndentStyle {
pub fn indent_string(&self, level: usize) -> String {
if self.uses_tabs {
"\t".repeat(level)
} else {
" ".repeat(self.spaces * level)
}
}
}
const SUPERMAJORITY_RATIO: f64 = 0.80;
pub fn detect_indent_style(lines: &[String]) -> IndentStyle {
let mut space_levels: Vec<usize> = Vec::new();
let mut tab_levels: Vec<usize> = Vec::new();
for line in lines.iter() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let leading_spaces = line.chars().take_while(|c| *c == ' ').count();
let leading_tabs = line.chars().take_while(|c| *c == '\t').count();
if leading_tabs > 0 && leading_spaces == 0 {
tab_levels.push(leading_tabs);
} else if leading_spaces > 0 && leading_tabs == 0 {
space_levels.push(leading_spaces);
}
}
let total_indented = tab_levels.len() + space_levels.len();
if total_indented == 0 {
return IndentStyle::default();
}
if tab_levels.len() as f64 / total_indented as f64 > SUPERMAJORITY_RATIO {
let mode_width = mode_usize(&tab_levels).unwrap_or(1).max(1);
return IndentStyle {
spaces: 0,
uses_tabs: true,
width: mode_width,
};
}
let indent_step = detect_space_step(&space_levels);
IndentStyle {
spaces: indent_step,
uses_tabs: false,
width: indent_step,
}
}
fn detect_space_step(levels: &[usize]) -> usize {
if levels.is_empty() {
return 4;
}
let mut freq: HashMap<usize, usize> = HashMap::new();
for &l in levels {
*freq.entry(l).or_default() += 1;
}
let mut candidates: Vec<usize> = freq.keys().copied().filter(|&k| k > 0).collect();
let mut sorted_keys: Vec<usize> = candidates.clone();
sorted_keys.sort_unstable();
for w in sorted_keys.windows(2) {
let d = w[1] - w[0];
if d > 0 && !candidates.contains(&d) {
candidates.push(d);
}
}
if !candidates.contains(&4) {
candidates.push(4);
}
let mut best_step = 4;
let mut best_score: i64 = -1;
for &candidate in &candidates {
if !(2..=16).contains(&candidate) {
continue;
}
let raw_freq = freq.get(&candidate).copied().unwrap_or(0) as i64;
let score = raw_freq * 1000 - candidate as i64;
if score > best_score {
best_score = score;
best_step = candidate;
}
}
best_step
}
fn mode_usize(values: &[usize]) -> Option<usize> {
let mut counts: HashMap<usize, usize> = HashMap::new();
for v in values {
*counts.entry(*v).or_default() += 1;
}
counts.into_iter().max_by_key(|(_, c)| *c).map(|(v, _)| v)
}
const MAX_SCAN_BACK: usize = 20;
pub fn detect_expected_indent(
all_lines: &[String],
start_line: usize,
_end_line: usize,
) -> (IndentStyle, usize) {
let style = detect_indent_style(all_lines);
let step = if style.uses_tabs {
1
} else {
style.spaces.max(1)
};
let s = start_line.saturating_sub(1);
let scan_start = s.saturating_sub(MAX_SCAN_BACK);
let mut best_level: Option<usize> = None;
let mut best_quality: i32 = -1;
for i in (scan_start..s).rev() {
if i >= all_lines.len() {
continue;
}
let line = &all_lines[i];
if line.trim().is_empty() {
continue;
}
let leading = count_leading_whitespace(line);
let quality = context_quality(leading, step, line);
if quality > best_quality {
let level = round_to_nearest_level(leading, step);
best_level = Some(level);
best_quality = quality;
if leading.is_multiple_of(step) {
break;
}
}
}
let mut context_level = best_level.unwrap_or(0);
if let Some(line) = context_line_before(all_lines, start_line) {
let trimmed = line.trim_end();
let trimmed_no_comment = strip_trailing_comment(trimmed);
if trimmed_no_comment.ends_with('{')
|| trimmed_no_comment.ends_with(':')
|| trimmed_no_comment.ends_with('(')
|| trimmed_no_comment.ends_with('[')
|| trimmed_no_comment.ends_with(',')
{
context_level += 1;
}
}
(style, context_level)
}
fn context_line_before(all_lines: &[String], start_line: usize) -> Option<&String> {
let s = start_line.saturating_sub(1);
for i in (0..s).rev() {
if i >= all_lines.len() {
continue;
}
if !all_lines[i].trim().is_empty() {
return Some(&all_lines[i]);
}
}
None
}
fn context_quality(leading: usize, step: usize, _line: &str) -> i32 {
if step == 0 {
return 0;
}
let remainder = leading % step;
let base = if remainder == 0 {
10
} else if remainder <= 2 {
5
} else {
0
};
let depth_bonus = (leading / step).min(5) as i32;
base + depth_bonus
}
fn round_to_nearest_level(leading: usize, step: usize) -> usize {
if step == 0 {
return 0;
}
let exact = leading / step;
let remainder = leading % step;
if remainder > step / 2 {
exact + 1
} else {
exact
}
}
fn count_leading_whitespace(line: &str) -> usize {
line.chars().take_while(|c| c.is_whitespace()).count()
}
fn strip_trailing_comment(s: &str) -> &str {
if let Some(pos) = s.find("//") {
return &s[..pos];
}
if let Some(pos) = s.find('#') {
return &s[..pos];
}
s
}
pub fn validate_indentation(
all_lines: &[String],
start_line: usize,
end_line: usize,
replacement_lines: &[String],
) -> (bool, Option<String>, Option<String>) {
let (style, expected_level) = detect_expected_indent(all_lines, start_line, end_line);
let expected_indent = style.indent_string(expected_level);
let mut has_content = false;
let mut min_leading = usize::MAX;
for line in replacement_lines.iter().filter(|l| !l.trim().is_empty()) {
has_content = true;
let leading = line.chars().take_while(|c| c.is_whitespace()).count();
min_leading = min_leading.min(leading);
}
if !has_content {
return (true, None, None);
}
let expected_spaces = expected_indent.len();
let style_desc = if style.uses_tabs {
format!("{} tab(s)", expected_level)
} else {
format!("{} space(s)", expected_spaces)
};
if min_leading < expected_spaces {
let diff = expected_spaces - min_leading;
let warning = format!(
"INDENTATION WARNING: Replacement has {} leading {}, expected {} (diff: {})",
min_leading,
if style.uses_tabs {
"tab(s)"
} else {
"space(s)"
},
style_desc,
diff
);
let fix = replacement_lines
.iter()
.map(|line| {
if line.trim().is_empty() {
line.clone()
} else {
let stripped = line.trim_start();
format!("{}{}", expected_indent, stripped)
}
})
.collect::<Vec<_>>()
.join("\n");
(false, Some(warning), Some(fix))
} else {
(true, None, None)
}
}
pub fn auto_indent_content(
all_lines: &[String],
start_line: usize,
end_line: usize,
content: &str,
) -> String {
let (style, expected_level) = detect_expected_indent(all_lines, start_line, end_line);
let expected_indent = style.indent_string(expected_level);
if expected_indent.is_empty() {
return content.to_string();
}
content
.lines()
.map(|line| {
if line.trim().is_empty() {
line.to_string()
} else {
let stripped = line.trim_start();
format!("{}{}", expected_indent, stripped)
}
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn needs_indent_fix(
all_lines: &[String],
start_line: usize,
end_line: usize,
content: &str,
) -> bool {
let (style, expected_level) = detect_expected_indent(all_lines, start_line, end_line);
let expected_indent = style.indent_string(expected_level);
if expected_indent.is_empty() {
return false;
}
let expected_spaces = expected_indent.len();
for line in content.lines() {
if !line.trim().is_empty() {
let leading = line.chars().take_while(|c| c.is_whitespace()).count();
if leading < expected_spaces {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_spaces_indent() {
let lines = vec![
" def foo():".to_string(),
" pass".to_string(),
" def bar():".to_string(),
];
let style = detect_indent_style(&lines);
assert_eq!(style.spaces, 4);
assert!(!style.uses_tabs);
}
#[test]
fn test_detect_tabs_indent() {
let lines: Vec<String> = (0..50).map(|_| "\tfn foo() {".to_string()).collect();
let style = detect_indent_style(&lines);
assert!(style.uses_tabs);
}
#[test]
fn test_detect_2_space_indent() {
let lines = vec![" def foo():".to_string(), " pass".to_string()];
let style = detect_indent_style(&lines);
assert_eq!(style.spaces, 2);
}
#[test]
fn test_detect_8_space_indent() {
let lines = vec![
" fn main() {".to_string(),
" println!();".to_string(),
];
let style = detect_indent_style(&lines);
assert_eq!(style.spaces, 8);
}
#[test]
fn test_detect_indent_empty_file_defaults_spaces() {
let lines: Vec<String> = vec![];
let style = detect_indent_style(&lines);
assert_eq!(style.spaces, 4);
assert!(!style.uses_tabs);
}
#[test]
fn test_supermajority_one_tab_does_not_flip_space_file() {
let mut lines: Vec<String> = (0..29).map(|_| " fn foo() {".to_string()).collect();
lines.push("\tfn rogue() {}".to_string());
let style = detect_indent_style(&lines);
assert!(
!style.uses_tabs,
"Single rogue tab should not flip a space-indented file"
);
}
#[test]
fn test_detect_step_from_level_differences() {
let lines = vec![
"class Foo:".to_string(),
" def a(self):".to_string(),
" pass".to_string(),
" def b(self):".to_string(),
" return 1".to_string(),
];
let style = detect_indent_style(&lines);
assert_eq!(style.spaces, 4);
}
#[test]
fn test_detect_indent_with_anomalous_spacing() {
let lines = vec![
"fn main() {".to_string(),
" let x = 1;".to_string(), " let y = 2;".to_string(), " let z = 3;".to_string(), " let w = 4;".to_string(), ];
let style = detect_indent_style(&lines);
assert!(!style.uses_tabs);
assert!(style.spaces >= 1);
}
#[test]
fn test_expected_indent_after_colon() {
let all_lines = vec!["def foo():\n".to_string(), " pass\n".to_string()];
let (_, level) = detect_expected_indent(&all_lines, 2, 2);
assert_eq!(level, 1);
}
#[test]
fn test_expected_indent_after_brace() {
let all_lines = vec!["fn main() {\n".to_string(), " let x = 1;\n".to_string()];
let (_, level) = detect_expected_indent(&all_lines, 2, 2);
assert_eq!(level, 1, "Content after `{{` should be indent level 1");
}
#[test]
fn test_expected_indent_after_closing_brace() {
let all_lines = vec![
"fn outer() {\n".to_string(),
" fn inner() {\n".to_string(),
" pass\n".to_string(),
" }\n".to_string(),
" // editing here\n".to_string(),
];
let (_, level) = detect_expected_indent(&all_lines, 5, 5);
assert_eq!(
level, 1,
"Content after `}}` at outer level should be level 1, not 2"
);
}
#[test]
fn test_expected_indent_top_of_file() {
let all_lines = vec!["fn main() {\n".to_string(), " let x = 1;\n".to_string()];
let (_, level) = detect_expected_indent(&all_lines, 1, 1);
assert_eq!(level, 0);
}
#[test]
fn test_expected_indent_deeply_nested() {
let all_lines = vec![
"class Foo:\n".to_string(),
" def bar(self):\n".to_string(),
" if True:\n".to_string(),
" pass\n".to_string(),
];
let (_, level) = detect_expected_indent(&all_lines, 4, 4);
assert_eq!(level, 3);
}
#[test]
fn test_expected_indent_skips_blank_lines() {
let all_lines = vec![
"fn outer() {\n".to_string(),
" let x = 1;\n".to_string(),
"\n".to_string(),
"\n".to_string(),
" // editing here\n".to_string(),
];
let (_, level) = detect_expected_indent(&all_lines, 5, 5);
assert_eq!(level, 1, "Blank lines must be skipped to find real context");
}
#[test]
fn test_expected_indent_one_misindented_line_does_not_poison() {
let all_lines = vec![
"fn main() {\n".to_string(),
" let a = 1;\n".to_string(),
" let bad = 2;\n".to_string(),
" let b = 3;\n".to_string(),
" let c = 4;\n".to_string(),
" let d = 5;\n".to_string(),
" let e = 6;\n".to_string(),
" // editing here\n".to_string(),
];
let (_, level) = detect_expected_indent(&all_lines, 8, 8);
assert_eq!(
level, 1,
"A single misindented line must not poison context detection"
);
}
#[test]
fn test_expected_indent_off_by_one_rounding() {
let all_lines = vec![
"fn main() {\n".to_string(),
" let x = 1;\n".to_string(), ];
let (_, level) = detect_expected_indent(&all_lines, 2, 2);
assert_eq!(
level, 1,
"3 spaces in a 4-space file should round to level 1, not 0"
);
}
#[test]
fn test_expected_indent_scan_back_multiple_lines() {
let all_lines = vec![
"fn main() {\n".to_string(),
" let a = 1;\n".to_string(),
" let b = 2;\n".to_string(),
" let c = 3;\n".to_string(), "\n".to_string(), "// editing here\n".to_string(),
];
let (_, level) = detect_expected_indent(&all_lines, 6, 6);
assert_eq!(
level, 1,
"Must scan past blank+anomaly to find the real context"
);
}
#[test]
fn test_expected_indent_after_trailing_comma() {
let all_lines = vec![
"fn main() {\n".to_string(),
" my_function(\n".to_string(),
" arg1,\n".to_string(),
" arg2,\n".to_string(),
];
let (_, level) = detect_expected_indent(&all_lines, 5, 5);
assert!(
level >= 2,
"Content after continuation line should keep indent"
);
}
#[test]
fn test_auto_indent_unindented_content() {
let all_lines = vec!["def foo():\n".to_string(), " pass\n".to_string()];
let content = "print('hello')";
let fixed = auto_indent_content(&all_lines, 2, 2, content);
assert_eq!(fixed, " print('hello')");
}
#[test]
fn test_auto_indent_strips_existing_whitespace() {
let all_lines = vec!["def foo():\n".to_string(), " pass\n".to_string()];
let content = " print('hello')";
let fixed = auto_indent_content(&all_lines, 2, 2, content);
assert_eq!(fixed, " print('hello')");
}
#[test]
fn test_auto_indent_multiline_content() {
let all_lines = vec!["def foo():\n".to_string(), " pass\n".to_string()];
let content = "print('hello')\nprint('world')";
let fixed = auto_indent_content(&all_lines, 2, 2, content);
assert_eq!(fixed, " print('hello')\n print('world')");
}
#[test]
fn test_auto_indent_preserves_blank_lines() {
let all_lines = vec!["def foo():\n".to_string(), " pass\n".to_string()];
let content = "print('a')\n\nprint('b')";
let fixed = auto_indent_content(&all_lines, 2, 2, content);
assert_eq!(fixed, " print('a')\n\n print('b')");
}
#[test]
fn test_auto_indent_mixed_existing_indent() {
let all_lines = vec!["def foo():\n".to_string(), " pass\n".to_string()];
let content = " line1\n line2\nline3";
let fixed = auto_indent_content(&all_lines, 2, 2, content);
assert_eq!(fixed, " line1\n line2\n line3");
}
#[test]
fn test_auto_indent_no_indent_needed() {
let all_lines = vec!["fn main() {\n".to_string(), " let x = 1;\n".to_string()];
let content = "println!(\"hello\");";
let fixed = auto_indent_content(&all_lines, 1, 1, content);
assert_eq!(fixed, "println!(\"hello\");");
}
#[test]
fn test_auto_indent_with_tabs() {
let lines: Vec<String> = (0..30)
.map(|_| "\tfn foo() {".to_string())
.chain(std::iter::once("\t\tpass".to_string()))
.collect();
let content = "print('hello')";
let fixed = auto_indent_content(&lines, 31, 31, content);
assert_eq!(fixed, "\t\tprint('hello')");
}
#[test]
fn test_auto_indent_tabs_strips_existing_spaces() {
let lines: Vec<String> = (0..30)
.map(|_| "\tfn foo() {".to_string())
.chain(std::iter::once("\t\tpass".to_string()))
.collect();
let content = " print('hello')";
let fixed = auto_indent_content(&lines, 31, 31, content);
assert_eq!(fixed, "\t\tprint('hello')");
}
#[test]
fn test_needs_indent_fix_unindented_content() {
let all_lines = vec!["def foo():\n".to_string(), " pass\n".to_string()];
assert!(needs_indent_fix(&all_lines, 2, 2, "print('hello')"));
}
#[test]
fn test_needs_indent_fix_already_indented() {
let all_lines = vec!["def foo():\n".to_string(), " pass\n".to_string()];
assert!(!needs_indent_fix(&all_lines, 2, 2, " print('hello')"));
}
#[test]
fn test_needs_indent_fix_partial_indent() {
let all_lines = vec!["def foo():\n".to_string(), " pass\n".to_string()];
assert!(needs_indent_fix(&all_lines, 2, 2, " print('hello')"));
}
#[test]
fn test_needs_indent_fix_tab_content() {
let lines: Vec<String> = (0..30)
.map(|_| "\tfn foo() {".to_string())
.chain(std::iter::once("\t\tpass".to_string()))
.collect();
assert!(needs_indent_fix(&lines, 31, 31, "print('hello')"));
assert!(!needs_indent_fix(&lines, 31, 31, "\t\tprint('hello')"));
}
#[test]
fn test_needs_indent_fix_top_level() {
let all_lines = vec!["fn main() {\n".to_string(), " let x = 1;\n".to_string()];
assert!(!needs_indent_fix(&all_lines, 1, 1, "println!(\"hello\");"));
}
#[test]
fn test_validate_indentation_missing() {
let all_lines = vec!["def foo():\n".to_string(), " pass\n".to_string()];
let replacement = vec!["print('hello')".to_string()];
let (valid, warning, fix) = validate_indentation(&all_lines, 2, 2, &replacement);
assert!(!valid);
assert!(warning.is_some());
assert!(warning.unwrap().contains("4 space"));
assert_eq!(fix.unwrap(), " print('hello')");
}
#[test]
fn test_validate_indentation_correct() {
let all_lines = vec!["def foo():\n".to_string(), " pass\n".to_string()];
let replacement = vec![" print('hello')".to_string()];
let (valid, warning, _fix) = validate_indentation(&all_lines, 2, 2, &replacement);
assert!(valid);
assert!(warning.is_none());
}
#[test]
fn test_validate_indentation_partial_fix_strips_existing() {
let all_lines = vec!["def foo():\n".to_string(), " pass\n".to_string()];
let replacement = vec![" print('hello')".to_string()];
let (valid, _warning, fix) = validate_indentation(&all_lines, 2, 2, &replacement);
assert!(!valid);
assert_eq!(fix.unwrap(), " print('hello')");
}
#[test]
fn test_validate_indentation_empty_replacement() {
let all_lines = vec!["def foo():\n".to_string(), " pass\n".to_string()];
let replacement: Vec<String> = vec!["".to_string()];
let (valid, warning, _fix) = validate_indentation(&all_lines, 2, 2, &replacement);
assert!(valid);
assert!(warning.is_none());
}
#[test]
fn test_validate_indentation_whitespace_only_line() {
let all_lines = vec!["def foo():\n".to_string(), " pass\n".to_string()];
let replacement = vec![" ".to_string(), "print('x')".to_string()];
let (valid, _warning, _fix) = validate_indentation(&all_lines, 2, 2, &replacement);
assert!(!valid);
}
#[test]
fn test_indent_string_spaces() {
let style = IndentStyle {
spaces: 4,
uses_tabs: false,
width: 4,
};
assert_eq!(style.indent_string(0), "");
assert_eq!(style.indent_string(1), " ");
assert_eq!(style.indent_string(2), " ");
}
#[test]
fn test_indent_string_tabs() {
let style = IndentStyle {
spaces: 0,
uses_tabs: true,
width: 4,
};
assert_eq!(style.indent_string(0), "");
assert_eq!(style.indent_string(1), "\t");
assert_eq!(style.indent_string(2), "\t\t");
}
#[test]
fn test_round_to_nearest_level() {
assert_eq!(round_to_nearest_level(0, 4), 0);
assert_eq!(round_to_nearest_level(4, 4), 1);
assert_eq!(round_to_nearest_level(6, 4), 1); assert_eq!(round_to_nearest_level(2, 4), 0); assert_eq!(round_to_nearest_level(7, 4), 2); assert_eq!(round_to_nearest_level(3, 4), 1); }
#[test]
fn test_strip_trailing_comment() {
assert_eq!(
strip_trailing_comment(" let x = 1; // comment"),
" let x = 1; "
);
assert_eq!(strip_trailing_comment(" # python comment"), " ");
assert_eq!(strip_trailing_comment(" let x = 1;"), " let x = 1;");
assert_eq!(strip_trailing_comment("x = obj#method"), "x = obj");
}
}