use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{anyhow, Context, Result};
use ec4rs::property::{EndOfLine, IndentSize, IndentStyle, TabWidth};
use ec4rs::{properties_of, Properties};
pub(super) fn find_git_root() -> Result<PathBuf> {
let current_dir = std::env::current_dir().context("Failed to get current directory")?;
let mut path = current_dir.as_path();
loop {
if path.join(".git").exists() {
return Ok(path.to_path_buf());
}
match path.parent() {
Some(parent) => path = parent,
None => return Err(anyhow!("Not in a git repository")),
}
}
}
pub(super) fn get_git_files(git_root: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
let output = Command::new("git")
.args(["ls-files"])
.current_dir(git_root)
.output()
.context("Failed to execute 'git ls-files'")?;
if !output.status.success() {
return Err(anyhow!(
"Git ls-files failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let tracked_files = String::from_utf8_lossy(&output.stdout);
for line in tracked_files.lines() {
if !line.trim().is_empty() {
files.push(git_root.join(line.trim()));
}
}
let output = Command::new("git")
.args(["status", "--porcelain", "--untracked-files=all"])
.current_dir(git_root)
.output()
.context("Failed to execute 'git status'")?;
if !output.status.success() {
return Err(anyhow!(
"Git status failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let status_output = String::from_utf8_lossy(&output.stdout);
for line in status_output.lines() {
if line.len() >= 3 {
let status = &line[0..2];
let file_path = &line[3..];
if status == "??" {
let full_path = git_root.join(file_path.trim());
if full_path.exists() && full_path.is_file() {
files.push(full_path);
}
}
}
}
let mut final_files = Vec::new();
for file in files {
if is_text_file(&file)? {
final_files.push(file);
}
}
Ok(final_files)
}
pub(super) fn is_text_file(file_path: &Path) -> Result<bool> {
let output = Command::new("git")
.args(["check-attr", "--all", &file_path.to_string_lossy()])
.output()
.context("Failed to execute 'git check-attr'")?;
if !output.status.success() {
return Ok(is_likely_text_file(file_path));
}
let attr_output = String::from_utf8_lossy(&output.stdout);
if attr_output.contains("binary: set") || attr_output.contains("binary: true") {
return Ok(false);
}
Ok(is_likely_text_file(file_path))
}
pub(super) fn is_likely_text_file(file_path: &Path) -> bool {
let text_extensions = [
"rs",
"py",
"js",
"ts",
"jsx",
"tsx",
"go",
"java",
"kt",
"scala",
"cpp",
"c",
"h",
"hpp",
"cc",
"cxx",
"cs",
"php",
"rb",
"pl",
"pm",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"bat",
"cmd",
"html",
"htm",
"xml",
"xhtml",
"svg",
"css",
"scss",
"sass",
"less",
"json",
"yaml",
"yml",
"toml",
"ini",
"cfg",
"conf",
"config",
"md",
"markdown",
"rst",
"txt",
"text",
"rtf",
"sql",
"ddl",
"dml",
"graphql",
"gql",
"dockerfile",
"makefile",
"cmake",
"gradle",
"maven",
"vue",
"svelte",
"astro",
"ejs",
"hbs",
"mustache",
"r",
"m",
"swift",
"dart",
"lua",
"nim",
"zig",
"v",
];
if let Some(extension) = file_path.extension() {
let ext = extension.to_string_lossy().to_lowercase();
if text_extensions.contains(&ext.as_str()) {
return true;
}
}
let filename = file_path
.file_name()
.map(|n| n.to_string_lossy().to_lowercase())
.unwrap_or_default();
let text_filenames = [
"dockerfile",
"makefile",
"rakefile",
"gemfile",
"podfile",
"license",
"readme",
"changelog",
"authors",
"contributors",
"copying",
"install",
"news",
"todo",
"version",
".gitignore",
".gitattributes",
".editorconfig",
".eslintrc",
".prettierrc",
".babelrc",
".nvmrc",
".rustfmt.toml",
];
for pattern in &text_filenames {
if filename.contains(pattern) {
return true;
}
}
if file_path.extension().is_none() {
if let Ok(content) = fs::read_to_string(file_path) {
if content.starts_with("#!") {
return true;
}
}
}
false
}
pub(super) fn format_file(file_path: &Path, apply: bool, verbose: bool) -> Result<usize> {
let content = fs::read_to_string(file_path)
.with_context(|| format!("Failed to read file: {}", file_path.display()))?;
let properties = properties_of(file_path).map_err(|e| {
anyhow!(
"Failed to get editorconfig properties for {}: {}",
file_path.display(),
e
)
})?;
if verbose {
println!(" EditorConfig properties for {}:", file_path.display());
if let Ok(charset) = properties.get::<ec4rs::property::Charset>() {
println!(" charset: {}", charset);
}
if let Ok(end_of_line) = properties.get::<EndOfLine>() {
println!(" end_of_line: {:?}", end_of_line);
}
if let Ok(indent_style) = properties.get::<IndentStyle>() {
println!(" indent_style: {:?}", indent_style);
}
if let Ok(indent_size) = properties.get::<IndentSize>() {
println!(" indent_size: {:?}", indent_size);
}
if let Ok(tab_width) = properties.get::<TabWidth>() {
println!(" tab_width: {:?}", tab_width);
}
if let Ok(insert_final_newline) = properties.get::<ec4rs::property::FinalNewline>() {
println!(" insert_final_newline: {}", insert_final_newline);
}
if let Ok(trim_trailing_whitespace) = properties.get::<ec4rs::property::TrimTrailingWs>() {
println!(" trim_trailing_whitespace: {}", trim_trailing_whitespace);
}
if let Ok(max_line_length) = properties.get::<ec4rs::property::MaxLineLen>() {
println!(" max_line_length: {:?}", max_line_length);
}
}
let mut changes = 0;
let mut new_content = content.clone();
if let Ok(line_ending) = properties.get::<EndOfLine>() {
let target_ending = match line_ending {
EndOfLine::Lf => "\n",
EndOfLine::CrLf => "\r\n",
EndOfLine::Cr => "\r",
};
let normalized = new_content.replace("\r\n", "\n").replace('\r', "\n");
let with_target_endings = if target_ending != "\n" {
normalized.replace('\n', target_ending)
} else {
normalized
};
if with_target_endings != new_content {
new_content = with_target_endings;
changes += 1;
if verbose {
println!(" - Fixed line endings to {:?}", line_ending);
}
}
}
if let Ok(charset) = properties.get::<ec4rs::property::Charset>() {
if verbose {
println!(" - Verified charset: {}", charset);
}
}
if let Ok(indent_style) = properties.get::<IndentStyle>() {
let indent_size = get_effective_indent_size(&properties);
if let Ok(indented_content) =
apply_indentation(&new_content, indent_style, indent_size, verbose)
{
if indented_content != new_content {
new_content = indented_content;
changes += 1;
}
}
}
if let Ok(ec4rs::property::TrimTrailingWs::Value(true)) =
properties.get::<ec4rs::property::TrimTrailingWs>()
{
let trimmed_content = trim_trailing_whitespace(&new_content);
if trimmed_content != new_content {
new_content = trimmed_content;
changes += 1;
if verbose {
println!(" - Trimmed trailing whitespace");
}
}
}
if let Ok(final_newline) = properties.get::<ec4rs::property::FinalNewline>() {
let ec4rs::property::FinalNewline::Value(insert_final_newline) = final_newline;
let processed_content = handle_final_newline(&new_content, insert_final_newline);
if processed_content != new_content {
new_content = processed_content;
changes += 1;
if verbose {
if insert_final_newline {
println!(" - Added final newline");
} else {
println!(" - Removed final newline");
}
}
}
}
if let Ok(max_line_length) = properties.get::<ec4rs::property::MaxLineLen>() {
match max_line_length {
ec4rs::property::MaxLineLen::Value(length) => {
check_line_length(&new_content, length as u32, file_path, verbose);
}
ec4rs::property::MaxLineLen::Off => {
}
}
}
if apply && changes > 0 {
fs::write(file_path, &new_content)
.with_context(|| format!("Failed to write file: {}", file_path.display()))?;
}
Ok(changes)
}
pub(super) fn get_effective_indent_size(properties: &Properties) -> usize {
if let Ok(indent_size) = properties.get::<IndentSize>() {
match indent_size {
IndentSize::Value(size) => size,
IndentSize::UseTabWidth => {
if let Ok(TabWidth::Value(width)) = properties.get::<TabWidth>() {
width
} else {
2 }
}
}
} else if let Ok(TabWidth::Value(width)) = properties.get::<TabWidth>() {
width
} else {
2 }
}
pub(super) fn apply_indentation(
content: &str,
indent_style: IndentStyle,
indent_size: usize,
verbose: bool,
) -> Result<String> {
let lines: Vec<&str> = content.lines().collect();
let mut converted_lines = Vec::new();
let mut indent_changes = 0;
for line in lines {
if line.trim().is_empty() {
converted_lines.push(line.to_string());
continue;
}
let (leading_whitespace, rest) = split_leading_whitespace(line);
let converted_indent =
convert_indentation_smart(&leading_whitespace, indent_style, indent_size);
if converted_indent != leading_whitespace {
indent_changes += 1;
}
converted_lines.push(format!("{}{}", converted_indent, rest));
}
if indent_changes > 0 && verbose {
println!(
" - Converted indentation to {:?} (size: {}) on {} lines",
indent_style, indent_size, indent_changes
);
}
let line_ending = detect_line_ending(content);
let result = converted_lines.join(line_ending);
let should_end_with_newline = content.ends_with('\n') || content.ends_with('\r');
if should_end_with_newline && !result.ends_with('\n') && !result.ends_with('\r') {
Ok(format!("{}{}", result, line_ending))
} else {
Ok(result)
}
}
pub(super) fn split_leading_whitespace(line: &str) -> (String, &str) {
let trimmed = line.trim_start();
let leading_len = line.len() - trimmed.len();
(line[..leading_len].to_string(), trimmed)
}
pub(super) fn convert_indentation_smart(
whitespace: &str,
target_style: IndentStyle,
target_size: usize,
) -> String {
match target_style {
IndentStyle::Tabs => {
let indent_level = determine_indentation_level(whitespace, target_size);
"\t".repeat(indent_level)
}
IndentStyle::Spaces => {
if whitespace.chars().all(|c| c == ' ') {
let space_count = whitespace.len();
if space_count % target_size == 0 {
whitespace.to_string()
} else {
let indent_level = determine_indentation_level(whitespace, target_size);
" ".repeat(indent_level * target_size)
}
} else {
let indent_level = determine_indentation_level(whitespace, target_size);
" ".repeat(indent_level * target_size)
}
}
}
}
pub(super) fn determine_indentation_level(whitespace: &str, reference_size: usize) -> usize {
if whitespace.is_empty() {
return 0;
}
let mut level = 0;
let chars: Vec<char> = whitespace.chars().collect();
let mut i = 0;
while i < chars.len() {
match chars[i] {
'\t' => {
level += 1;
i += 1;
}
' ' => {
let start_i = i;
while i < chars.len() && chars[i] == ' ' {
i += 1;
}
let space_count = i - start_i;
if space_count > 0 {
let detected_indent_size =
detect_space_indent_size(space_count, reference_size);
level += space_count / detected_indent_size;
}
}
_ => break, }
}
level
}
pub(super) fn detect_space_indent_size(space_count: usize, _reference_size: usize) -> usize {
for size in [4, 2, 8, 1] {
if space_count % size == 0 {
return size;
}
}
4
}
pub(super) fn trim_trailing_whitespace(content: &str) -> String {
let line_ending = detect_line_ending(content);
let lines: Vec<String> = content
.lines()
.map(|line| line.trim_end().to_string())
.collect();
let result = lines.join(line_ending);
if content.ends_with('\n') || content.ends_with('\r') {
if !result.ends_with('\n') && !result.ends_with('\r') {
format!("{}{}", result, line_ending)
} else {
result
}
} else {
result
}
}
pub(super) fn handle_final_newline(content: &str, insert_final_newline: bool) -> String {
let line_ending = detect_line_ending(content);
let ends_with_newline = content.ends_with('\n') || content.ends_with('\r');
if insert_final_newline {
if !content.is_empty() && !ends_with_newline {
format!("{}{}", content, line_ending)
} else {
content.to_string()
}
} else if ends_with_newline {
content.trim_end_matches(&['\n', '\r'][..]).to_string()
} else {
content.to_string()
}
}
pub(super) fn detect_line_ending(content: &str) -> &str {
if content.contains("\r\n") {
"\r\n"
} else if content.contains('\r') {
"\r"
} else {
"\n"
}
}
pub(super) fn check_line_length(
content: &str,
max_line_length: u32,
file_path: &Path,
verbose: bool,
) {
if !verbose {
return;
}
let long_lines: Vec<usize> = content
.lines()
.enumerate()
.filter_map(|(i, line)| {
if line.len() > max_line_length as usize {
Some(i + 1)
} else {
None
}
})
.take(5) .collect();
if !long_lines.is_empty() {
println!(
" - Warning: {} lines exceed max length ({}) in {}",
long_lines.len(),
max_line_length,
file_path.display()
);
if verbose {
for line_num in long_lines {
println!(" Line {}", line_num);
}
}
}
}