use std::path::{Path, PathBuf};
use anyhow::Result;
use bstr::ByteSlice;
use clap::{Parser, ValueEnum};
use memchr::memchr2;
use crate::hook::Hook;
use crate::hooks::HookOutput;
use crate::hooks::pre_commit_hooks::{parse_hook_args, run_file_checks};
use crate::run::INTERNAL_CONCURRENCY;
const CRLF: &[u8] = b"\r\n";
const LF: &[u8] = b"\n";
const CR: &[u8] = b"\r";
#[derive(Parser)]
#[command(disable_help_subcommand = true)]
#[command(disable_version_flag = true)]
#[command(disable_help_flag = true)]
pub(crate) struct Args {
#[clap(long, short, value_enum, default_value_t = FixMode::Auto)]
fix: FixMode,
#[arg(value_name = "FILENAMES")]
filenames: Vec<PathBuf>,
}
#[derive(Copy, Clone, Debug, Default, ValueEnum)]
#[allow(clippy::upper_case_acronyms)]
enum FixMode {
#[default]
Auto,
No,
LF,
CRLF,
CR,
}
#[derive(Default)]
struct LineEndingCounts {
cr: usize,
crlf: usize,
lf: usize,
}
impl LineEndingCounts {
fn kind_count(&self) -> usize {
usize::from(self.cr > 0) + usize::from(self.crlf > 0) + usize::from(self.lf > 0)
}
fn has_any_except(&self, ending: &[u8]) -> bool {
(ending != CR && self.cr > 0)
|| (ending != CRLF && self.crlf > 0)
|| (ending != LF && self.lf > 0)
}
}
pub(crate) async fn run(hook: &Hook, filenames: &[&Path]) -> Result<HookOutput> {
let args: Args = parse_hook_args(hook)?;
run_file_checks(
&args.filenames,
filenames,
*INTERNAL_CONCURRENCY,
|filename| fix_file(hook.project().relative_path(), filename, args.fix),
)
.await
}
async fn fix_file(file_base: &Path, filename: &Path, fix_mode: FixMode) -> Result<HookOutput> {
let file_path = file_base.join(filename);
let contents = fs_err::tokio::read(&file_path).await?;
if contents.is_empty() || contents.find_byte(0).is_some() {
return Ok(HookOutput::unchanged(0, Vec::new()));
}
let counts = count_line_endings(&contents);
let has_mixed_endings = counts.kind_count() > 1;
match fix_mode {
FixMode::No => {
if has_mixed_endings {
Ok(HookOutput::unchanged(
1,
format!("{}: mixed line endings\n", filename.display()).into_bytes(),
))
} else {
Ok(HookOutput::unchanged(0, Vec::new()))
}
}
FixMode::Auto => {
if !has_mixed_endings {
return Ok(HookOutput::unchanged(0, Vec::new()));
}
let target_ending = find_most_common_ending(&counts);
apply_line_ending(&file_path, &contents, target_ending).await?;
Ok(HookOutput::known(
1,
format!("Fixing {}\n", filename.display()).into_bytes(),
true,
))
}
_ => {
let target_ending = match fix_mode {
FixMode::LF => LF,
FixMode::CRLF => CRLF,
FixMode::CR => CR,
_ => unreachable!(),
};
let needs_fixing = counts.has_any_except(target_ending);
if needs_fixing {
apply_line_ending(&file_path, &contents, target_ending).await?;
Ok(HookOutput::known(
1,
format!("Fixing {}\n", filename.display()).into_bytes(),
true,
))
} else {
Ok(HookOutput::unchanged(0, Vec::new()))
}
}
}
}
fn count_line_endings(contents: &[u8]) -> LineEndingCounts {
let mut counts = LineEndingCounts::default();
let mut search_start = 0;
while let Some(offset) = memchr2(b'\r', b'\n', &contents[search_start..]) {
let index = search_start + offset;
match contents[index] {
b'\r' if contents.get(index + 1) == Some(&b'\n') => {
counts.crlf += 1;
search_start = index + 2;
}
b'\r' => {
counts.cr += 1;
search_start = index + 1;
}
b'\n' => {
counts.lf += 1;
search_start = index + 1;
}
_ => unreachable!(),
}
}
counts
}
fn find_most_common_ending(counts: &LineEndingCounts) -> &'static [u8] {
if counts.lf >= counts.crlf && counts.lf >= counts.cr {
LF
} else if counts.crlf >= counts.cr {
CRLF
} else {
CR
}
}
async fn apply_line_ending(filename: &Path, contents: &[u8], ending: &[u8]) -> Result<()> {
let mut new_contents = Vec::with_capacity(contents.len());
let mut line_start = 0;
let mut search_start = 0;
while let Some(offset) = memchr2(b'\r', b'\n', &contents[search_start..]) {
let index = search_start + offset;
let ending_len = if contents[index] == b'\r' && contents.get(index + 1) == Some(&b'\n') {
2
} else {
1
};
new_contents.extend_from_slice(&contents[line_start..index]);
new_contents.extend_from_slice(ending);
search_start = index + ending_len;
line_start = search_start;
}
if line_start < contents.len() {
new_contents.extend_from_slice(&contents[line_start..]);
new_contents.extend_from_slice(ending);
}
fs_err::tokio::write(filename, &new_contents).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use bstr::ByteSlice;
use std::path::{Path, PathBuf};
use tempfile::tempdir;
async fn create_test_file(
dir: &tempfile::TempDir,
name: &str,
content: &[u8],
) -> Result<PathBuf> {
let file_path = dir.path().join(name);
fs_err::tokio::write(&file_path, content).await?;
Ok(file_path)
}
#[tokio::test]
async fn test_auto_fix_crlf_wins() -> Result<()> {
let dir = tempdir()?;
let content = b"line1\nline2\r\nline3\r\n"; let file_path = create_test_file(&dir, "mixed_crlf.txt", content).await?;
let result = fix_file(Path::new(""), &file_path, FixMode::Auto).await?;
assert_eq!(result.exit_status, 1);
assert!(result.output.as_bytes().contains_str("Fixing"));
let new_content = fs_err::tokio::read(&file_path).await?;
assert_eq!(new_content, b"line1\r\nline2\r\nline3\r\n");
Ok(())
}
#[tokio::test]
async fn test_auto_fix_lf_wins() -> Result<()> {
let dir = tempdir()?;
let content = b"line1\nline2\nline3\r\n"; let file_path = create_test_file(&dir, "mixed_lf.txt", content).await?;
let result = fix_file(Path::new(""), &file_path, FixMode::Auto).await?;
assert_eq!(result.exit_status, 1);
assert!(result.output.as_bytes().contains_str("Fixing"));
let new_content = fs_err::tokio::read(&file_path).await?;
assert_eq!(new_content, b"line1\nline2\nline3\n");
Ok(())
}
#[tokio::test]
async fn test_auto_fix_tie_prefers_lf() -> Result<()> {
let dir = tempdir()?;
let content = b"line1\nline2\r\n"; let file_path = create_test_file(&dir, "mixed_tie.txt", content).await?;
let result = fix_file(Path::new(""), &file_path, FixMode::Auto).await?;
assert_eq!(result.exit_status, 1);
assert!(result.output.as_bytes().contains_str("Fixing"));
let new_content = fs_err::tokio::read(&file_path).await?;
assert_eq!(new_content, b"line1\nline2\n");
Ok(())
}
#[tokio::test]
async fn test_fix_no() -> Result<()> {
let dir = tempdir()?;
let content = b"line1\nline2\r\n";
let file_path = create_test_file(&dir, "mixed_no.txt", content).await?;
let result = fix_file(Path::new(""), &file_path, FixMode::No).await?;
assert_eq!(result.exit_status, 1);
assert!(result.output.as_bytes().contains_str("mixed line endings"));
let new_content = fs_err::tokio::read(&file_path).await?;
assert_eq!(new_content, content);
Ok(())
}
#[tokio::test]
async fn test_no_line_endings() -> Result<()> {
let dir = tempdir()?;
let content = b"some content";
let file_path = create_test_file(&dir, "no_endings.txt", content).await?;
let result = fix_file(Path::new(""), &file_path, FixMode::Auto).await?;
assert_eq!(result.exit_status, 0);
assert!(result.output.is_empty());
Ok(())
}
#[tokio::test]
async fn test_fix_with_cr_endings() -> Result<()> {
let dir = tempdir()?;
let content = b"line1\rline2\nline3\r\n";
let file_path = create_test_file(&dir, "all_mixed.txt", content).await?;
let result = fix_file(Path::new(""), &file_path, FixMode::Auto).await?;
assert_eq!(result.exit_status, 1);
assert!(result.output.as_bytes().contains_str("Fixing"));
let new_content = fs_err::tokio::read(&file_path).await?;
assert_eq!(new_content, b"line1\nline2\nline3\n");
fs_err::tokio::write(&file_path, content).await?;
let result = fix_file(Path::new(""), &file_path, FixMode::CRLF).await?;
assert_eq!(result.exit_status, 1);
assert!(result.output.as_bytes().contains_str("Fixing"));
let new_content = fs_err::tokio::read(&file_path).await?;
assert_eq!(new_content, b"line1\r\nline2\r\nline3\r\n");
Ok(())
}
}