use crate::core::backend::GeneratedFile;
use crate::core::config::e2e::DependencyMode;
use crate::e2e::config::E2eConfig;
use anyhow::Context as _;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use tracing::warn;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeferredFormatting {
pub language: String,
pub step: String,
pub reason: String,
}
impl std::fmt::Display for DeferredFormatting {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "[{}] {} — {}", self.language, self.step, self.reason)
}
}
const UNPUBLISHED_VERSION_REASON: &str = "registry-mode manifests pin the version this run produces, which is not \
published yet; re-run after publishing";
pub fn run_formatters(
files: &[GeneratedFile],
e2e_config: &E2eConfig,
strict: bool,
) -> anyhow::Result<Vec<DeferredFormatting>> {
let defer_resolution = e2e_config.dep_mode == DependencyMode::Registry;
let mut deferred = Vec::new();
let output_prefix = Path::new(e2e_config.effective_output());
let current_dir = std::env::current_dir().context("failed to resolve formatter working directory")?;
let mut languages: Vec<String> = files
.iter()
.filter_map(|f| {
let remainder = f.path.strip_prefix(output_prefix).ok()?;
let first = remainder.components().next()?;
Some(first.as_os_str().to_string_lossy().into_owned())
})
.collect::<HashSet<String>>()
.into_iter()
.collect();
languages.sort();
let mut failures: Vec<String> = Vec::new();
for lang in &languages {
if let Err(error) = format_language(lang, e2e_config, ¤t_dir, defer_resolution, strict, &mut deferred) {
failures.push(format!("{lang}: {error:#}"));
}
}
for file in files {
if file.content.starts_with("#!")
&& let Err(e) = crate::cli::pipeline::apply_shebang_chmod(&file.path, &file.content)
{
warn!("failed to restore exec bit on {}: {e}", file.path.display());
}
}
if !failures.is_empty() {
anyhow::bail!(
"formatting failed for {} of {} language(s): {}",
failures.len(),
languages.len(),
failures.join("; ")
);
}
Ok(deferred)
}
fn format_language(
lang: &str,
e2e_config: &E2eConfig,
current_dir: &Path,
defer_resolution: bool,
strict: bool,
deferred: &mut Vec<DeferredFormatting>,
) -> anyhow::Result<()> {
let configured_dir = PathBuf::from(format!("{}/{}", e2e_config.effective_output(), lang));
let dir_path = resolve_formatter_directory(&configured_dir, current_dir)?;
let dir = dir_path.to_string_lossy();
if let Some(custom) = e2e_config.format.get(lang) {
let cmd = custom.replace("{dir}", &dir);
tracing::debug!("Formatting {lang}: {cmd}");
return match run_shell(&cmd, lang) {
Ok(()) => Ok(()),
Err(failure) if failure.executable_missing => resolve_shell_failure(failure, lang, &cmd, strict, deferred),
Err(failure) if defer_resolution => {
let error = failure.error;
warn!("deferring {lang} format override until after publish: {error}");
deferred.push(DeferredFormatting {
language: lang.to_owned(),
step: cmd,
reason: format!("{UNPUBLISHED_VERSION_REASON} (failed with: {error})"),
});
Ok(())
}
Err(failure) => Err(failure.error),
};
}
tracing::debug!("Formatting {lang} with poly: {dir}");
if !crate::cli::pipeline::is_tool_available("poly") {
if strict {
anyhow::bail!("poly not found on PATH; generated output cannot be formatted");
}
warn!("{lang}: poly fmt skipped — executable not found; continuing so the run reaches finalisation");
deferred.push(DeferredFormatting {
language: lang.to_owned(),
step: "poly fmt --fix".to_owned(),
reason: MISSING_TOOLCHAIN_REASON.to_owned(),
});
} else {
crate::cli::pipeline::poly_format_strict(std::slice::from_ref(&dir_path), &dir_path)?;
}
if lang == "go" {
if defer_resolution {
warn!("skipping `go mod tidy` for {lang}: {UNPUBLISHED_VERSION_REASON}");
deferred.push(DeferredFormatting {
language: lang.to_owned(),
step: GO_MOD_TIDY_STEP.to_owned(),
reason: UNPUBLISHED_VERSION_REASON.to_owned(),
});
} else {
run_go_mod_tidy(&dir_path, lang, strict, deferred)?;
}
}
if lang == "elixir" {
run_mix_format(&dir_path, lang, strict, deferred)?;
}
Ok(())
}
fn resolve_formatter_directory(path: &Path, current_dir: &Path) -> anyhow::Result<PathBuf> {
let absolute_path = if path.is_absolute() {
path.to_path_buf()
} else {
current_dir.join(path)
};
absolute_path
.canonicalize()
.with_context(|| format!("generated formatter path does not exist: {}", absolute_path.display()))
}
pub fn run_formatters_for_cached_paths(
paths: &[PathBuf],
base_dir: &Path,
e2e_config: &E2eConfig,
strict: bool,
) -> anyhow::Result<Vec<DeferredFormatting>> {
let output_is_absolute = Path::new(e2e_config.effective_output()).is_absolute();
let files: Vec<GeneratedFile> = paths
.iter()
.filter_map(|path| {
let formatter_path = if output_is_absolute {
path.clone()
} else {
path.strip_prefix(base_dir).ok()?.to_path_buf()
};
let content = std::fs::read_to_string(path).unwrap_or_default();
Some(GeneratedFile {
path: formatter_path,
content,
generated_header: true,
})
})
.collect();
run_formatters(&files, e2e_config, strict)
}
const SHELL_COMMAND_NOT_FOUND: i32 = 127;
struct ShellFailure {
executable_missing: bool,
error: anyhow::Error,
}
fn run_shell(cmd: &str, lang: &str) -> Result<(), ShellFailure> {
match std::process::Command::new("sh").args(["-c", cmd]).status() {
Ok(status) if status.success() => Ok(()),
Ok(status) => Err(ShellFailure {
executable_missing: status.code() == Some(SHELL_COMMAND_NOT_FOUND),
error: anyhow::anyhow!("formatter for {lang} exited with {status}: {cmd}"),
}),
Err(error) => Err(ShellFailure {
executable_missing: error.kind() == std::io::ErrorKind::NotFound,
error: anyhow::Error::new(error).context(format!("failed to run formatter for {lang}: {cmd}")),
}),
}
}
const MISSING_TOOLCHAIN_REASON: &str = "the formatter's executable is not installed on this machine; generation \
continued so the run still reaches finalisation. Install the toolchain, or \
re-run with --strict to make this fatal";
fn resolve_shell_failure(
failure: ShellFailure,
lang: &str,
step: &str,
strict: bool,
deferred: &mut Vec<DeferredFormatting>,
) -> anyhow::Result<()> {
if !failure.executable_missing || strict {
return Err(failure.error);
}
warn!("{lang}: `{step}` skipped — executable not found; continuing so the run reaches finalisation");
deferred.push(DeferredFormatting {
language: lang.to_owned(),
step: step.to_owned(),
reason: MISSING_TOOLCHAIN_REASON.to_owned(),
});
Ok(())
}
const GO_MOD_TIDY_STEP: &str = "go mod tidy";
pub fn warn_deferred(deferred: &[DeferredFormatting]) {
for entry in deferred {
warn!("formatting step deferred: {entry}");
}
}
fn run_in_dir(program: &str, args: &[&str], dir: &Path, lang: &str) -> Result<(), ShellFailure> {
let step = std::iter::once(program)
.chain(args.iter().copied())
.collect::<Vec<_>>()
.join(" ");
match std::process::Command::new(program).args(args).current_dir(dir).status() {
Ok(status) if status.success() => Ok(()),
Ok(status) => Err(ShellFailure {
executable_missing: false,
error: anyhow::anyhow!(
"formatter for {lang} exited with {status}: {step} (in {})",
dir.display()
),
}),
Err(error) => Err(ShellFailure {
executable_missing: error.kind() == std::io::ErrorKind::NotFound,
error: anyhow::Error::new(error).context(format!("failed to run {step} for {lang} in {}", dir.display())),
}),
}
}
fn run_go_mod_tidy(dir: &Path, lang: &str, strict: bool, deferred: &mut Vec<DeferredFormatting>) -> anyhow::Result<()> {
match run_in_dir("go", &["mod", "tidy"], dir, "go") {
Ok(()) => Ok(()),
Err(failure) => resolve_shell_failure(failure, lang, GO_MOD_TIDY_STEP, strict, deferred),
}
}
fn run_mix_format(dir: &Path, lang: &str, strict: bool, deferred: &mut Vec<DeferredFormatting>) -> anyhow::Result<()> {
match run_in_dir("mix", &["format"], dir, "elixir") {
Ok(()) => Ok(()),
Err(failure) => resolve_shell_failure(failure, lang, "mix format", strict, deferred),
}
}
#[cfg(test)]
#[path = "format_tests.rs"]
mod tests;