use std::io::{Read, Seek, SeekFrom, Write};
use std::path::Path;
use anyhow::Result;
use crate::hook::Hook;
use crate::hooks::HookOutput;
use crate::hooks::pre_commit_hooks::{FilenamesArgs, parse_hook_args, run_file_checks};
use crate::run::INTERNAL_CONCURRENCY;
pub(crate) async fn run(hook: &Hook, filenames: &[&Path]) -> Result<HookOutput> {
let args: FilenamesArgs = parse_hook_args(hook)?;
run_file_checks(
&args.filenames,
filenames,
*INTERNAL_CONCURRENCY,
|filename| fix_file(hook.project().relative_path(), filename),
)
.await
}
async fn fix_file(file_base: &Path, filename: &Path) -> Result<HookOutput> {
let file_path = file_base.join(filename);
let modified = tokio::task::spawn_blocking(move || fix_file_sync(&file_path)).await??;
if modified {
Ok(HookOutput::known(
1,
format!("Fixing {}\n", filename.display()).into_bytes(),
true,
))
} else {
Ok(HookOutput::unchanged(0, Vec::new()))
}
}
fn fix_file_sync(file_path: &Path) -> Result<bool> {
let file_size = fs_err::metadata(file_path)?.len();
if file_size == 0 {
return Ok(false);
}
let mut file = fs_err::OpenOptions::new()
.read(true)
.write(true)
.open(file_path)?;
match find_last_non_ending(&mut file)? {
(None, _) => {
file.set_len(0)?;
file.flush()?;
Ok(true)
}
(Some(pos), None) => {
file.seek(SeekFrom::Start(pos + 1))?;
file.write_all(b"\n")?;
file.flush()?;
Ok(true)
}
(Some(pos), Some(line_ending)) => {
let new_size = pos + 1 + line_ending.len() as u64;
if file_size == new_size {
return Ok(false);
}
file.set_len(new_size)?;
Ok(true)
}
}
}
fn determine_line_ending(first: u8, second: u8) -> Option<&'static str> {
if first == b'\r' && second == b'\n' {
Some("\r\n")
} else if first == b'\n' {
Some("\n")
} else if first == b'\r' {
Some("\r")
} else {
None
}
}
fn find_last_non_ending<T>(reader: &mut T) -> Result<(Option<u64>, Option<&str>)>
where
T: Read + Seek,
{
const MAX_SCAN_SIZE: usize = 4 * 1024;
let data_len = reader.seek(SeekFrom::End(0))?;
if data_len == 0 {
return Ok((None, None));
}
let mut read_len = 0;
let mut next_char = 0;
let mut buf = [0u8; MAX_SCAN_SIZE];
let mut line_ending = None;
while read_len < data_len {
let block_size = MAX_SCAN_SIZE.min(usize::try_from(data_len - read_len)?);
reader.seek(SeekFrom::Current(-i64::try_from(block_size).unwrap()))?;
reader.read_exact(&mut buf[..block_size])?;
read_len += block_size as u64;
let mut pos = block_size;
while pos > 0 {
pos -= 1;
if matches!(buf[pos], b'\n' | b'\r') {
line_ending = if pos + 1 == block_size {
determine_line_ending(buf[pos], next_char)
} else {
determine_line_ending(buf[pos], buf[pos + 1])
};
} else {
return Ok((Some(data_len - read_len + pos as u64), line_ending));
}
}
next_char = buf[0];
}
Ok((None, line_ending))
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Ok;
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_no_line_ending_1() -> Result<()> {
let dir = tempdir()?;
let content = b"line1\nline2\nline3";
let file_path = create_test_file(&dir, "unix_no_eof.txt", content).await?;
let result = fix_file(Path::new(""), &file_path).await?;
assert_eq!(result.exit_status, 1, "Should fix the file");
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");
let content = b"line1\r\nline2\nline3\r\nline4";
let file_path = create_test_file(&dir, "mixed.txt", content).await?;
let result = fix_file(Path::new(""), &file_path).await?;
assert_eq!(result.exit_status, 1, "Should fix the file");
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\nline3\r\nline4\n");
let content = b"line1\r\nline2\r\nline3";
let file_path = create_test_file(&dir, "windows_no_eof.txt", content).await?;
let result = fix_file(Path::new(""), &file_path).await?;
assert_eq!(result.exit_status, 1, "Should fix the file");
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\n");
Ok(())
}
#[tokio::test]
async fn test_already_has_correct_windows_ending() -> Result<()> {
let dir = tempdir()?;
let content = b"line1\r\nline2\r\nline3\r\n";
let file_path = create_test_file(&dir, "windows_with_eof.txt", content).await?;
let result = fix_file(Path::new(""), &file_path).await?;
assert_eq!(result.exit_status, 0, "Should not change the file");
assert!(result.output.is_empty());
let new_content = fs_err::tokio::read(&file_path).await?;
assert_eq!(new_content, content);
Ok(())
}
#[tokio::test]
async fn test_already_has_correct_unix_ending() -> Result<()> {
let dir = tempdir()?;
let content = b"line1\nline2\nline3\n";
let file_path = create_test_file(&dir, "unix_with_eof.txt", content).await?;
let result = fix_file(Path::new(""), &file_path).await?;
assert_eq!(result.exit_status, 0, "Should not change the file");
assert!(result.output.is_empty());
let new_content = fs_err::tokio::read(&file_path).await?;
assert_eq!(new_content, content);
Ok(())
}
#[tokio::test]
async fn test_empty_file() -> Result<()> {
let dir = tempdir()?;
let content = b"";
let file_path = create_test_file(&dir, "empty.txt", content).await?;
let result = fix_file(Path::new(""), &file_path).await?;
assert_eq!(result.exit_status, 0, "Should not change empty file");
assert!(result.output.is_empty());
let new_content = fs_err::tokio::read(&file_path).await?;
assert_eq!(new_content, b"");
Ok(())
}
#[tokio::test]
async fn test_excess_newlines_removal() -> Result<()> {
let dir = tempdir()?;
let content = b"line1\nline2\n\n\n\n";
let file_path = create_test_file(&dir, "excess_newlines.txt", content).await?;
let result = fix_file(Path::new(""), &file_path).await?;
assert_eq!(result.exit_status, 1, "Should fix the file");
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_excess_crlf_removal() -> Result<()> {
let dir = tempdir()?;
let content = b"line1\r\nline2\r\n\r\n\r\n";
let file_path = create_test_file(&dir, "excess_crlf.txt", content).await?;
let result = fix_file(Path::new(""), &file_path).await?;
assert_eq!(result.exit_status, 1, "Should fix the file");
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\n");
Ok(())
}
#[tokio::test]
async fn test_all_newlines_make_empty() -> Result<()> {
let dir = tempdir()?;
let content = b"\n\n\n\n";
let file_path = create_test_file(&dir, "only_newlines.txt", content).await?;
let result = fix_file(Path::new(""), &file_path).await?;
assert_eq!(result.exit_status, 1, "Should fix the file");
assert!(result.output.as_bytes().contains_str("Fixing"));
let new_content = fs_err::tokio::read(&file_path).await?;
assert_eq!(new_content, b"");
Ok(())
}
}