use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::{Mutex, OnceLock};
use std::thread;
use std::time::Duration;
use crate::config::FormatterConfig;
pub use crate::external_formatters_common::FormatterError;
use crate::external_formatters_common::{
FormatterIoMode, find_missing_formatter_commands, log_formatter_invocation,
log_formatter_nonzero_exit, log_formatter_spawn_failed, log_formatter_success,
log_formatter_timeout, log_missing_formatter_commands, resolve_file_args, resolve_stdin_args,
temp_file_extension_for_language,
};
use panache_formatter::{ExternalCodeBlock, FormattedCodeMap};
pub fn format_code_sync(
code: &str,
language: &str,
config: &FormatterConfig,
timeout: Duration,
) -> Result<String, FormatterError> {
if config.stdin {
format_with_stdin(code, language, config, timeout)
} else {
format_with_file(code, language, config, timeout)
}
}
fn format_with_stdin(
code: &str,
language: &str,
config: &FormatterConfig,
timeout: Duration,
) -> Result<String, FormatterError> {
let resolved_args = resolve_stdin_args(&config.args, language);
log_formatter_invocation(
&config.cmd,
language,
FormatterIoMode::Stdin,
&resolved_args,
);
let mut cmd = Command::new(&config.cmd);
cmd.args(&resolved_args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| {
log_formatter_spawn_failed(&config.cmd, language, FormatterIoMode::Stdin, &e);
FormatterError::SpawnFailed(format!("{}: {}", config.cmd, e))
})?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(code.as_bytes())?;
drop(stdin); }
use std::sync::mpsc;
use std::time::Instant;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let output = child.wait_with_output();
let _ = tx.send(output);
});
let start = Instant::now();
match rx.recv_timeout(timeout) {
Ok(Ok(output)) => {
if output.status.success() {
let formatted = String::from_utf8_lossy(&output.stdout).to_string();
log_formatter_success(
&config.cmd,
language,
FormatterIoMode::Stdin,
formatted.len(),
start.elapsed(),
);
Ok(formatted)
} else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let code = output.status.code().unwrap_or(-1);
log_formatter_nonzero_exit(
&config.cmd,
language,
FormatterIoMode::Stdin,
code,
&stderr,
);
Err(FormatterError::NonZeroExit { code, stderr })
}
}
Ok(Err(e)) => {
log::error!("Formatter I/O error: {}", e);
Err(FormatterError::IoError(e))
}
Err(_) => {
log_formatter_timeout(&config.cmd, language, FormatterIoMode::Stdin);
Err(FormatterError::Timeout)
}
}
}
fn format_with_file(
code: &str,
language: &str,
config: &FormatterConfig,
timeout: Duration,
) -> Result<String, FormatterError> {
use std::fs;
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join(format!(
"panache-{}.{}",
uuid::Uuid::new_v4(),
temp_file_extension_for_language(language)
));
fs::write(&temp_path, code).map_err(FormatterError::IoError)?;
let args = resolve_file_args(&config.args, language, temp_path.to_str().unwrap());
log_formatter_invocation(&config.cmd, language, FormatterIoMode::File, &args);
log::trace!(
"External formatter temp path ({}): {}",
config.cmd,
temp_path.display()
);
let mut cmd = Command::new(&config.cmd);
cmd.args(&args).stderr(Stdio::piped());
let child = cmd.spawn().map_err(|e| {
log_formatter_spawn_failed(&config.cmd, language, FormatterIoMode::File, &e);
FormatterError::SpawnFailed(format!("{}: {}", config.cmd, e))
})?;
use std::sync::mpsc;
use std::time::Instant;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let output = child.wait_with_output();
let _ = tx.send(output);
});
let start = Instant::now();
let result = match rx.recv_timeout(timeout) {
Ok(Ok(output)) => {
let formatted = fs::read_to_string(&temp_path).map_err(FormatterError::IoError)?;
if output.status.success() {
log_formatter_success(
&config.cmd,
language,
FormatterIoMode::File,
formatted.len(),
start.elapsed(),
);
Ok(formatted)
} else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let code = output.status.code().unwrap_or(-1);
log_formatter_nonzero_exit(
&config.cmd,
language,
FormatterIoMode::File,
code,
&stderr,
);
Err(FormatterError::NonZeroExit { code, stderr })
}
}
Ok(Err(e)) => {
log::error!("Formatter I/O error: {}", e);
Err(FormatterError::IoError(e))
}
Err(_) => {
log_formatter_timeout(&config.cmd, language, FormatterIoMode::File);
Err(FormatterError::Timeout)
}
};
let _ = fs::remove_file(&temp_path);
result
}
pub fn run_formatters_parallel(
blocks: Vec<ExternalCodeBlock>,
formatters: &HashMap<String, Vec<FormatterConfig>>,
timeout: Duration,
max_parallel: usize,
) -> FormattedCodeMap {
use rayon::prelude::*;
let missing_formatters = find_missing_formatter_commands(formatters);
log_missing_formatter_commands(&missing_formatters);
let max_parallel = max_parallel.max(1);
let mut groups: HashMap<(String, String), Vec<ExternalCodeBlock>> = HashMap::new();
for block in blocks {
groups
.entry((block.language.clone(), block.formatter_input.clone()))
.or_default()
.push(block);
}
let groups: Vec<((String, String), Vec<ExternalCodeBlock>)> = groups.into_iter().collect();
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(max_parallel)
.build()
.expect("failed to build rayon thread pool");
pool.install(|| {
groups
.into_par_iter()
.flat_map(|((lang, input), blocks)| {
let Some(formatted) =
run_formatter_chain(&lang, &input, formatters, &missing_formatters, timeout)
else {
return Vec::new();
};
blocks
.into_iter()
.filter_map(|block| {
if formatted == block.original {
return None;
}
let output = match block.hashpipe_prefix {
Some(prefix) => format!("{}{}", prefix, formatted),
None => formatted.clone(),
};
Some(((lang.clone(), block.original), output))
})
.collect::<Vec<_>>()
})
.collect::<FormattedCodeMap>()
})
}
fn run_formatter_chain(
lang: &str,
input: &str,
formatters: &HashMap<String, Vec<FormatterConfig>>,
missing_formatters: &HashSet<String>,
timeout: Duration,
) -> Option<String> {
let formatter_configs = formatters.get(lang)?;
if formatter_configs.is_empty() {
return None;
}
let chain_fp = chain_fingerprint(formatter_configs);
if let Some(cached) = chain_cache_get(&chain_fp, lang, input) {
return Some(cached);
}
let _permit = crate::external_tools_common::acquire_external_tool_permit();
let mut current_code = input.to_string();
for (idx, formatter_cfg) in formatter_configs.iter().enumerate() {
let formatter_cmd = formatter_cfg.cmd.trim();
if formatter_cmd.is_empty() {
continue;
}
if missing_formatters.contains(formatter_cmd) {
return None;
}
log::debug!(
"Formatting {} code with {} ({}/{} in chain)",
lang,
formatter_cfg.cmd,
idx + 1,
formatter_configs.len()
);
match format_code_sync(¤t_code, lang, formatter_cfg, timeout) {
Ok(formatted) => {
current_code = formatted;
}
Err(e) => {
log::warn!(
"{} formatter '{}' failed: {}. Falling back to original code block unchanged.",
lang,
formatter_cfg.cmd,
e
);
return None;
}
}
}
chain_cache_put(&chain_fp, lang, input, ¤t_code);
Some(current_code)
}
static FORMATTER_CHAIN_CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
const FORMATTER_CHAIN_CACHE_CAP: usize = 8192;
fn chain_fingerprint(configs: &[FormatterConfig]) -> String {
let mut fp = String::new();
for cfg in configs {
fp.push_str(cfg.cmd.trim());
fp.push('\u{1}');
for arg in &cfg.args {
fp.push_str(arg);
fp.push('\u{1}');
}
fp.push(if cfg.enabled { 'E' } else { 'D' });
fp.push(if cfg.stdin { 'S' } else { 'F' });
fp.push('\u{2}');
}
fp
}
fn chain_cache_key(chain_fp: &str, lang: &str, input: &str) -> String {
format!("{chain_fp}\u{0}{lang}\u{0}{input}")
}
fn chain_cache_get(chain_fp: &str, lang: &str, input: &str) -> Option<String> {
let cache = FORMATTER_CHAIN_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let key = chain_cache_key(chain_fp, lang, input);
cache
.lock()
.expect("formatter chain cache mutex poisoned")
.get(&key)
.cloned()
}
fn chain_cache_put(chain_fp: &str, lang: &str, input: &str, output: &str) {
let cache = FORMATTER_CHAIN_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let key = chain_cache_key(chain_fp, lang, input);
let mut guard = cache.lock().expect("formatter chain cache mutex poisoned");
if guard.len() >= FORMATTER_CHAIN_CACHE_CAP && !guard.contains_key(&key) {
return;
}
guard.insert(key, output.to_string());
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(cmd: &str, args: &[&str], stdin: bool) -> FormatterConfig {
FormatterConfig {
cmd: cmd.to_string(),
args: args.iter().map(|a| a.to_string()).collect(),
enabled: true,
stdin,
}
}
#[test]
fn chain_fingerprint_distinguishes_cmd_args_and_flags() {
let base = chain_fingerprint(&[cfg("black", &["-"], true)]);
assert_ne!(base, chain_fingerprint(&[cfg("blue", &["-"], true)]));
assert_ne!(base, chain_fingerprint(&[cfg("black", &["-q", "-"], true)]));
assert_ne!(base, chain_fingerprint(&[cfg("black", &["-"], false)]));
assert_ne!(
base,
chain_fingerprint(&[cfg("black", &["-"], true), cfg("isort", &["-"], true)])
);
assert_eq!(base, chain_fingerprint(&[cfg("black", &["-"], true)]));
}
#[test]
fn chain_fingerprint_is_unambiguous_across_field_boundaries() {
assert_ne!(
chain_fingerprint(&[cfg("fmt", &["ab"], true)]),
chain_fingerprint(&[cfg("fmt", &["a", "b"], true)])
);
}
#[test]
fn cache_round_trips_per_chain_lang_and_input() {
let fp = "test-roundtrip\u{2}";
assert_eq!(chain_cache_get(fp, "py", "x=1"), None);
chain_cache_put(fp, "py", "x=1", "x = 1");
assert_eq!(chain_cache_get(fp, "py", "x=1").as_deref(), Some("x = 1"));
assert_eq!(chain_cache_get(fp, "py", "y=2"), None);
assert_eq!(chain_cache_get(fp, "sh", "x=1"), None);
assert_eq!(chain_cache_get("other\u{2}", "py", "x=1"), None);
}
}