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}");
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, lang, strict, deferred)?;
}
}
if lang == "elixir" {
run_mix_format(&dir, 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!("deferred until the pinned version is published: {entry}");
}
}
fn run_go_mod_tidy(dir: &str, lang: &str, strict: bool, deferred: &mut Vec<DeferredFormatting>) -> anyhow::Result<()> {
let cmd = format!("(cd {dir} && go mod tidy)");
match run_shell(&cmd, "go") {
Ok(()) => Ok(()),
Err(failure) => resolve_shell_failure(failure, lang, GO_MOD_TIDY_STEP, strict, deferred),
}
}
fn run_mix_format(dir: &str, lang: &str, strict: bool, deferred: &mut Vec<DeferredFormatting>) -> anyhow::Result<()> {
let cmd = format!("(cd {dir} && mix format)");
match run_shell(&cmd, "elixir") {
Ok(()) => Ok(()),
Err(failure) => resolve_shell_failure(failure, lang, "mix format", strict, deferred),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn e2e_config_for(out: &Path) -> E2eConfig {
E2eConfig {
output: out.to_string_lossy().into_owned(),
..E2eConfig::default()
}
}
#[test]
fn formatter_directory_resolves_relative_targets_against_launch_directory() {
let directory = tempfile::tempdir().expect("tempdir");
let output = directory.path().join("e2e").join("python");
std::fs::create_dir_all(&output).expect("create formatter target");
let resolved = resolve_formatter_directory(Path::new("e2e/python"), directory.path()).expect("resolve path");
assert!(resolved.is_absolute());
assert_eq!(resolved, output.canonicalize().expect("canonical output"));
}
#[test]
fn formatter_directory_rejects_real_missing_targets() {
let directory = tempfile::tempdir().expect("tempdir");
let error = resolve_formatter_directory(Path::new("e2e/missing"), directory.path())
.expect_err("missing formatter target must fail");
assert!(error.to_string().contains("generated formatter path does not exist"));
}
#[test]
fn user_override_command_is_expanded_and_run() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
let out = base.join("e2e-out");
std::fs::create_dir_all(out.join("python")).unwrap();
let sentinel = out.join("python/was_run.txt");
let sentinel_str = sentinel.to_string_lossy().replace('\\', "/");
let mut e2e_config = e2e_config_for(&out);
e2e_config
.format
.insert("python".to_owned(), format!("touch {sentinel_str}"));
let files = vec![GeneratedFile {
path: out.join("python/main.py"),
content: "x = 1\n".to_owned(),
generated_header: false,
}];
assert!(!sentinel.exists());
run_formatters(&files, &e2e_config, false).unwrap();
assert!(
sentinel.exists(),
"user override command must run with {{dir}} expanded"
);
}
fn config_with_override(out: &Path, lang: &str, command: &str, dep_mode: DependencyMode) -> E2eConfig {
let mut config = e2e_config_for(out);
config.registry.output = out.to_string_lossy().into_owned();
config.dep_mode = dep_mode;
config.format.insert(lang.to_owned(), command.to_owned());
config
}
fn one_file_in(out: &Path, lang: &str, name: &str) -> Vec<GeneratedFile> {
vec![GeneratedFile {
path: out.join(lang).join(name),
content: "x = 1\n".to_owned(),
generated_header: false,
}]
}
#[test]
fn local_mode_still_aborts_when_a_format_override_fails() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("e2e-out");
std::fs::create_dir_all(out.join("python")).unwrap();
let config = config_with_override(&out, "python", "exit 3", DependencyMode::Local);
let error = run_formatters(&one_file_in(&out, "python", "main.py"), &config, false)
.expect_err("local mode must abort on a failing formatter");
assert!(
error.to_string().contains("formatter for python exited"),
"expected the formatter failure to propagate, got: {error}"
);
}
#[test]
fn registry_mode_defers_a_failing_format_override_instead_of_aborting() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("e2e-out");
std::fs::create_dir_all(out.join("python")).unwrap();
let config = config_with_override(&out, "python", "exit 3", DependencyMode::Registry);
let deferred = run_formatters(&one_file_in(&out, "python", "main.py"), &config, false)
.expect("registry mode must not abort when a resolver cannot run pre-publish");
assert_eq!(deferred.len(), 1, "expected exactly one deferred step: {deferred:?}");
assert_eq!(deferred[0].language, "python");
assert_eq!(deferred[0].step, "exit 3");
assert!(
deferred[0].reason.contains("not published yet"),
"reason must name the unpublished pin, got: {}",
deferred[0].reason
);
}
#[test]
fn registry_mode_reports_nothing_when_the_override_succeeds() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("e2e-out");
std::fs::create_dir_all(out.join("python")).unwrap();
let sentinel = out.join("python/ran.txt");
let sentinel_str = sentinel.to_string_lossy().replace('\\', "/");
let config = config_with_override(
&out,
"python",
&format!("touch {sentinel_str}"),
DependencyMode::Registry,
);
let deferred = run_formatters(&one_file_in(&out, "python", "main.py"), &config, false)
.expect("successful override must be Ok");
assert!(sentinel.exists(), "registry mode must still run the formatter");
assert!(
deferred.is_empty(),
"a successful step must not be deferred: {deferred:?}"
);
}
#[test]
fn deferred_entry_renders_language_step_and_reason() {
let entry = DeferredFormatting {
language: "go".to_owned(),
step: GO_MOD_TIDY_STEP.to_owned(),
reason: UNPUBLISHED_VERSION_REASON.to_owned(),
};
let rendered = entry.to_string();
assert!(rendered.starts_with("[go] go mod tidy — "), "got: {rendered}");
assert!(rendered.contains("not published yet"), "got: {rendered}");
}
#[test]
fn default_path_formats_python_with_poly() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
let out = base.join("e2e-out");
std::fs::create_dir_all(out.join("python")).unwrap();
let py = out.join("python/main.py");
std::fs::write(&py, "x=1").unwrap();
let e2e_config = e2e_config_for(&out);
let files = vec![GeneratedFile {
path: out.join("python/main.py"),
content: "x=1".to_owned(),
generated_header: false,
}];
if which::which("poly").is_ok() {
run_formatters(&files, &e2e_config, false).unwrap();
let formatted = std::fs::read_to_string(&py).unwrap();
assert_eq!(
formatted, "x = 1\n",
"with poly installed, `poly fmt --fix` must reformat the e2e Python file"
);
} else {
assert!(run_formatters(&files, &e2e_config, false).is_err());
}
}
fn config_with_absent_formatter(out: &Path) -> (E2eConfig, Vec<GeneratedFile>) {
std::fs::create_dir_all(out.join("python")).unwrap();
let py = out.join("python/main.py");
std::fs::write(&py, "x = 1\n").unwrap();
let mut e2e_config = e2e_config_for(out);
e2e_config.format.insert(
"python".to_owned(),
"alef_formatter_that_does_not_exist {dir}".to_owned(),
);
let files = vec![GeneratedFile {
path: py,
content: "x = 1\n".to_owned(),
generated_header: false,
}];
(e2e_config, files)
}
#[test]
fn unavailable_configured_formatter_aborts_generation_under_strict() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("e2e-out");
let (e2e_config, files) = config_with_absent_formatter(&out);
let error = run_formatters(&files, &e2e_config, true).expect_err("strict must fail on a missing formatter");
assert!(
error.to_string().contains("formatter for python exited"),
"got: {error}"
);
}
#[test]
fn unavailable_configured_formatter_is_recorded_and_survived_by_default() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("e2e-out");
let (e2e_config, files) = config_with_absent_formatter(&out);
let deferred = run_formatters(&files, &e2e_config, false).expect("a missing formatter must not abort the run");
assert_eq!(deferred.len(), 1, "the skip must be recorded, got: {deferred:?}");
assert_eq!(deferred[0].language, "python");
assert!(
deferred[0].step.contains("alef_formatter_that_does_not_exist"),
"the record must name the command that could not run, got: {}",
deferred[0].step
);
assert!(
deferred[0].reason.contains("not installed"),
"the record must say why, got: {}",
deferred[0].reason
);
}
#[test]
fn a_formatter_that_runs_and_fails_still_aborts_without_strict() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("e2e-out");
std::fs::create_dir_all(out.join("python")).unwrap();
let py = out.join("python/main.py");
std::fs::write(&py, "x = 1\n").unwrap();
let mut e2e_config = e2e_config_for(&out);
e2e_config.format.insert("python".to_owned(), "exit 3".to_owned());
let files = vec![GeneratedFile {
path: py,
content: "x = 1\n".to_owned(),
generated_header: false,
}];
let error = run_formatters(&files, &e2e_config, false).expect_err("a formatter that ran and failed must abort");
assert!(
error.to_string().contains("formatter for python exited"),
"got: {error}"
);
}
#[test]
fn cached_paths_use_the_same_formatter_pipeline() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("e2e-out");
std::fs::create_dir_all(out.join("python")).unwrap();
let py = out.join("python/main.py");
std::fs::write(&py, "x=1").unwrap();
let e2e_config = e2e_config_for(&out);
if which::which("poly").is_ok() {
run_formatters_for_cached_paths(std::slice::from_ref(&py), dir.path(), &e2e_config, false).unwrap();
let formatted = std::fs::read_to_string(&py).unwrap();
assert_eq!(formatted, "x = 1\n");
} else {
assert!(
run_formatters_for_cached_paths(std::slice::from_ref(&py), dir.path(), &e2e_config, false).is_err()
);
}
}
#[cfg(unix)]
#[test]
fn run_formatters_restores_exec_bit_on_shebang_scripts() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("e2e-out");
std::fs::create_dir_all(out.join("php")).unwrap();
let script = out.join("php/run_tests.php");
let content = "#!/usr/bin/env php\n<?php\n";
std::fs::write(&script, content).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o644)).unwrap();
let e2e_config = e2e_config_for(&out);
let files = vec![GeneratedFile {
path: script.clone(),
content: content.to_owned(),
generated_header: false,
}];
run_formatters(&files, &e2e_config, false).unwrap();
let mode = std::fs::metadata(&script).unwrap().permissions().mode();
assert!(
mode & 0o111 != 0,
"shebang script must be executable after run_formatters, got mode {mode:#o}"
);
}
#[test]
fn default_path_formats_elixir_with_mix() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("e2e-out");
std::fs::create_dir_all(out.join("elixir/test")).unwrap();
std::fs::write(
out.join("elixir/.formatter.exs"),
"[\n inputs: [\"{mix,.formatter}.exs\", \"{config,lib,test}/**/*.{ex,exs}\"],\n line_length: 140\n]\n",
)
.unwrap();
let long_call = format!("<blockquote><p>{}</p></blockquote>", "x".repeat(160));
let unformatted =
format!("defmodule T do\n def go do\n {{:ok, r}} = M.convert(\"{long_call}\")\n end\nend\n");
let test_file = out.join("elixir/test/smoke_test.exs");
std::fs::write(&test_file, &unformatted).unwrap();
let e2e_config = e2e_config_for(&out);
let files = vec![GeneratedFile {
path: test_file.clone(),
content: unformatted.clone(),
generated_header: false,
}];
let result = run_formatters(&files, &e2e_config, false);
let formatted = std::fs::read_to_string(&test_file).unwrap();
if which::which("poly").is_ok() && which::which("mix").is_ok() {
result.unwrap();
assert_ne!(
formatted, unformatted,
"with mix installed, the elixir residual must reformat the over-long call"
);
assert!(
formatted.contains("M.convert(\n"),
"mix must wrap the over-long call onto its own line, got:\n{formatted}"
);
} else {
assert!(result.is_err());
assert_eq!(formatted, unformatted, "without mix the file must be left untouched");
}
}
#[test]
fn unknown_language_dir_is_best_effort() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
let out = base.join("e2e-out");
std::fs::create_dir_all(out.join("cobol")).unwrap();
let e2e_config = e2e_config_for(&out);
let files = vec![GeneratedFile {
path: out.join("cobol/main.cob"),
content: " IDENTIFICATION DIVISION.\n".to_owned(),
generated_header: false,
}];
let result = run_formatters(&files, &e2e_config, false);
if which::which("poly").is_ok() {
result.unwrap();
} else {
assert!(result.is_err());
}
}
fn config_recording_order(out: &Path, log: &Path, languages: &[&str]) -> E2eConfig {
let mut e2e_config = e2e_config_for(out);
let log_str = log.to_string_lossy().replace('\\', "/");
for lang in languages {
std::fs::create_dir_all(out.join(lang)).expect("create language dir");
e2e_config
.format
.insert((*lang).to_owned(), format!("echo {lang} >> {log_str}"));
}
e2e_config
}
fn files_for(out: &Path, languages: &[&str]) -> Vec<GeneratedFile> {
languages
.iter()
.map(|lang| GeneratedFile {
path: out.join(lang).join("main.txt"),
content: String::new(),
generated_header: false,
})
.collect()
}
#[test]
fn languages_are_formatted_in_a_deterministic_order() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("e2e-out");
let log = dir.path().join("order.log");
let languages = ["python", "csharp", "go", "ruby", "elixir", "dart"];
let e2e_config = config_recording_order(&out, &log, &languages);
let files = files_for(&out, &languages);
run_formatters(&files, &e2e_config, false).expect("first pass");
let first = std::fs::read_to_string(&log).expect("order log");
std::fs::remove_file(&log).expect("reset log");
run_formatters(&files, &e2e_config, false).expect("second pass");
let second = std::fs::read_to_string(&log).expect("order log");
let mut expected = languages.to_vec();
expected.sort_unstable();
let expected = expected
.iter()
.map(|lang| format!("{lang}\n"))
.collect::<Vec<_>>()
.join("");
assert_eq!(first, expected, "languages must be formatted in sorted order");
assert_eq!(
second, first,
"two runs over an unchanged tree must format in the same order"
);
}
#[test]
fn a_failing_language_does_not_skip_the_others() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("e2e-out");
let log = dir.path().join("order.log");
let languages = ["python", "csharp", "go"];
let mut e2e_config = config_recording_order(&out, &log, &languages);
e2e_config.format.insert("csharp".to_owned(), "exit 1".to_owned());
let files = files_for(&out, &languages);
let error = run_formatters(&files, &e2e_config, false).expect_err("a failing formatter must fail the run");
let recorded = std::fs::read_to_string(&log).unwrap_or_default();
assert!(
recorded.contains("go") && recorded.contains("python"),
"languages after the failing one must still be formatted, recorded: {recorded:?}"
);
let message = format!("{error:#}");
assert!(
message.contains("csharp"),
"the failing language must be named, got: {message}"
);
assert!(
message.contains("1 of 3"),
"the report must say how many of how many failed, got: {message}"
);
}
#[test]
fn every_failing_language_is_named() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("e2e-out");
let log = dir.path().join("order.log");
let languages = ["python", "csharp", "go"];
let mut e2e_config = config_recording_order(&out, &log, &languages);
e2e_config.format.insert("csharp".to_owned(), "exit 1".to_owned());
e2e_config.format.insert("go".to_owned(), "exit 1".to_owned());
let files = files_for(&out, &languages);
let error = run_formatters(&files, &e2e_config, false).expect_err("failing formatters must fail the run");
let message = format!("{error:#}");
assert!(message.contains("csharp"), "got: {message}");
assert!(message.contains("go"), "got: {message}");
assert!(message.contains("2 of 3"), "got: {message}");
}
}