use crate::core::config::{FormatConfig, Language, ResolvedCrateConfig};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use tracing::{debug, warn};
struct ResidualStep {
command: String,
args: Vec<String>,
work_dir: PathBuf,
}
pub fn format_generated(
files: &[(Language, Vec<crate::core::backend::GeneratedFile>)],
config: &ResolvedCrateConfig,
base_dir: &Path,
only_languages: Option<&HashSet<Language>>,
) {
let mut seen = HashSet::new();
let present: Vec<Language> = files
.iter()
.map(|(lang, _)| *lang)
.filter(|lang| seen.insert(*lang))
.collect();
let mut poly_langs: Vec<Language> = Vec::new();
for &lang in &present {
let lang_str = lang.to_string().to_lowercase();
let fmt_cfg = effective_format_cfg(config, lang);
if !fmt_cfg.enabled {
debug!(" [{lang_str}] formatting disabled, skipping");
continue;
}
if let Some(custom) = &fmt_cfg.command {
if let Err(e) = run_custom_formatter(custom, base_dir) {
warn!("[{lang_str}] custom formatter failed: {e}");
}
continue;
}
if let Some(filter) = only_languages
&& !filter.contains(&lang)
{
continue;
}
poly_langs.push(lang);
}
if poly_langs.is_empty() {
return;
}
let paths = poly_paths(config, base_dir, only_languages, &poly_langs);
poly_format(&paths, base_dir);
for &lang in &poly_langs {
let lang_str = lang.to_string().to_lowercase();
for step in language_residuals(config, lang, base_dir) {
run_residual(&step, &lang_str);
}
}
}
fn effective_format_cfg(config: &ResolvedCrateConfig, lang: Language) -> &FormatConfig {
let lang_str = lang.to_string().to_lowercase();
config.format_overrides.get(&lang_str).unwrap_or(&config.format)
}
fn poly_paths(
config: &ResolvedCrateConfig,
base_dir: &Path,
only_languages: Option<&HashSet<Language>>,
poly_langs: &[Language],
) -> Vec<PathBuf> {
match only_languages {
None => vec![base_dir.to_path_buf()],
Some(_) => {
let mut seen = HashSet::new();
let mut dirs = Vec::new();
for &lang in poly_langs {
let dir = base_dir.join(config.package_dir(lang));
if seen.insert(dir.clone()) && dir.exists() {
dirs.push(dir);
}
}
dirs
}
}
}
pub(crate) fn poly_format(paths: &[PathBuf], config_start: &Path) {
if paths.is_empty() {
return;
}
if !is_tool_available("poly") {
warn!("poly not found on PATH (skipping post-generation formatting)");
return;
}
let mut args: Vec<String> = vec!["fmt".to_owned(), "--fix".to_owned()];
args.extend(paths.iter().map(|path| path.to_string_lossy().into_owned()));
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
match run_formatter("poly", &arg_refs, config_start) {
Ok(()) => debug!("poly fmt over {} path(s) ok", paths.len()),
Err(e) => warn!("poly fmt failed (non-fatal): {e}"),
}
}
pub(crate) fn install_poly_hooks(base_dir: &Path) {
if !base_dir.join(".git").exists() {
debug!(
"not a git repository at {}, skipping poly hooks install",
base_dir.display()
);
return;
}
if !is_tool_available("poly") {
warn!("poly not found on PATH (skipping poly hooks install)");
return;
}
match run_formatter("poly", &["hooks", "install"], base_dir) {
Ok(()) => debug!("poly hooks install ok"),
Err(e) => warn!("poly hooks install failed (non-fatal): {e}"),
}
}
fn language_residuals(config: &ResolvedCrateConfig, lang: Language, base_dir: &Path) -> Vec<ResidualStep> {
match lang {
Language::Wasm => {
let crate_dir = config
.output_for("wasm")
.map(resolve_crate_dir)
.unwrap_or_else(|| Path::new("crates").join(format!("{}-wasm", config.name)));
let crate_dir_str = crate_dir.to_string_lossy().into_owned().replace('\\', "/");
vec![cargo_sort(vec![crate_dir_str], base_dir.to_path_buf())]
}
Language::Ffi => vec![cargo_sort(vec!["-w".to_owned()], base_dir.to_path_buf())],
Language::Ruby => {
let gem_name = config.ruby_gem_name();
let native_subdir = format!("ext/{gem_name}/native");
vec![cargo_sort(vec![native_subdir], base_dir.join("packages/ruby"))]
}
Language::Elixir => {
let app_name = config.elixir_app_name();
let native_subdir = format!("native/{app_name}_nif");
vec![cargo_sort(vec![native_subdir], base_dir.join("packages/elixir"))]
}
Language::R => vec![cargo_sort(
vec!["packages/r/src/rust".to_owned()],
base_dir.to_path_buf(),
)],
_ => vec![],
}
}
fn cargo_sort(mut sort_args: Vec<String>, work_dir: PathBuf) -> ResidualStep {
let mut args = vec!["sort".to_owned()];
args.append(&mut sort_args);
ResidualStep {
command: "cargo".to_owned(),
args,
work_dir,
}
}
fn run_residual(step: &ResidualStep, lang_str: &str) {
if !step.work_dir.exists() {
debug!(
" [{lang_str}] residual work dir does not exist: {}, skipping",
step.work_dir.display()
);
return;
}
if !is_tool_available(&step.command) {
warn!("[{lang_str}] residual formatter not found: {} (skipping)", step.command);
return;
}
let args: Vec<&str> = step.args.iter().map(String::as_str).collect();
match run_formatter(&step.command, &args, &step.work_dir) {
Ok(()) => debug!(" [{lang_str}] {} {:?} ok", step.command, args),
Err(e) => warn!("[{lang_str}] {} {:?} failed: {e}", step.command, args),
}
}
fn is_tool_available(tool: &str) -> bool {
Command::new("which")
.arg(tool)
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
fn run_formatter(command: &str, args: &[&str], work_dir: &Path) -> anyhow::Result<()> {
let output = Command::new(command).args(args).current_dir(work_dir).output()?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"formatter exited with code {:?}: {}",
output.status.code(),
format_command_output(&output)
));
}
Ok(())
}
fn run_custom_formatter(cmd: &str, work_dir: &Path) -> anyhow::Result<()> {
let output = Command::new("sh").arg("-c").arg(cmd).current_dir(work_dir).output()?;
if !output.status.success() {
debug!("custom formatter output: {}", format_command_output(&output));
return Err(anyhow::anyhow!(
"formatter exited with code {:?}: {}",
output.status.code(),
format_command_output(&output)
));
}
Ok(())
}
fn format_command_output(output: &Output) -> String {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = stdout.trim();
let stderr = stderr.trim();
match (stdout.is_empty(), stderr.is_empty()) {
(false, false) => format!("stdout:\n{stdout}\nstderr:\n{stderr}"),
(false, true) => format!("stdout:\n{stdout}"),
(true, false) => format!("stderr:\n{stderr}"),
(true, true) => "<no output>".to_string(),
}
}
fn resolve_crate_dir(output_path: &Path) -> PathBuf {
output_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| output_path.to_path_buf())
}
#[cfg(test)]
mod tests;