1use std::path::{Path, PathBuf};
43use std::time::Duration;
44
45use tokio::io::{AsyncReadExt, AsyncWriteExt};
46
47pub const MAX_FORMATTER_OUTPUT_BYTES: usize = 8 * 1024 * 1024;
51
52pub const MAX_DIFF_CHARS: usize = 6000;
57
58pub const DEFAULT_FORMATTER_TIMEOUT_SECS: u64 = 10;
61
62#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct FormatterSpec {
69 pub command: String,
71 pub args: Vec<String>,
73 pub extensions: Vec<String>,
76}
77
78fn spec_for_extension<'a>(
79 specs: &'a [(String, FormatterSpec)],
80 path: &Path,
81) -> Option<&'a (String, FormatterSpec)> {
82 let ext = path.extension()?.to_str()?.to_ascii_lowercase();
83 specs.iter().find(|(_, s)| {
84 s.extensions
85 .iter()
86 .any(|e| e.trim_start_matches('.').to_ascii_lowercase() == ext)
87 })
88}
89
90async fn run_formatter(
97 spec: &FormatterSpec,
98 input: &[u8],
99 timeout: Duration,
100) -> crate::error::Result<Option<Vec<u8>>> {
101 let mut cmd = tokio::process::Command::new(&spec.command);
109 cmd.args(&spec.args)
110 .stdin(std::process::Stdio::piped())
111 .stdout(std::process::Stdio::piped())
112 .stderr(std::process::Stdio::null())
113 .kill_on_drop(true);
114 #[cfg(unix)]
115 cmd.process_group(0);
116 let mut child = cmd.spawn().map_err(|e| {
117 crate::error::Error::tool("formatters", format!("spawn {}: {e}", spec.command))
118 })?;
119 #[cfg(unix)]
120 let child_pid = child.id();
121
122 let mut stdin = child
123 .stdin
124 .take()
125 .ok_or_else(|| crate::error::Error::tool("formatters", "no stdin"))?;
126 let mut stdout = child
127 .stdout
128 .take()
129 .ok_or_else(|| crate::error::Error::tool("formatters", "no stdout"))?;
130
131 let owned_input = input.to_vec();
132 let writer = tokio::spawn(async move {
136 let _ = stdin.write_all(&owned_input).await;
137 });
139 let reader = tokio::spawn(async move {
140 let mut buf = Vec::new();
141 let mut limited = (&mut stdout).take(MAX_FORMATTER_OUTPUT_BYTES as u64);
142 let _ = limited.read_to_end(&mut buf).await;
143 buf
144 });
145
146 let wait_result = tokio::time::timeout(timeout, child.wait()).await;
147 match wait_result {
148 Ok(Ok(status)) => {
149 writer.abort();
150 let output = reader.await.unwrap_or_default();
151 if !status.success() {
152 return Ok(None); }
154 if output.is_empty() {
155 return Ok(None); }
157 Ok(Some(output))
158 }
159 Ok(Err(e)) => Err(crate::error::Error::tool(
160 "formatters",
161 format!("wait failed: {e}"),
162 )),
163 Err(_elapsed) => {
164 #[cfg(unix)]
176 if let Some(pid) = child_pid {
177 crate::lsp::kill_process_group(pid);
178 }
179 writer.abort();
180 reader.abort();
181 Err(crate::error::Error::tool(
182 "formatters",
183 format!("timed out after {:?}", timeout),
184 ))
185 }
186 }
187}
188
189#[derive(Debug)]
198pub struct FormatObserver {
199 specs: Vec<(String, FormatterSpec)>,
200 root: PathBuf,
201 timeout: Duration,
202 diff_back: bool,
203}
204
205impl FormatObserver {
206 pub fn new(
210 root: PathBuf,
211 specs: Vec<(String, FormatterSpec)>,
212 timeout: Duration,
213 diff_back: bool,
214 ) -> Self {
215 FormatObserver {
216 specs,
217 root,
218 timeout,
219 diff_back,
220 }
221 }
222}
223
224#[async_trait::async_trait]
225impl crate::tools::WriteObserver for FormatObserver {
226 async fn before_write(&self, _path: &Path) {}
227
228 async fn after_write(&self, path: &Path) -> Option<String> {
229 if !crate::safe_path::contained(&self.root, path) {
230 return None; }
232 let (name, spec) = spec_for_extension(&self.specs, path)?;
233 let original = match tokio::fs::read(path).await {
234 Ok(b) => b,
235 Err(_) => return None, };
237 let formatted = match run_formatter(spec, &original, self.timeout).await {
238 Ok(Some(bytes)) => bytes,
239 Ok(None) => return None, Err(e) => {
241 tracing::warn!(formatter = %name, "formatters: {e} — leaving file untouched");
242 return None;
243 }
244 };
245 if formatted == original {
246 return None; }
248 if !crate::safe_path::contained(&self.root, path) {
254 return None;
255 }
256 if tokio::fs::write(path, &formatted).await.is_err() {
257 tracing::warn!(formatter = %name, path = %path.display(), "formatters: failed to write formatted output");
258 return None;
259 }
260 if !self.diff_back {
261 return None;
265 }
266 let original_text = String::from_utf8_lossy(&original);
267 let formatted_text = String::from_utf8_lossy(&formatted);
268 let mut diff = diffy::create_patch(&original_text, &formatted_text).to_string();
269 if diff.chars().count() > MAX_DIFF_CHARS {
270 diff = diff.chars().take(MAX_DIFF_CHARS).collect::<String>();
271 diff.push_str("\n... (diff truncated)");
272 }
273 let display_path = path.strip_prefix(&self.root).unwrap_or(path);
274 Some(format!(
275 "Formatter `{name}` reformatted {} — diff:\n{diff}",
276 display_path.display()
277 ))
278 }
279}
280
281pub fn observer_for_config(config: &crate::Config) -> Option<std::sync::Arc<FormatObserver>> {
287 if !config.formatters_enabled {
288 return None;
289 }
290 if config.formatters.is_empty() {
291 eprintln!(
292 "warning: [capabilities.formatters] is enabled but no formatters are configured \
293 under [capabilities.formatters.<name>] — nothing will ever be reformatted"
294 );
295 }
296 Some(std::sync::Arc::new(FormatObserver::new(
297 config.cwd.clone(),
298 config.formatters.clone(),
299 Duration::from_secs(config.formatters_timeout_secs.max(1)),
300 config.formatters_diff_back,
301 )))
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use crate::tools::WriteObserver;
308
309 fn tmp(tag: &str) -> PathBuf {
310 let dir = std::env::temp_dir().join(format!(
311 "supercode-formatters-test-{tag}-{}-{}",
312 std::process::id(),
313 std::time::SystemTime::now()
314 .duration_since(std::time::UNIX_EPOCH)
315 .unwrap()
316 .as_nanos()
317 ));
318 std::fs::create_dir_all(&dir).unwrap();
319 dir
320 }
321
322 fn uppercase_spec() -> FormatterSpec {
326 FormatterSpec {
327 command: "sh".to_string(),
328 args: vec!["-c".to_string(), "tr 'a-z' 'A-Z'".to_string()],
329 extensions: vec![".txt".to_string()],
330 }
331 }
332
333 fn failing_spec() -> FormatterSpec {
336 FormatterSpec {
337 command: "sh".to_string(),
338 args: vec!["-c".to_string(), "exit 1".to_string()],
339 extensions: vec![".txt".to_string()],
340 }
341 }
342
343 fn hanging_spec() -> FormatterSpec {
345 FormatterSpec {
346 command: "sh".to_string(),
347 args: vec!["-c".to_string(), "cat >/dev/null; sleep 3600".to_string()],
348 extensions: vec![".txt".to_string()],
349 }
350 }
351
352 #[tokio::test]
353 async fn observer_for_config_is_none_when_disabled_default_off_byte_identity() {
354 let config = crate::Config::builder().model("m").build();
355 assert!(!config.formatters_enabled);
356 assert!(observer_for_config(&config).is_none());
357 }
358
359 #[tokio::test]
363 async fn diff_back_true_surfaces_the_formatted_content_in_the_annotation() {
364 let project = tmp("diffback-on");
365 let file = project.join("f.txt");
366 std::fs::write(&file, "hello world\n").unwrap();
367 let observer = FormatObserver::new(
368 project.clone(),
369 vec![("upper".to_string(), uppercase_spec())],
370 Duration::from_secs(5),
371 true, );
373 let note = observer.after_write(&file).await;
374 let note = note.expect("diff_back=true must annotate a formatting change");
375 assert!(note.contains("upper"), "{note}");
376 assert!(
377 note.contains("HELLO WORLD"),
378 "annotation must reflect the FORMATTED content, not the raw model input: {note}"
379 );
380 assert!(
381 note.contains("-hello world") && note.contains("+HELLO WORLD"),
382 "diff must show the raw input removed and the formatted output added: {note}"
383 );
384 let on_disk = std::fs::read_to_string(&file).unwrap();
385 assert_eq!(
386 on_disk, "HELLO WORLD\n",
387 "the file itself must be reformatted"
388 );
389 std::fs::remove_dir_all(&project).ok();
390 }
391
392 #[tokio::test]
395 async fn diff_back_false_reformats_silently() {
396 let project = tmp("diffback-off");
397 let file = project.join("f.txt");
398 std::fs::write(&file, "hello world\n").unwrap();
399 let observer = FormatObserver::new(
400 project.clone(),
401 vec![("upper".to_string(), uppercase_spec())],
402 Duration::from_secs(5),
403 false, );
405 let note = observer.after_write(&file).await;
406 assert!(
407 note.is_none(),
408 "diff_back=false must not annotate, even though the file changed: {note:?}"
409 );
410 let on_disk = std::fs::read_to_string(&file).unwrap();
411 assert_eq!(
412 on_disk, "HELLO WORLD\n",
413 "the formatter must still have run and rewritten the file"
414 );
415 std::fs::remove_dir_all(&project).ok();
416 }
417
418 #[tokio::test]
419 async fn an_already_formatted_file_produces_no_annotation_or_rewrite() {
420 let project = tmp("idempotent");
421 let file = project.join("f.txt");
422 std::fs::write(&file, "HELLO WORLD\n").unwrap();
423 let observer = FormatObserver::new(
424 project.clone(),
425 vec![("upper".to_string(), uppercase_spec())],
426 Duration::from_secs(5),
427 true,
428 );
429 let mtime_before = std::fs::metadata(&file).unwrap().modified().unwrap();
430 std::thread::sleep(Duration::from_millis(10));
431 let note = observer.after_write(&file).await;
432 assert!(note.is_none());
433 let mtime_after = std::fs::metadata(&file).unwrap().modified().unwrap();
434 assert_eq!(
435 mtime_before, mtime_after,
436 "an already-formatted file must not be rewritten"
437 );
438 std::fs::remove_dir_all(&project).ok();
439 }
440
441 #[tokio::test]
442 async fn a_failing_formatter_never_corrupts_the_file() {
443 let project = tmp("failing");
444 let file = project.join("f.txt");
445 std::fs::write(&file, "hello world\n").unwrap();
446 let observer = FormatObserver::new(
447 project.clone(),
448 vec![("broken".to_string(), failing_spec())],
449 Duration::from_secs(5),
450 true,
451 );
452 let note = observer.after_write(&file).await;
453 assert!(note.is_none());
454 let on_disk = std::fs::read_to_string(&file).unwrap();
455 assert_eq!(
456 on_disk, "hello world\n",
457 "a failing formatter must leave the file untouched"
458 );
459 std::fs::remove_dir_all(&project).ok();
460 }
461
462 #[tokio::test]
465 async fn a_hanging_formatter_degrades_within_the_timeout_bound() {
466 let project = tmp("hanging");
467 let file = project.join("f.txt");
468 std::fs::write(&file, "hello world\n").unwrap();
469 let observer = FormatObserver::new(
470 project.clone(),
471 vec![("hangs".to_string(), hanging_spec())],
472 Duration::from_millis(500),
473 true,
474 );
475 let started = std::time::Instant::now();
476 let note = tokio::time::timeout(Duration::from_secs(10), observer.after_write(&file))
477 .await
478 .expect("must not hang past the configured formatter timeout");
479 assert!(note.is_none());
480 assert!(
481 started.elapsed() < Duration::from_secs(5),
482 "took {:?}, expected to bail out near the 500ms configured timeout",
483 started.elapsed()
484 );
485 let on_disk = std::fs::read_to_string(&file).unwrap();
486 assert_eq!(
487 on_disk, "hello world\n",
488 "a timed-out formatter must leave the file untouched"
489 );
490 std::fs::remove_dir_all(&project).ok();
491 }
492
493 #[tokio::test]
494 async fn an_unconfigured_extension_is_a_true_noop() {
495 let project = tmp("unconfigured");
496 let file = project.join("f.py");
497 std::fs::write(&file, "hello world\n").unwrap();
498 let observer = FormatObserver::new(
499 project.clone(),
500 vec![("upper".to_string(), uppercase_spec())], Duration::from_secs(5),
502 true,
503 );
504 let note = observer.after_write(&file).await;
505 assert!(note.is_none());
506 let on_disk = std::fs::read_to_string(&file).unwrap();
507 assert_eq!(on_disk, "hello world\n");
508 std::fs::remove_dir_all(&project).ok();
509 }
510
511 #[tokio::test]
512 async fn a_path_outside_the_root_is_refused() {
513 let project = tmp("outside-project");
514 let outside = tmp("outside-elsewhere");
515 let victim = outside.join("victim.txt");
516 std::fs::write(&victim, "hello world\n").unwrap();
517 let observer = FormatObserver::new(
518 project.clone(),
519 vec![("upper".to_string(), uppercase_spec())],
520 Duration::from_secs(5),
521 true,
522 );
523 let note = observer.after_write(&victim).await;
524 assert!(note.is_none());
525 let on_disk = std::fs::read_to_string(&victim).unwrap();
526 assert_eq!(
527 on_disk, "hello world\n",
528 "must never touch a path outside root"
529 );
530 std::fs::remove_dir_all(&project).ok();
531 std::fs::remove_dir_all(&outside).ok();
532 }
533}