fn expand_tabs(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut col = 0usize;
for ch in s.chars() {
if ch == '\t' {
let spaces = 4 - (col % 4);
for _ in 0..spaces {
out.push(' ');
}
col += spaces;
} else {
out.push(ch);
col += 1;
}
}
out
}
#[must_use]
fn min_indent(code: &str) -> usize {
code.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| {
let expanded = expand_tabs(line);
expanded.len() - expanded.trim_start().len()
})
.min()
.unwrap_or(0)
}
#[must_use]
pub fn dedent(code: &str) -> String {
let strip = min_indent(code);
if strip == 0 {
return code.to_owned();
}
code.lines()
.map(|line| {
let expanded = expand_tabs(line);
if expanded.len() >= strip {
expanded[strip..].to_owned()
} else {
expanded.trim_start().to_owned()
}
})
.collect::<Vec<String>>()
.join("\n")
}
#[must_use]
pub fn reindent(code: &str, target_column: usize) -> String {
if target_column == 0 {
return code.to_owned();
}
let prefix = " ".repeat(target_column);
code.lines()
.map(|line| {
if line.trim().is_empty() {
String::default() } else {
format!("{prefix}{line}")
}
})
.collect::<Vec<String>>()
.join("\n")
}
#[must_use]
pub fn dedent_then_reindent(code: &str, target_column: usize) -> String {
let dedented = dedent(code);
reindent(&dedented, target_column)
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_dedent_four_spaces() {
let code = " x := 1\n return x";
assert_eq!(dedent(code), "x := 1\nreturn x");
}
#[test]
fn test_dedent_mixed_indent_uses_minimum() {
let code = " x := 1\n y := 2";
assert_eq!(dedent(code), "x := 1\n y := 2");
}
#[test]
fn test_dedent_empty_lines_ignored_in_min() {
let code = " x := 1\n\n return x";
assert_eq!(dedent(code), "x := 1\n\nreturn x");
}
#[test]
fn test_dedent_already_at_column_zero() {
let code = "x := 1\nreturn x";
assert_eq!(dedent(code), code);
}
#[test]
fn test_dedent_tab_indented_normalises_to_spaces() {
let code = "\t\tx := 1\n\t\treturn x";
let result = dedent(code);
assert_eq!(result, "x := 1\nreturn x");
}
#[test]
fn test_dedent_single_line() {
let code = " return 42;";
assert_eq!(dedent(code), "return 42;");
}
#[test]
fn test_reindent_column_8() {
let code = "x := 1\nreturn x";
let result = reindent(code, 8);
assert_eq!(result, " x := 1\n return x");
}
#[test]
fn test_reindent_column_zero_is_noop() {
let code = "x := 1\nreturn x";
assert_eq!(reindent(code, 0), code);
}
#[test]
fn test_reindent_blank_lines_not_padded() {
let code = "x := 1\n\nreturn x";
let result = reindent(code, 4);
assert_eq!(result, " x := 1\n\n return x");
}
#[test]
fn test_dedent_then_reindent_pipeline() {
let code = " x := 1\n return x";
let result = dedent_then_reindent(code, 8);
assert_eq!(result, " x := 1\n return x");
}
#[test]
fn test_dedent_then_reindent_no_double_indent() {
let code = " return value;";
let result = dedent_then_reindent(code, 4);
assert_eq!(result, " return value;");
}
#[test]
fn test_dedent_then_reindent_multiline_with_blank() {
let code = " fn body() {\n\n return 42;\n }";
let result = dedent_then_reindent(code, 4);
assert_eq!(result, " fn body() {\n\n return 42;\n }");
}
}