mod stamp_gate;
pub(crate) use stamp_gate::generated_tree_needs_formatting;
pub use stamp_gate::unstamp_before_formatting;
use crate::core::config::{Language, OutputLayout, ResolvedCrateConfig};
use crate::e2e::format::DeferredFormatting;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use tracing::{debug, warn};
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";
const PACKAGE_TREE_SCOPE: &str = "packages";
struct FormatPass<'probe> {
is_available: &'probe dyn Fn(&str) -> bool,
skipped: Vec<DeferredFormatting>,
}
impl<'probe> FormatPass<'probe> {
fn new(is_available: &'probe dyn Fn(&str) -> bool) -> Self {
Self {
is_available,
skipped: Vec::new(),
}
}
fn available(&self, tool: &str) -> bool {
(self.is_available)(tool)
}
fn record_missing(&mut self, scope: &str, tool: &str, step: &str) {
self.skipped.push(DeferredFormatting {
language: scope.to_owned(),
step: format!("{step} (missing: {tool})"),
reason: MISSING_TOOLCHAIN_REASON.to_owned(),
});
}
}
#[derive(Debug)]
struct ResidualStep {
command: String,
args: Vec<String>,
work_dir: PathBuf,
}
#[derive(Clone, Copy)]
struct RequiredFormatter {
tool: &'static str,
install_hint: &'static str,
}
fn required_formatters(languages: &[Language]) -> Vec<RequiredFormatter> {
let mut required = vec![
RequiredFormatter {
tool: "rustfmt",
install_hint: "rustup component add rustfmt",
},
RequiredFormatter {
tool: "poly",
install_hint: "install polylint (`poly`) and put it on PATH",
},
];
let needs_cargo_sort = languages.iter().any(|language| {
matches!(
language,
Language::Wasm | Language::Ffi | Language::Ruby | Language::Elixir | Language::R
)
});
if needs_cargo_sort {
required.push(RequiredFormatter {
tool: "cargo-sort",
install_hint: "cargo install cargo-sort",
});
}
if languages.contains(&Language::Elixir) {
required.push(RequiredFormatter {
tool: "mix",
install_hint: "install Elixir (https://elixir-lang.org/install.html); `mix` ships with it",
});
}
required
}
pub fn warn_missing_formatters(languages: &[Language]) {
let missing: Vec<RequiredFormatter> = required_formatters(languages)
.into_iter()
.filter(|formatter| !is_tool_available(formatter.tool))
.collect();
if missing.is_empty() {
return;
}
let details = missing
.iter()
.map(|formatter| format!(" - {}: {}", formatter.tool, formatter.install_hint))
.collect::<Vec<_>>()
.join("\n");
warn!(
"code formatter(s) not found on PATH; generated output may be un(der)-formatted and \
host-dependent (#184). Install to restore deterministic formatting:\n{details}"
);
}
pub fn format_generated(config: &ResolvedCrateConfig, base_dir: &Path, only_languages: Option<&HashSet<Language>>) {
let skipped = run_format_pass(config, base_dir, only_languages, &is_tool_available);
crate::e2e::format::warn_deferred(&skipped);
}
pub fn format_generated_reporting(
config: &ResolvedCrateConfig,
base_dir: &Path,
only_languages: Option<&HashSet<Language>>,
strict: bool,
) -> anyhow::Result<Vec<DeferredFormatting>> {
format_generated_reporting_with(config, base_dir, only_languages, strict, &is_tool_available)
}
pub(crate) fn format_generated_reporting_with(
config: &ResolvedCrateConfig,
base_dir: &Path,
only_languages: Option<&HashSet<Language>>,
strict: bool,
is_available: &dyn Fn(&str) -> bool,
) -> anyhow::Result<Vec<DeferredFormatting>> {
let skipped = run_format_pass(config, base_dir, only_languages, is_available);
crate::e2e::format::warn_deferred(&skipped);
escalate_missing_toolchains(skipped, strict)
}
fn escalate_missing_toolchains(
skipped: Vec<DeferredFormatting>,
strict: bool,
) -> anyhow::Result<Vec<DeferredFormatting>> {
let missing: Vec<String> = skipped
.iter()
.filter(|entry| entry.is_missing_toolchain())
.map(|entry| format!("[{}] {}", entry.language, entry.step))
.collect();
if !strict || missing.is_empty() {
return Ok(skipped);
}
anyhow::bail!(
"--strict: {} formatting step(s) could not run because their executable is not installed, so the \
generated packages are NOT formatted: {}",
missing.len(),
missing.join("; ")
)
}
fn run_format_pass(
config: &ResolvedCrateConfig,
base_dir: &Path,
only_languages: Option<&HashSet<Language>>,
is_available: &dyn Fn(&str) -> bool,
) -> Vec<DeferredFormatting> {
let mut pass = FormatPass::new(is_available);
match only_languages {
None => converge_full_regen(base_dir, &mut pass),
Some(only) => {
let poly_langs: Vec<Language> = only.iter().copied().collect();
if poly_langs.is_empty() {
return pass.skipped;
}
let paths = poly_paths(config, base_dir, only_languages, &poly_langs);
poly_format_pass(&paths, base_dir, &mut pass);
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, &mut pass);
}
}
}
}
pass.skipped
}
const MAX_POLY_FMT_PASSES: u32 = 3;
pub(crate) fn converge_full_regen_formatting(base_dir: &Path) {
let mut pass = FormatPass::new(&is_tool_available);
converge_full_regen(base_dir, &mut pass);
crate::e2e::format::warn_deferred(&pass.skipped);
}
fn converge_full_regen(base_dir: &Path, pass: &mut FormatPass<'_>) {
let poly_present = pass.available("poly");
if !poly_present {
pass.record_missing(PACKAGE_TREE_SCOPE, "poly", POLY_FMT_STEP);
}
let root = vec![base_dir.to_path_buf()];
for _iteration in 1..=MAX_POLY_FMT_PASSES {
if poly_present {
poly_format_pass(&root, base_dir, pass);
}
run_cargo_fmt(base_dir, pass);
run_workspace_cargo_sort(base_dir, pass);
if !poly_present || poly_fmt_is_clean(base_dir) {
run_elixir_mix_format(base_dir, pass);
return;
}
}
warn!(
"poly fmt did not converge after {MAX_POLY_FMT_PASSES} passes (non-fatal); generated \
output may have residual formatting drift"
);
run_elixir_mix_format(base_dir, pass);
}
fn poly_fmt_is_clean(base_dir: &Path) -> bool {
let path_str = base_dir.to_string_lossy().into_owned();
let mut args: Vec<String> = vec!["fmt".to_owned(), "--check".to_owned(), path_str];
push_poly_elixir_excludes(&mut args);
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
run_formatter("poly", &arg_refs, base_dir).is_ok()
}
fn run_cargo_fmt(base_dir: &Path, pass: &mut FormatPass<'_>) {
if !base_dir.join("Cargo.toml").exists() {
debug!(
"no root Cargo.toml at {}, skipping workspace cargo fmt",
base_dir.display()
);
return;
}
for tool in ["cargo", "rustfmt"] {
if !pass.available(tool) {
pass.record_missing(PACKAGE_TREE_SCOPE, tool, CARGO_FMT_STEP);
return;
}
}
match run_formatter("cargo", &["fmt", "--all"], base_dir) {
Ok(()) => debug!("cargo fmt --all ok"),
Err(e) => warn!("cargo fmt --all failed (non-fatal): {e}"),
}
}
fn run_workspace_cargo_sort(base_dir: &Path, pass: &mut FormatPass<'_>) {
if !base_dir.join("Cargo.toml").exists() {
debug!(
"no root Cargo.toml at {}, skipping workspace cargo sort",
base_dir.display()
);
return;
}
if !pass.available("cargo-sort") {
pass.record_missing(PACKAGE_TREE_SCOPE, "cargo-sort", CARGO_SORT_STEP);
return;
}
match run_formatter("cargo", &["sort", "-n", "-w"], base_dir) {
Ok(()) => debug!("cargo sort -n -w ok"),
Err(e) => warn!("cargo sort -n -w failed (non-fatal): {e}"),
}
}
pub fn poly_lint(base_dir: &Path) -> anyhow::Result<()> {
poly_lint_with(base_dir, &is_tool_available)
}
pub(crate) fn poly_lint_with(base_dir: &Path, is_available: &dyn Fn(&str) -> bool) -> anyhow::Result<()> {
if !is_available("poly") {
anyhow::bail!("poly not found on PATH; \"alef lint\" has nothing else to run -- install poly to lint");
}
let path_str = base_dir.to_string_lossy().into_owned();
let arg_refs: Vec<&str> = vec!["lint", &path_str];
match run_formatter("poly", &arg_refs, base_dir) {
Ok(()) => {
debug!("poly lint ok");
Ok(())
}
Err(e) => Err(anyhow::anyhow!("poly lint failed: {e}")),
}
}
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 package_dir = base_dir.join(config.package_dir(lang));
let output_path = config.output_for(&lang.to_string());
let output_dir = output_path.map(|out| base_dir.join(out));
let crate_root = output_path
.map(|out| OutputLayout::from_output_dir(&out.to_string_lossy()).root)
.map(|root| base_dir.join(root));
for dir in std::iter::once(package_dir).chain(output_dir).chain(crate_root) {
if seen.insert(dir.clone()) && dir.exists() {
dirs.push(dir);
}
}
}
collapse_nested_paths(dirs)
}
}
}
pub(crate) fn languages_owning_changed_paths(
config: &ResolvedCrateConfig,
base_dir: &Path,
languages: &[Language],
changed_paths: &HashSet<PathBuf>,
) -> HashSet<Language> {
if changed_paths.is_empty() {
return HashSet::new();
}
let mut owners = HashSet::new();
for &lang in languages {
let single = HashSet::from([lang]);
let dirs = poly_paths(config, base_dir, Some(&single), &[lang]);
if changed_paths
.iter()
.any(|path| dirs.iter().any(|dir| path.starts_with(dir)))
{
owners.insert(lang);
}
}
owners
}
fn collapse_nested_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
paths
.iter()
.filter(|candidate| {
!paths
.iter()
.any(|other| other != *candidate && candidate.starts_with(other))
})
.cloned()
.collect()
}
const POLY_ELIXIR_EXCLUDE_GLOBS: [&str; 2] = ["**/*.ex", "**/*.exs"];
fn push_poly_elixir_excludes(args: &mut Vec<String>) {
for glob in POLY_ELIXIR_EXCLUDE_GLOBS {
args.push("--exclude".to_owned());
args.push(glob.to_owned());
}
}
pub(crate) fn poly_format(paths: &[PathBuf], config_start: &Path) {
let mut pass = FormatPass::new(&is_tool_available);
poly_format_pass(paths, config_start, &mut pass);
crate::e2e::format::warn_deferred(&pass.skipped);
}
fn poly_format_pass(paths: &[PathBuf], config_start: &Path, pass: &mut FormatPass<'_>) {
if paths.is_empty() {
return;
}
if !pass.available("poly") {
pass.record_missing(PACKAGE_TREE_SCOPE, "poly", POLY_FMT_STEP);
return;
}
if let Err(error) = poly_format_strict(paths, config_start) {
warn!("poly fmt failed (non-fatal): {error}");
}
}
const POLY_FMT_STEP: &str = "poly fmt --fix";
const CARGO_FMT_STEP: &str = "cargo fmt --all";
const CARGO_SORT_STEP: &str = "cargo sort -n -w";
pub(crate) fn poly_format_strict(paths: &[PathBuf], config_start: &Path) -> anyhow::Result<()> {
if paths.is_empty() {
return Ok(());
}
if !is_tool_available("poly") {
anyhow::bail!("poly not found on PATH; generated output cannot be formatted");
}
let executable_modes = snapshot_executable_modes(paths);
let mut args: Vec<String> = vec!["fmt".to_owned(), "--fix".to_owned()];
args.extend(paths.iter().map(|path| path.to_string_lossy().into_owned()));
push_poly_elixir_excludes(&mut args);
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
let result = run_poly_formatter(&arg_refs, config_start);
restore_executable_modes(&executable_modes);
result?;
debug!("poly fmt over {} path(s) ok", paths.len());
Ok(())
}
fn run_poly_formatter(args: &[&str], work_dir: &Path) -> anyhow::Result<()> {
let output = Command::new("poly").args(args).current_dir(work_dir).output()?;
if poly_format_exit_code_is_success(output.status.code()) && !poly_format_output_reports_failure(&output.stderr) {
return Ok(());
}
Err(formatter_failure(&output))
}
fn poly_format_exit_code_is_success(exit_code: Option<i32>) -> bool {
matches!(exit_code, Some(0 | 1))
}
fn poly_format_output_reports_failure(stderr: &[u8]) -> bool {
String::from_utf8_lossy(stderr).contains("format failed:")
}
#[cfg(unix)]
const EXEC_SNAPSHOT_SKIP_DIRS: &[&str] = &[
".git",
"target",
"node_modules",
".venv",
"venv",
"vendor",
"deps",
"_build",
"build",
".build",
"zig-out",
"dist",
"__pycache__",
".gradle",
".dart_tool",
".zig-cache",
".cache",
];
#[cfg(unix)]
const EXECUTE_BITS: u32 = 0o111;
#[cfg(unix)]
fn snapshot_executable_modes(paths: &[PathBuf]) -> Vec<(PathBuf, u32)> {
use std::os::unix::fs::PermissionsExt as _;
let mut snapshot = Vec::new();
for root in paths {
let walker = walkdir::WalkDir::new(root).into_iter().filter_entry(|entry| {
!entry.file_type().is_dir()
|| entry
.file_name()
.to_str()
.is_none_or(|name| !EXEC_SNAPSHOT_SKIP_DIRS.contains(&name))
});
for entry in walker.filter_map(Result::ok) {
if !entry.file_type().is_file() {
continue;
}
let Ok(metadata) = entry.metadata() else { continue };
let mode = metadata.permissions().mode();
if mode & EXECUTE_BITS != 0 {
snapshot.push((entry.into_path(), mode));
}
}
}
snapshot
}
#[cfg(unix)]
fn restore_executable_modes(snapshot: &[(PathBuf, u32)]) {
use std::os::unix::fs::PermissionsExt as _;
for (path, mode) in snapshot {
let Ok(metadata) = std::fs::metadata(path) else {
continue;
};
if metadata.permissions().mode() & EXECUTE_BITS == mode & EXECUTE_BITS {
continue;
}
match std::fs::set_permissions(path, std::fs::Permissions::from_mode(*mode)) {
Ok(()) => debug!("restored exec bit on {}", path.display()),
Err(e) => warn!("failed to restore exec bit on {}: {e}", path.display()),
}
}
}
#[cfg(not(unix))]
fn snapshot_executable_modes(_paths: &[PathBuf]) -> Vec<(PathBuf, u32)> {
Vec::new()
}
#[cfg(not(unix))]
fn restore_executable_modes(_snapshot: &[(PathBuf, u32)]) {}
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 ext_name = config.ruby_native_ext_name();
let native_subdir = format!("ext/{ext_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");
let elixir_dir = base_dir.join("packages/elixir");
vec![
cargo_sort(vec![native_subdir], elixir_dir.clone()),
mix_deps_get(elixir_dir.clone()),
mix_format(elixir_dir),
]
}
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(), "-n".to_owned()];
args.append(&mut sort_args);
ResidualStep {
command: "cargo".to_owned(),
args,
work_dir,
}
}
fn mix_deps_get(work_dir: PathBuf) -> ResidualStep {
ResidualStep {
command: "mix".to_owned(),
args: vec!["deps.get".to_owned()],
work_dir,
}
}
fn mix_format(work_dir: PathBuf) -> ResidualStep {
ResidualStep {
command: "mix".to_owned(),
args: vec!["format".to_owned()],
work_dir,
}
}
fn run_elixir_mix_format(base_dir: &Path, pass: &mut FormatPass<'_>) {
let elixir_dir = base_dir.join("packages/elixir");
if !elixir_dir.join("mix.exs").exists() {
debug!(
"no packages/elixir/mix.exs at {}, skipping full-regen mix format",
base_dir.display()
);
return;
}
run_residual(&mix_deps_get(elixir_dir.clone()), "elixir", pass);
run_residual(&mix_format(elixir_dir), "elixir", pass);
}
fn run_residual(step: &ResidualStep, lang_str: &str, pass: &mut FormatPass<'_>) {
if !step.work_dir.exists() {
debug!(
" [{lang_str}] residual work dir does not exist: {}, skipping",
step.work_dir.display()
);
return;
}
if !pass.available(&step.command) {
let command_line = std::iter::once(step.command.as_str())
.chain(step.args.iter().map(String::as_str))
.collect::<Vec<_>>()
.join(" ");
pass.record_missing(lang_str, &step.command, &command_line);
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),
}
}
pub(crate) fn is_tool_available(tool: &str) -> bool {
is_tool_available_on(tool, std::env::var_os("PATH"))
}
fn is_tool_available_on(tool: &str, path_var: Option<std::ffi::OsString>) -> bool {
which::which_in(tool, path_var, std::env::current_dir().unwrap_or_default()).is_ok()
}
#[path = "format/external_formatter.rs"]
mod external_formatter;
use external_formatter::{formatter_failure, resolve_crate_dir, run_formatter};
#[cfg(test)]
mod scope_tests;
#[cfg(test)]
mod strict_tests;
#[cfg(test)]
mod tests;