use std::path::Path;
use tracing::debug;
pub fn normalize_content(path: &Path, content: &str) -> String {
let pre = if path.extension().is_some_and(|ext| ext == "rs") {
format_rust_content(path, content)
} else {
content.to_string()
};
let is_markdown = path.extension().is_some_and(|ext| ext == "md");
normalize_whitespace_with_policy(&pre, is_markdown)
}
pub(super) fn normalize_whitespace(content: &str) -> String {
normalize_whitespace_with_policy(content, false)
}
fn normalize_whitespace_with_policy(content: &str, is_markdown: bool) -> String {
if content.is_empty() {
return String::new();
}
let max_blanks: usize = if is_markdown { 1 } else { 2 };
let mut result = String::with_capacity(content.len());
let mut blank_count = 0usize;
for line in content.lines() {
let trimmed = line.trim_end();
if trimmed.is_empty() {
blank_count += 1;
if blank_count <= max_blanks {
result.push('\n');
}
} else {
blank_count = 0;
result.push_str(trimmed);
result.push('\n');
}
}
while result.ends_with("\n\n") {
result.pop();
}
if !result.ends_with('\n') {
result.push('\n');
}
result
}
pub(super) fn detect_crate_edition(path: &Path) -> String {
let start = if path.is_dir() {
path
} else {
match path.parent() {
Some(p) => p,
None => return "2024".to_string(),
}
};
let mut current = start;
loop {
let candidate = current.join("Cargo.toml");
if candidate.is_file() {
if let Ok(text) = std::fs::read_to_string(&candidate) {
if let Some(edition) = parse_package_edition(&text) {
return edition;
}
}
return "2024".to_string();
}
match current.parent() {
Some(parent) => current = parent,
None => break,
}
}
"2024".to_string()
}
pub(super) fn parse_package_edition(toml_text: &str) -> Option<String> {
let mut in_package = false;
for line in toml_text.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_package = trimmed == "[package]";
continue;
}
if !in_package {
continue;
}
if let Some(rest) = trimmed.strip_prefix("edition") {
let rest = rest.trim_start();
if let Some(rest) = rest.strip_prefix('=') {
let value = rest.trim().trim_matches('"');
if value.len() == 4 && value.chars().all(|c| c.is_ascii_digit()) {
return Some(value.to_string());
}
}
}
}
None
}
pub fn format_rust_content(path: &Path, content: &str) -> String {
use std::io::Write;
use std::process::{Command, Stdio};
let edition = detect_crate_edition(path);
let config_dir = std::env::current_dir().unwrap_or_default();
let mut child = match Command::new("rustfmt")
.arg("--edition")
.arg(&edition)
.arg("--config-path")
.arg(&config_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(e) => {
debug!("rustfmt not available: {e}");
return content.to_string();
}
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(content.as_bytes());
}
match child.wait_with_output() {
Ok(output) if output.status.success() => {
String::from_utf8(output.stdout).unwrap_or_else(|_| content.to_string())
}
Ok(output) => {
debug!("rustfmt failed: {}", String::from_utf8_lossy(&output.stderr));
content.to_string()
}
Err(e) => {
debug!("rustfmt process error: {e}");
content.to_string()
}
}
}