use crate::core::config::{Language, ResolvedCrateConfig};
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use tracing::{debug, warn};
struct FormatterCommand {
command: String,
args: Vec<String>,
}
struct FormatterSpec {
commands: Vec<FormatterCommand>,
work_dir: String,
}
fn get_default_formatter(config: &ResolvedCrateConfig, lang: Language) -> Option<FormatterSpec> {
match lang {
Language::Python => {
let package_path = config
.python
.as_ref()
.and_then(|python| python.stubs.as_ref())
.map(|stubs| stubs.output.to_string_lossy().into_owned())
.unwrap_or_else(|| "packages/python/".to_owned());
Some(FormatterSpec {
commands: vec![
FormatterCommand {
command: "ruff".to_owned(),
args: vec!["check".to_owned(), "--fix".to_owned(), package_path.clone()],
},
FormatterCommand {
command: "ruff".to_owned(),
args: vec!["format".to_owned(), package_path],
},
],
work_dir: String::new(),
})
}
Language::Node => Some(FormatterSpec {
commands: vec![
FormatterCommand {
command: "pnpm".to_owned(),
args: vec![
"dlx".to_owned(),
"oxfmt".to_owned(),
".".to_owned(),
"!**/*.toml".to_owned(),
],
},
FormatterCommand {
command: "pnpm".to_owned(),
args: vec![
"dlx".to_owned(),
"oxlint".to_owned(),
"--fix".to_owned(),
".".to_owned(),
],
},
],
work_dir: ".".to_owned(),
}),
Language::Ruby => {
let gem_name = config.ruby_gem_name();
let native_subdir = format!("ext/{gem_name}/native");
Some(FormatterSpec {
commands: vec![
FormatterCommand {
command: "rubocop".to_owned(),
args: vec!["-A".to_owned(), "--no-server".to_owned()],
},
FormatterCommand {
command: "cargo".to_owned(),
args: vec!["sort".to_owned(), native_subdir],
},
],
work_dir: "packages/ruby/".to_owned(),
})
}
Language::Php => Some(FormatterSpec {
commands: vec![FormatterCommand {
command: "php-cs-fixer".to_owned(),
args: vec!["fix".to_owned()],
}],
work_dir: "packages/php/".to_owned(),
}),
Language::Elixir => {
let app_name = config.elixir_app_name();
let native_subdir = format!("native/{app_name}_nif");
Some(FormatterSpec {
commands: vec![
FormatterCommand {
command: "mix".to_owned(),
args: vec!["format".to_owned()],
},
FormatterCommand {
command: "cargo".to_owned(),
args: vec!["sort".to_owned(), native_subdir],
},
],
work_dir: "packages/elixir/".to_owned(),
})
}
Language::Go => Some(FormatterSpec {
commands: vec![
FormatterCommand {
command: "gofmt".to_owned(),
args: vec!["-s".to_owned(), "-w".to_owned(), ".".to_owned()],
},
FormatterCommand {
command: "goimports".to_owned(),
args: vec!["-w".to_owned(), ".".to_owned()],
},
],
work_dir: "packages/go/".to_owned(),
}),
Language::Java => Some(FormatterSpec {
commands: vec![FormatterCommand {
command: "google-java-format".to_owned(),
args: vec!["-i".to_owned()],
}],
work_dir: "packages/java/src/".to_owned(),
}),
Language::Csharp => {
let mut args = vec!["format".to_owned()];
let work_dir = "packages/csharp/".to_owned();
if let Some(project_file) = config.project_file_for_language(Language::Csharp) {
let relative = Path::new(project_file)
.strip_prefix(&work_dir)
.unwrap_or(Path::new(project_file));
args.push(relative.to_string_lossy().into_owned());
}
Some(FormatterSpec {
commands: vec![FormatterCommand {
command: "dotnet".to_owned(),
args,
}],
work_dir,
})
}
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 manifest_path = crate_dir
.join("Cargo.toml")
.to_string_lossy()
.into_owned()
.replace('\\', "/");
let crate_dir_str = crate_dir.to_string_lossy().into_owned().replace('\\', "/");
Some(FormatterSpec {
commands: vec![
FormatterCommand {
command: "cargo".to_owned(),
args: vec!["fmt".to_owned(), "--manifest-path".to_owned(), manifest_path],
},
FormatterCommand {
command: "cargo".to_owned(),
args: vec!["sort".to_owned(), crate_dir_str],
},
],
work_dir: String::new(),
})
}
Language::Ffi => Some(FormatterSpec {
commands: vec![
FormatterCommand {
command: "cargo".to_owned(),
args: vec!["fmt".to_owned(), "--all".to_owned()],
},
FormatterCommand {
command: "cargo".to_owned(),
args: vec!["sort".to_owned(), "-w".to_owned()],
},
],
work_dir: String::new(),
}),
Language::R => Some(FormatterSpec {
commands: vec![
FormatterCommand {
command: "Rscript".to_owned(),
args: vec!["-e".to_owned(), "styler::style_pkg('packages/r')".to_owned()],
},
FormatterCommand {
command: "cargo".to_owned(),
args: vec!["sort".to_owned(), "packages/r/src/rust".to_owned()],
},
],
work_dir: String::new(),
}),
Language::Kotlin => Some(FormatterSpec {
commands: vec![FormatterCommand {
command: "ktfmt".to_owned(),
args: vec!["--kotlinlang-style".to_owned()],
}],
work_dir: "packages/kotlin/src".to_owned(),
}),
Language::KotlinAndroid => Some(FormatterSpec {
commands: vec![FormatterCommand {
command: "ktfmt".to_owned(),
args: vec!["--kotlinlang-style".to_owned()],
}],
work_dir: "packages/kotlin-android/src".to_owned(),
}),
Language::Swift => Some(FormatterSpec {
commands: vec![FormatterCommand {
command: "swift".to_owned(),
args: vec![
"format".to_owned(),
"--in-place".to_owned(),
"--recursive".to_owned(),
"Sources".to_owned(),
],
}],
work_dir: "packages/swift/".to_owned(),
}),
Language::Dart => Some(FormatterSpec {
commands: vec![FormatterCommand {
command: "dart".to_owned(),
args: vec!["format".to_owned(), ".".to_owned()],
}],
work_dir: "packages/dart/".to_owned(),
}),
Language::Gleam => Some(FormatterSpec {
commands: vec![FormatterCommand {
command: "gleam".to_owned(),
args: vec!["format".to_owned()],
}],
work_dir: "packages/gleam/".to_owned(),
}),
Language::Zig => Some(FormatterSpec {
commands: vec![FormatterCommand {
command: "zig".to_owned(),
args: vec!["fmt".to_owned(), "src".to_owned()],
}],
work_dir: "packages/zig/".to_owned(),
}),
Language::Rust | Language::C | Language::Jni => None,
}
}
fn detect_spotless_pom(base_dir: &Path, java_work_dir: &str) -> Option<PathBuf> {
let pom = base_dir.join(java_work_dir).parent()?.join("pom.xml");
if !pom.is_file() {
return None;
}
let content = std::fs::read_to_string(&pom).ok()?;
if content.contains("spotless-maven-plugin") {
Some(pom)
} else {
None
}
}
fn collect_java_files(dir: &Path, limit: usize) -> Vec<PathBuf> {
let pattern = format!("{}/**/*.java", dir.display());
let Ok(entries) = glob::glob(&pattern) else {
return vec![];
};
entries.flatten().filter(|p| p.is_file()).take(limit).collect()
}
fn collect_kotlin_files(dir: &Path, limit: usize) -> Vec<PathBuf> {
let mut out = Vec::new();
for ext in ["kt", "kts"] {
let pattern = format!("{}/**/*.{ext}", dir.display());
let Ok(entries) = glob::glob(&pattern) else { continue };
for entry in entries.flatten() {
if entry.is_file() {
out.push(entry);
if out.len() >= limit {
return out;
}
}
}
}
out
}
pub fn format_generated(
files: &[(Language, Vec<crate::core::backend::GeneratedFile>)],
config: &ResolvedCrateConfig,
base_dir: &Path,
only_languages: Option<&std::collections::HashSet<Language>>,
) {
let mut formatted_langs = std::collections::HashSet::new();
for (lang, _) in files {
if formatted_langs.contains(lang) {
continue;
}
let lang_str = lang.to_string().to_lowercase();
let format_cfg = config
.format_overrides
.get(&lang_str)
.cloned()
.unwrap_or_else(|| config.format.clone());
let has_custom_command = format_cfg.command.is_some();
if !has_custom_command {
if let Some(filter) = only_languages {
if !filter.contains(lang) {
continue;
}
}
}
if !format_cfg.enabled {
debug!(" [{lang_str}] formatting disabled, skipping");
continue;
}
let formatter_cmd = if let Some(custom) = format_cfg.command {
if let Err(e) = run_custom_formatter(&custom, base_dir) {
warn!("[{lang_str}] custom formatter failed: {e}");
}
formatted_langs.insert(*lang);
continue;
} else if let Some(spec) = get_default_formatter(config, *lang) {
if *lang == Language::Java
&& let Some(pom) = detect_spotless_pom(base_dir, &spec.work_dir)
{
debug!(
" [java] spotless detected at {}, using mvn spotless:apply",
pom.display()
);
FormatterSpec {
commands: vec![FormatterCommand {
command: "mvn".to_owned(),
args: vec![
"-f".to_owned(),
pom.to_string_lossy().into_owned(),
"spotless:apply".to_owned(),
"-q".to_owned(),
],
}],
work_dir: spec.work_dir,
}
} else {
spec
}
} else {
debug!(" [{lang_str}] no default formatter configured");
continue;
};
let work_dir = if formatter_cmd.work_dir.is_empty() {
base_dir.to_path_buf()
} else {
base_dir.join(&formatter_cmd.work_dir)
};
if !work_dir.exists() {
debug!(
" [{lang_str}] package directory does not exist: {}, skipping",
work_dir.display()
);
continue;
}
for step in &formatter_cmd.commands {
if !is_tool_available(&step.command) {
warn!("[{lang_str}] formatter not found: {} (skipping format)", step.command);
break;
}
let extra_args: Vec<String> = if *lang == Language::Java && step.command == "google-java-format" {
const JAVA_FILE_BATCH_LIMIT: usize = 200;
let java_files = collect_java_files(&work_dir, JAVA_FILE_BATCH_LIMIT);
if java_files.is_empty() {
debug!(
" [{lang_str}] no .java files found in {}, skipping",
work_dir.display()
);
break;
}
java_files
.into_iter()
.map(|p| p.to_string_lossy().into_owned())
.collect()
} else if (*lang == Language::KotlinAndroid || *lang == Language::Kotlin) && step.command == "ktfmt" {
const KOTLIN_FILE_BATCH_LIMIT: usize = 500;
let kotlin_files = collect_kotlin_files(&work_dir, KOTLIN_FILE_BATCH_LIMIT);
if kotlin_files.is_empty() {
debug!(
" [{lang_str}] no .kt/.kts files found in {}, skipping",
work_dir.display()
);
break;
}
kotlin_files
.into_iter()
.map(|p| p.to_string_lossy().into_owned())
.collect()
} else {
vec![]
};
let mut all_args: Vec<&str> = step.args.iter().map(String::as_str).collect();
let extra_refs: Vec<&str> = extra_args.iter().map(String::as_str).collect();
all_args.extend_from_slice(&extra_refs);
match run_formatter(&step.command, &all_args, &work_dir) {
Ok(()) => {
debug!(" [{lang_str}] {} {:?} ok", step.command, all_args);
}
Err(e) => {
warn!("[{lang_str}] {} {:?} failed: {}", step.command, all_args, e);
break;
}
}
}
formatted_langs.insert(*lang);
}
shfmt_emitted_scripts(files, base_dir);
}
fn shfmt_emitted_scripts(files: &[(Language, Vec<crate::core::backend::GeneratedFile>)], base_dir: &Path) {
let scripts: Vec<String> = files
.iter()
.flat_map(|(_, lang_files)| lang_files.iter())
.filter(|f| f.path.extension().is_some_and(|e| e == "sh"))
.map(|f| base_dir.join(&f.path).to_string_lossy().into_owned())
.collect();
if scripts.is_empty() {
return;
}
if !is_tool_available("shfmt") {
warn!("shfmt not found on PATH (skipping shell-script format)");
return;
}
let mut args: Vec<&str> = vec!["-w", "-i", "2"];
args.extend(scripts.iter().map(String::as_str));
match run_formatter("shfmt", &args, base_dir) {
Ok(()) => debug!(" [shell] shfmt over {} script(s) ok", scripts.len()),
Err(e) => warn!("[shell] shfmt failed: {e}"),
}
}
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;