use std::path::Path;
use anyhow::Context;
use tempfile::NamedTempFile;
pub fn detect_eol(text: &str) -> &'static str {
let crlf = text.matches("\r\n").count();
let lf_only = text.matches('\n').count().saturating_sub(crlf);
if crlf > 0 && crlf >= lf_only {
"\r\n"
} else {
"\n"
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum EolMode {
#[default]
Keep,
Lf,
Crlf,
Cr,
}
pub struct WritePolicy {
pub ensure_final_newline: bool,
pub normalize_eol: EolMode,
pub trim_trailing_whitespace: bool,
pub collapse_blanks: bool,
}
impl WritePolicy {
pub fn is_noop(&self) -> bool {
!self.ensure_final_newline
&& matches!(self.normalize_eol, EolMode::Keep)
&& !self.trim_trailing_whitespace
&& !self.collapse_blanks
}
pub fn apply_override(&mut self, ov: &WritePolicyOverride) -> anyhow::Result<()> {
let parsed_eol = match ov.normalize_eol {
Some(ref s) => Some(parse_eol_mode(s)?),
None => None,
};
if let Some(v) = ov.ensure_final_newline {
self.ensure_final_newline = v;
}
if let Some(eol) = parsed_eol {
self.normalize_eol = eol;
}
if let Some(v) = ov.trim_trailing_whitespace {
self.trim_trailing_whitespace = v;
}
if let Some(v) = ov.collapse_blanks {
self.collapse_blanks = v;
}
Ok(())
}
}
impl Default for WritePolicy {
fn default() -> Self {
Self {
ensure_final_newline: false,
normalize_eol: EolMode::Keep,
trim_trailing_whitespace: false,
collapse_blanks: false,
}
}
}
#[derive(Debug, Default, Clone, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
#[serde(default)]
pub struct WritePolicyOverride {
pub ensure_final_newline: Option<bool>,
pub normalize_eol: Option<String>,
pub trim_trailing_whitespace: Option<bool>,
pub collapse_blanks: Option<bool>,
pub respect_editorconfig: Option<bool>,
}
pub fn parse_eol_mode(mode: &str) -> anyhow::Result<EolMode> {
match mode {
"lf" => Ok(EolMode::Lf),
"crlf" => Ok(EolMode::Crlf),
"cr" => Ok(EolMode::Cr),
"keep" => Ok(EolMode::Keep),
_ => {
anyhow::bail!(
"invalid normalize_eol value '{mode}': expected 'lf', 'crlf', 'cr', or 'keep'"
)
}
}
}
pub fn ensure_final_newline(content: &str, eol: EolMode) -> std::borrow::Cow<'_, str> {
use std::borrow::Cow;
let (suffix, already_ok) = match eol {
EolMode::Cr => ("\r", content.ends_with('\r')),
EolMode::Crlf => ("\r\n", content.ends_with("\r\n")),
EolMode::Lf => ("\n", content.ends_with('\n')),
EolMode::Keep => {
let detected = detect_eol(content);
(detected, content.ends_with(detected))
}
};
if content.is_empty() || already_ok {
Cow::Borrowed(content)
} else {
let mut s = String::with_capacity(content.len() + suffix.len());
s.push_str(content);
s.push_str(suffix);
Cow::Owned(s)
}
}
pub fn normalize_eol(content: &str, mode: EolMode) -> std::borrow::Cow<'_, str> {
use std::borrow::Cow;
match mode {
EolMode::Keep => Cow::Borrowed(content),
EolMode::Lf => {
let bytes = content.as_bytes();
let has_cr = memchr::memchr(b'\r', bytes).is_some();
if !has_cr {
Cow::Borrowed(content)
} else {
let without_crlf = content.replace("\r\n", "\n");
Cow::Owned(without_crlf.replace('\r', "\n"))
}
}
EolMode::Crlf => {
let bytes = content.as_bytes();
let has_bare_cr = memchr::memchr_iter(b'\r', bytes)
.any(|i| i + 1 >= bytes.len() || bytes[i + 1] != b'\n');
if has_bare_cr {
let lf_first = content.replace("\r\n", "\n").replace('\r', "\n");
Cow::Owned(lf_first.replace('\n', "\r\n"))
} else {
let has_bare_lf =
memchr::memchr_iter(b'\n', bytes).any(|i| i == 0 || bytes[i - 1] != b'\r');
if !has_bare_lf {
Cow::Borrowed(content)
} else {
let mut result = String::with_capacity(content.len() + content.len() / 10);
let mut last = 0;
for i in memchr::memchr_iter(b'\n', bytes) {
if i == 0 || bytes[i - 1] != b'\r' {
result.push_str(&content[last..i]);
result.push_str("\r\n");
} else {
result.push_str(&content[last..=i]);
}
last = i + 1;
}
if last < content.len() {
result.push_str(&content[last..]);
}
Cow::Owned(result)
}
}
}
EolMode::Cr => {
let bytes = content.as_bytes();
let has_lf = memchr::memchr(b'\n', bytes).is_some();
if !has_lf {
Cow::Borrowed(content)
} else {
let without_crlf = content.replace("\r\n", "\r");
Cow::Owned(without_crlf.replace('\n', "\r"))
}
}
}
}
pub fn trim_trailing_whitespace(content: &str) -> std::borrow::Cow<'_, str> {
use std::borrow::Cow;
let bytes = content.as_bytes();
let has_trailing = memchr::memchr2_iter(b'\r', b'\n', bytes).any(|i| {
if bytes[i] == b'\n' && i > 0 && bytes[i - 1] == b'\r' {
return false;
}
let prev = i.wrapping_sub(1);
prev < bytes.len() && matches!(bytes[prev], b' ' | b'\t')
}) || (!content.is_empty()
&& !matches!(bytes[bytes.len() - 1], b'\r' | b'\n')
&& matches!(bytes[bytes.len() - 1], b' ' | b'\t'));
if !has_trailing {
return Cow::Borrowed(content);
}
let mut result = String::with_capacity(content.len());
let mut rest = content;
while !rest.is_empty() {
let rest_bytes = rest.as_bytes();
if let Some(pos) = memchr::memchr2(b'\r', b'\n', rest_bytes) {
let (line, ending, advance) = if rest_bytes[pos] == b'\n' {
(&rest[..pos], "\n", pos + 1)
} else if pos + 1 < rest_bytes.len() && rest_bytes[pos + 1] == b'\n' {
(&rest[..pos], "\r\n", pos + 2)
} else {
(&rest[..pos], "\r", pos + 1)
};
result.push_str(line.trim_end_matches([' ', '\t']));
result.push_str(ending);
rest = &rest[advance..];
} else {
result.push_str(rest.trim_end_matches([' ', '\t']));
break;
}
}
Cow::Owned(result)
}
pub fn collapse_blanks(content: &str) -> std::borrow::Cow<'_, str> {
use std::borrow::Cow;
let bytes = content.as_bytes();
let mut prev_blank = false;
let mut needs_collapse = false;
let mut scan = content.as_bytes();
while let Some(pos) = memchr::memchr2(b'\r', b'\n', scan) {
let end = if scan[pos] == b'\n' {
pos + 1
} else if pos + 1 < scan.len() && scan[pos + 1] == b'\n' {
pos + 2
} else {
pos + 1
};
let line = &content[content.len() - scan.len()..content.len() - scan.len() + pos];
let is_blank = line.trim().is_empty();
if is_blank && prev_blank {
needs_collapse = true;
break;
}
prev_blank = is_blank;
scan = &scan[end..];
}
if !needs_collapse && prev_blank && !scan.is_empty() {
let trailing = std::str::from_utf8(scan).unwrap_or("");
if trailing.trim().is_empty() {
needs_collapse = true;
}
}
if !needs_collapse {
return Cow::Borrowed(content);
}
let mut result = String::with_capacity(bytes.len());
let mut prev_blank = false;
let mut rest = content;
while !rest.is_empty() {
let rest_bytes = rest.as_bytes();
if let Some(pos) = memchr::memchr2(b'\r', b'\n', rest_bytes) {
let end = if rest_bytes[pos] == b'\n' {
pos + 1
} else if pos + 1 < rest_bytes.len() && rest_bytes[pos + 1] == b'\n' {
pos + 2
} else {
pos + 1
};
let line_content = &rest[..pos];
let line_with_ending = &rest[..end];
let is_blank = line_content.trim().is_empty();
if is_blank && prev_blank {
} else {
result.push_str(line_with_ending);
}
prev_blank = is_blank;
rest = &rest[end..];
} else {
let is_blank = rest.trim().is_empty();
if !(is_blank && prev_blank) {
result.push_str(rest);
}
break;
}
}
Cow::Owned(result)
}
pub fn dedent_content(
content: &str,
spec: &str,
line_range: Option<(usize, Option<usize>)>,
) -> String {
let lines: Vec<&str> = content.split('\n').collect();
let (start, end) = match line_range {
Some((s, e)) => (s.max(1), e.unwrap_or(lines.len())),
None => (1, lines.len()),
};
let in_range = |i: usize| {
let line_num = i + 1; line_num >= start && line_num <= end
};
match spec {
"auto" => {
let min_indent = lines
.iter()
.enumerate()
.filter(|&(i, _)| in_range(i))
.filter(|&(_, line)| !line.trim().is_empty())
.map(|(_, line)| line.len() - line.trim_start().len())
.filter(|&n| n > 0)
.min()
.unwrap_or(0);
if min_indent == 0 {
return content.to_string();
}
dedent_by_n(&lines, min_indent, &in_range)
}
"tab" => {
let result: Vec<String> = lines
.iter()
.enumerate()
.map(|(i, line)| {
if !in_range(i) || line.trim().is_empty() {
line.to_string()
} else if let Some(rest) = line.strip_prefix('\t') {
rest.to_string()
} else {
line.to_string()
}
})
.collect();
result.join("\n")
}
n => {
let count: usize = n.parse().unwrap_or(0);
if count == 0 {
return content.to_string();
}
dedent_by_n(&lines, count, &in_range)
}
}
}
fn dedent_by_n(lines: &[&str], n: usize, in_range: &dyn Fn(usize) -> bool) -> String {
let result: Vec<String> = lines
.iter()
.enumerate()
.map(|(i, line)| {
if !in_range(i) || line.trim().is_empty() {
line.to_string()
} else {
let leading_spaces = line.len() - line.trim_start().len();
let strip = n.min(leading_spaces);
line[strip..].to_string()
}
})
.collect();
result.join("\n")
}
pub fn indent_content(
content: &str,
spec: &str,
line_range: Option<(usize, Option<usize>)>,
) -> String {
let lines: Vec<&str> = content.split('\n').collect();
let (start, end) = match line_range {
Some((s, e)) => (s.max(1), e.unwrap_or(lines.len())),
None => (1, lines.len()),
};
let prefix = match spec {
"tab" => "\t".to_string(),
n => {
let count: usize = n.parse().unwrap_or(0);
" ".repeat(count)
}
};
if prefix.is_empty() {
return content.to_string();
}
let result: Vec<String> = lines
.iter()
.enumerate()
.map(|(i, line)| {
let line_num = i + 1;
if line_num < start || line_num > end || line.trim().is_empty() {
line.to_string()
} else {
format!("{prefix}{line}")
}
})
.collect();
result.join("\n")
}
pub fn apply_policy<'a>(content: &'a str, policy: &WritePolicy) -> std::borrow::Cow<'a, str> {
use std::borrow::Cow;
if policy.is_noop() {
return Cow::Borrowed(content);
}
let mut s = if policy.trim_trailing_whitespace {
trim_trailing_whitespace(content)
} else {
Cow::Borrowed(content)
};
if !matches!(policy.normalize_eol, EolMode::Keep)
&& let Cow::Owned(new) = normalize_eol(&s, policy.normalize_eol)
{
s = Cow::Owned(new);
}
if policy.collapse_blanks
&& let Cow::Owned(new) = collapse_blanks(&s)
{
s = Cow::Owned(new);
}
if policy.ensure_final_newline
&& let Cow::Owned(new) = ensure_final_newline(&s, policy.normalize_eol)
{
s = Cow::Owned(new);
}
s
}
pub fn policy_from_flags(
global: &crate::cli::global::GlobalFlags,
#[allow(unused_variables)] file_path: Option<&std::path::Path>,
) -> WritePolicy {
let efn = global.ensure_final_newline;
let eol = global.normalize_eol;
let ttw = global.trim_trailing_whitespace;
let respect_ec = if cfg!(feature = "cli") {
global.respect_editorconfig
} else {
false
};
let (efn, eol, ttw) = if respect_ec {
#[cfg(feature = "cli")]
if let Some(p) = file_path {
#[allow(unused_variables)]
if let Ok(props) = ec4rs::properties_of(p) {
let mut new_efn = efn;
let mut new_eol = eol;
let mut new_ttw = ttw;
if !global.ensure_final_newline
&& let Ok(ec4rs::property::FinalNewline::Value(true)) =
props.get::<ec4rs::property::FinalNewline>()
{
new_efn = true;
}
if global.normalize_eol.is_none()
&& let Ok(val) = props.get::<ec4rs::property::EndOfLine>()
{
new_eol = Some(match val {
ec4rs::property::EndOfLine::Lf => EolMode::Lf,
ec4rs::property::EndOfLine::CrLf => EolMode::Crlf,
ec4rs::property::EndOfLine::Cr => EolMode::Cr,
});
}
if !global.trim_trailing_whitespace
&& let Ok(ec4rs::property::TrimTrailingWs::Value(true)) =
props.get::<ec4rs::property::TrimTrailingWs>()
{
new_ttw = true;
}
(new_efn, new_eol, new_ttw)
} else {
(efn, eol, ttw)
}
} else {
(efn, eol, ttw)
}
#[cfg(not(feature = "cli"))]
{
(efn, eol, ttw)
}
} else {
(efn, eol, ttw)
};
WritePolicy {
ensure_final_newline: efn,
normalize_eol: eol.unwrap_or(EolMode::Keep),
trim_trailing_whitespace: ttw,
collapse_blanks: global.collapse_blanks,
}
}
pub(crate) fn atomic_create_new(
path: &Path,
content: &str,
policy: &WritePolicy,
) -> anyhow::Result<()> {
let final_content = apply_policy(content, policy);
let parent = path
.parent()
.context("cannot determine parent directory of target path")?;
let tmp = NamedTempFile::new_in(parent)
.with_context(|| format!("failed to create tempfile in {}", parent.display()))?;
std::fs::write(tmp.path(), final_content.as_bytes())
.with_context(|| format!("failed to write to tempfile {}", tmp.path().display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o644);
std::fs::set_permissions(tmp.path(), perms)
.with_context(|| format!("failed to set permissions on {}", tmp.path().display()))?;
}
tmp.persist_noclobber(path).map_err(|e| {
if e.error.kind() == std::io::ErrorKind::AlreadyExists {
anyhow::anyhow!("file already exists: {}", path.display())
} else {
anyhow::Error::from(e.error)
.context(format!("failed to persist tempfile to {}", path.display()))
}
})?;
Ok(())
}
pub(crate) fn atomic_write(path: &Path, content: &str, policy: &WritePolicy) -> anyhow::Result<()> {
let final_content = apply_policy(content, policy);
let resolved;
let write_path = if path.is_symlink() {
resolved = std::fs::canonicalize(path)
.with_context(|| format!("failed to resolve symlink {}", path.display()))?;
resolved.as_path()
} else {
path
};
let original_perms = std::fs::metadata(write_path).ok().map(|m| m.permissions());
let parent = write_path
.parent()
.context("cannot determine parent directory of target path")?;
let tmp = NamedTempFile::new_in(parent)
.with_context(|| format!("failed to create tempfile in {}", parent.display()))?;
std::fs::write(tmp.path(), final_content.as_bytes())
.with_context(|| format!("failed to write to tempfile {}", tmp.path().display()))?;
if let Some(perms) = original_perms {
std::fs::set_permissions(tmp.path(), perms)
.with_context(|| format!("failed to set permissions on {}", tmp.path().display()))?;
}
tmp.persist(write_path)
.with_context(|| format!("failed to persist tempfile to {}", write_path.display()))?;
Ok(())
}
#[cfg(feature = "cli")]
pub(crate) fn run_format_command(
global: &crate::cli::global::GlobalFlags,
cwd: &std::path::Path,
) -> anyhow::Result<()> {
run_format_command_ext(global, cwd, None, global.format_config.as_ref())
}
#[cfg(feature = "cli")]
pub(crate) fn run_format_command_ext(
global: &crate::cli::global::GlobalFlags,
cwd: &std::path::Path,
modified_paths: Option<&[&str]>,
format_config: Option<&crate::config::FormatConfig>,
) -> anyhow::Result<()> {
if global.no_format {
return Ok(());
}
let timeout_secs = global.format_timeout.unwrap_or(30);
if let Some(cmd) = global.format.as_deref() {
let result = crate::exec::run_with_timeout(cmd, timeout_secs, cwd)?;
if !result.status.success() {
let stderr = if result.stderr_head.is_empty() {
String::new()
} else {
format!(": {}", result.stderr_head)
};
anyhow::bail!("format command failed ({}){stderr}", cmd);
}
return Ok(());
}
let config = match format_config {
Some(c) => c,
None => return Ok(()),
};
if config.auto != Some(true) {
return Ok(());
}
if let Some(ref cmd) = config.command {
let result = crate::exec::run_with_timeout(cmd, timeout_secs, cwd)?;
if !result.status.success() {
eprintln!(
"warning: format command failed ({}): {}",
cmd,
result.stderr_head.trim()
);
}
return Ok(());
}
if config.by_extension.is_empty() {
return Ok(());
}
let discovered: Vec<String>;
let discovered_refs: Vec<&str>;
let paths: &[&str] = match modified_paths {
Some(p) => p,
None => {
discovered = discover_modified_files(cwd);
if discovered.is_empty() {
return Ok(());
}
discovered_refs = discovered.iter().map(|s| s.as_str()).collect();
&discovered_refs
}
};
let mut by_ext: std::collections::HashMap<&str, Vec<&str>> = std::collections::HashMap::new();
for path in paths {
if let Some(ext) = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
&& config.by_extension.contains_key(ext)
{
by_ext.entry(ext).or_default().push(path);
}
}
for (ext, files) in &by_ext {
if let Some(cmd_template) = config.by_extension.get(*ext) {
for file in files {
let cmd = format!("{cmd_template} {}", shell_escape(file));
match crate::exec::run_with_timeout(&cmd, timeout_secs, cwd) {
Ok(result) if !result.status.success() => {
eprintln!(
"warning: formatter for .{ext} failed on {file}: {}",
result.stderr_head.trim()
);
}
Err(e) => {
eprintln!("warning: formatter for .{ext} error on {file}: {e}");
}
_ => {}
}
}
}
}
Ok(())
}
#[cfg(feature = "cli")]
fn discover_modified_files(cwd: &std::path::Path) -> Vec<String> {
let output = std::process::Command::new("git")
.args(["diff", "--name-only", "HEAD"])
.current_dir(cwd)
.output();
match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect(),
_ => Vec::new(),
}
}
#[cfg(feature = "cli")]
fn shell_escape(path: &str) -> String {
if path.bytes().all(|b| {
b.is_ascii_alphanumeric()
|| b == b'/'
|| b == b'.'
|| b == b'_'
|| b == b'-'
|| (cfg!(windows) && b == b'\\')
}) {
return path.to_string();
}
#[cfg(windows)]
{
format!("\"{}\"", path.replace('"', "\"\""))
}
#[cfg(not(windows))]
{
format!("'{}'", path.replace('\'', "'\\''"))
}
}
#[path = "write_tests.rs"]
#[cfg(test)]
mod tests;