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,
)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::WriteObserver;
fn tmp(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"supercode-formatters-test-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn uppercase_spec() -> FormatterSpec {
FormatterSpec {
command: "sh".to_string(),
args: vec!["-c".to_string(), "tr 'a-z' 'A-Z'".to_string()],
extensions: vec![".txt".to_string()],
}
}
fn failing_spec() -> FormatterSpec {
FormatterSpec {
command: "sh".to_string(),
args: vec!["-c".to_string(), "exit 1".to_string()],
extensions: vec![".txt".to_string()],
}
}
fn hanging_spec() -> FormatterSpec {
FormatterSpec {
command: "sh".to_string(),
args: vec!["-c".to_string(), "cat >/dev/null; sleep 3600".to_string()],
extensions: vec![".txt".to_string()],
}
}
#[tokio::test]
async fn observer_for_config_is_none_when_disabled_default_off_byte_identity() {
let config = crate::Config::builder().model("m").build();
assert!(!config.formatters_enabled);
assert!(observer_for_config(&config).is_none());
}
#[tokio::test]
async fn diff_back_true_surfaces_the_formatted_content_in_the_annotation() {
let project = tmp("diffback-on");
let file = project.join("f.txt");
std::fs::write(&file, "hello world\n").unwrap();
let observer = FormatObserver::new(
project.clone(),
vec![("upper".to_string(), uppercase_spec())],
Duration::from_secs(5),
true, );
let note = observer.after_write(&file).await;
let note = note.expect("diff_back=true must annotate a formatting change");
assert!(note.contains("upper"), "{note}");
assert!(
note.contains("HELLO WORLD"),
"annotation must reflect the FORMATTED content, not the raw model input: {note}"
);
assert!(
note.contains("-hello world") && note.contains("+HELLO WORLD"),
"diff must show the raw input removed and the formatted output added: {note}"
);
let on_disk = std::fs::read_to_string(&file).unwrap();
assert_eq!(
on_disk, "HELLO WORLD\n",
"the file itself must be reformatted"
);
std::fs::remove_dir_all(&project).ok();
}
#[tokio::test]
async fn diff_back_false_reformats_silently() {
let project = tmp("diffback-off");
let file = project.join("f.txt");
std::fs::write(&file, "hello world\n").unwrap();
let observer = FormatObserver::new(
project.clone(),
vec![("upper".to_string(), uppercase_spec())],
Duration::from_secs(5),
false, );
let note = observer.after_write(&file).await;
assert!(
note.is_none(),
"diff_back=false must not annotate, even though the file changed: {note:?}"
);
let on_disk = std::fs::read_to_string(&file).unwrap();
assert_eq!(
on_disk, "HELLO WORLD\n",
"the formatter must still have run and rewritten the file"
);
std::fs::remove_dir_all(&project).ok();
}
#[tokio::test]
async fn an_already_formatted_file_produces_no_annotation_or_rewrite() {
let project = tmp("idempotent");
let file = project.join("f.txt");
std::fs::write(&file, "HELLO WORLD\n").unwrap();
let observer = FormatObserver::new(
project.clone(),
vec![("upper".to_string(), uppercase_spec())],
Duration::from_secs(5),
true,
);
let mtime_before = std::fs::metadata(&file).unwrap().modified().unwrap();
std::thread::sleep(Duration::from_millis(10));
let note = observer.after_write(&file).await;
assert!(note.is_none());
let mtime_after = std::fs::metadata(&file).unwrap().modified().unwrap();
assert_eq!(
mtime_before, mtime_after,
"an already-formatted file must not be rewritten"
);
std::fs::remove_dir_all(&project).ok();
}
#[tokio::test]
async fn a_failing_formatter_never_corrupts_the_file() {
let project = tmp("failing");
let file = project.join("f.txt");
std::fs::write(&file, "hello world\n").unwrap();
let observer = FormatObserver::new(
project.clone(),
vec![("broken".to_string(), failing_spec())],
Duration::from_secs(5),
true,
);
let note = observer.after_write(&file).await;
assert!(note.is_none());
let on_disk = std::fs::read_to_string(&file).unwrap();
assert_eq!(
on_disk, "hello world\n",
"a failing formatter must leave the file untouched"
);
std::fs::remove_dir_all(&project).ok();
}
#[tokio::test]
async fn a_hanging_formatter_degrades_within_the_timeout_bound() {
let project = tmp("hanging");
let file = project.join("f.txt");
std::fs::write(&file, "hello world\n").unwrap();
let observer = FormatObserver::new(
project.clone(),
vec![("hangs".to_string(), hanging_spec())],
Duration::from_millis(500),
true,
);
let started = std::time::Instant::now();
let note = tokio::time::timeout(Duration::from_secs(10), observer.after_write(&file))
.await
.expect("must not hang past the configured formatter timeout");
assert!(note.is_none());
assert!(
started.elapsed() < Duration::from_secs(5),
"took {:?}, expected to bail out near the 500ms configured timeout",
started.elapsed()
);
let on_disk = std::fs::read_to_string(&file).unwrap();
assert_eq!(
on_disk, "hello world\n",
"a timed-out formatter must leave the file untouched"
);
std::fs::remove_dir_all(&project).ok();
}
#[tokio::test]
async fn an_unconfigured_extension_is_a_true_noop() {
let project = tmp("unconfigured");
let file = project.join("f.py");
std::fs::write(&file, "hello world\n").unwrap();
let observer = FormatObserver::new(
project.clone(),
vec![("upper".to_string(), uppercase_spec())], Duration::from_secs(5),
true,
);
let note = observer.after_write(&file).await;
assert!(note.is_none());
let on_disk = std::fs::read_to_string(&file).unwrap();
assert_eq!(on_disk, "hello world\n");
std::fs::remove_dir_all(&project).ok();
}
#[tokio::test]
async fn a_path_outside_the_root_is_refused() {
let project = tmp("outside-project");
let outside = tmp("outside-elsewhere");
let victim = outside.join("victim.txt");
std::fs::write(&victim, "hello world\n").unwrap();
let observer = FormatObserver::new(
project.clone(),
vec![("upper".to_string(), uppercase_spec())],
Duration::from_secs(5),
true,
);
let note = observer.after_write(&victim).await;
assert!(note.is_none());
let on_disk = std::fs::read_to_string(&victim).unwrap();
assert_eq!(
on_disk, "hello world\n",
"must never touch a path outside root"
);
std::fs::remove_dir_all(&project).ok();
std::fs::remove_dir_all(&outside).ok();
}
}