use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
pub const MAX_FORMATTER_OUTPUT_BYTES: usize = 8 * 1024 * 1024;
pub const MAX_DIFF_CHARS: usize = 6000;
pub const DEFAULT_FORMATTER_TIMEOUT_SECS: u64 = 10;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormatterSpec {
pub command: String,
pub args: Vec<String>,
pub extensions: Vec<String>,
}
fn spec_for_extension<'a>(
specs: &'a [(String, FormatterSpec)],
path: &Path,
) -> Option<&'a (String, FormatterSpec)> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
specs.iter().find(|(_, s)| {
s.extensions
.iter()
.any(|e| e.trim_start_matches('.').to_ascii_lowercase() == ext)
})
}
async fn run_formatter(
spec: &FormatterSpec,
input: &[u8],
timeout: Duration,
) -> crate::error::Result<Option<Vec<u8>>> {
let mut cmd = tokio::process::Command::new(&spec.command);
cmd.args(&spec.args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.kill_on_drop(true);
#[cfg(unix)]
cmd.process_group(0);
let mut child = cmd.spawn().map_err(|e| {
crate::error::Error::tool("formatters", format!("spawn {}: {e}", spec.command))
})?;
#[cfg(unix)]
let child_pid = child.id();
let mut stdin = child
.stdin
.take()
.ok_or_else(|| crate::error::Error::tool("formatters", "no stdin"))?;
let mut stdout = child
.stdout
.take()
.ok_or_else(|| crate::error::Error::tool("formatters", "no stdout"))?;
let owned_input = input.to_vec();
let writer = tokio::spawn(async move {
let _ = stdin.write_all(&owned_input).await;
});
let reader = tokio::spawn(async move {
let mut buf = Vec::new();
let mut limited = (&mut stdout).take(MAX_FORMATTER_OUTPUT_BYTES as u64);
let _ = limited.read_to_end(&mut buf).await;
buf
});
let wait_result = tokio::time::timeout(timeout, child.wait()).await;
match wait_result {
Ok(Ok(status)) => {
writer.abort();
let output = reader.await.unwrap_or_default();
if !status.success() {
return Ok(None); }
if output.is_empty() {
return Ok(None); }
Ok(Some(output))
}
Ok(Err(e)) => Err(crate::error::Error::tool(
"formatters",
format!("wait failed: {e}"),
)),
Err(_elapsed) => {
#[cfg(unix)]
if let Some(pid) = child_pid {
crate::lsp::kill_process_group(pid);
}
writer.abort();
reader.abort();
Err(crate::error::Error::tool(
"formatters",
format!("timed out after {:?}", timeout),
))
}
}
}
#[derive(Debug)]
pub struct FormatObserver {
specs: Vec<(String, FormatterSpec)>,
root: PathBuf,
timeout: Duration,
diff_back: bool,
}
impl FormatObserver {
pub fn new(
root: PathBuf,
specs: Vec<(String, FormatterSpec)>,
timeout: Duration,
diff_back: bool,
) -> Self {
FormatObserver {
specs,
root,
timeout,
diff_back,
}
}
}
#[async_trait::async_trait]
impl crate::tools::WriteObserver for FormatObserver {
async fn before_write(&self, _path: &Path) {}
async fn after_write(&self, path: &Path) -> Option<String> {
if !crate::safe_path::contained(&self.root, path) {
return None; }
let (name, spec) = spec_for_extension(&self.specs, path)?;
let original = match tokio::fs::read(path).await {
Ok(b) => b,
Err(_) => return None, };
let formatted = match run_formatter(spec, &original, self.timeout).await {
Ok(Some(bytes)) => bytes,
Ok(None) => return None, Err(e) => {
tracing::warn!(formatter = %name, "formatters: {e} — leaving file untouched");
return None;
}
};
if formatted == original {
return None; }
if !crate::safe_path::contained(&self.root, path) {
return None;
}
if tokio::fs::write(path, &formatted).await.is_err() {
tracing::warn!(formatter = %name, path = %path.display(), "formatters: failed to write formatted output");
return None;
}
if !self.diff_back {
return None;
}
let original_text = String::from_utf8_lossy(&original);
let formatted_text = String::from_utf8_lossy(&formatted);
let mut diff = diffy::create_patch(&original_text, &formatted_text).to_string();
if diff.chars().count() > MAX_DIFF_CHARS {
diff = diff.chars().take(MAX_DIFF_CHARS).collect::<String>();
diff.push_str("\n... (diff truncated)");
}
let display_path = path.strip_prefix(&self.root).unwrap_or(path);
Some(format!(
"Formatter `{name}` reformatted {} — diff:\n{diff}",
display_path.display()
))
}
}
pub fn observer_for_config(config: &crate::Config) -> Option<std::sync::Arc<FormatObserver>> {
if !config.formatters_enabled {
return None;
}
if config.formatters.is_empty() {
eprintln!(
"warning: [capabilities.formatters] is enabled but no formatters are configured \
under [capabilities.formatters.<name>] — nothing will ever be reformatted"
);
}
Some(std::sync::Arc::new(FormatObserver::new(
config.cwd.clone(),
config.formatters.clone(),
Duration::from_secs(config.formatters_timeout_secs.max(1)),
config.formatters_diff_back,
)))
}