#![deny(missing_docs)]
use std::collections::BTreeSet;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use thiserror::Error;
pub(crate) use cabin_env::CABIN_FMT as CABIN_FMT_ENV;
pub(crate) const DEFAULT_FORMATTER_EXECUTABLE: &str = "clang-format";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FormatMode {
Write,
Check,
}
#[derive(Debug, Clone)]
pub struct FormatRequest {
pub executable: OsString,
pub files: Vec<PathBuf>,
pub mode: FormatMode,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FormatReport {
Wrote {
files_processed: usize,
},
Clean {
files_inspected: usize,
},
NeedsFormatting {
files_inspected: usize,
stderr: String,
},
}
#[derive(Debug, Error)]
pub enum FormatError {
#[error(
"{executable} was not found on PATH.\n install `clang-format` (LLVM toolchain) and re-run, or set `{env}=/path/to/clang-format` to a specific binary"
)]
ExecutableNotFound {
executable: String,
env: &'static str,
},
#[error("failed to invoke {executable}: {source}")]
SpawnFailed {
executable: String,
#[source]
source: std::io::Error,
},
#[error("{executable} exited with status {status}{}", display_stderr(stderr))]
InvocationFailed {
executable: String,
status: ExitStatusKind,
stderr: String,
},
}
pub use cabin_core::ExitStatusKind;
fn display_stderr(stderr: &str) -> String {
if stderr.is_empty() {
String::new()
} else {
format!("\n{stderr}")
}
}
pub fn resolve_formatter_executable<F>(env: F) -> OsString
where
F: Fn(&str) -> Option<OsString>,
{
if let Some(value) = env(CABIN_FMT_ENV)
&& !value.is_empty()
{
return value;
}
OsString::from(DEFAULT_FORMATTER_EXECUTABLE)
}
pub fn run_formatter(request: &FormatRequest) -> Result<FormatReport, FormatError> {
if request.files.is_empty() {
return Ok(match request.mode {
FormatMode::Write => FormatReport::Wrote { files_processed: 0 },
FormatMode::Check => FormatReport::Clean { files_inspected: 0 },
});
}
let files: BTreeSet<&Path> = request.files.iter().map(PathBuf::as_path).collect();
let files: Vec<&Path> = files.into_iter().collect();
let mut cmd = Command::new(&request.executable);
cmd.arg("--style=file");
match request.mode {
FormatMode::Write => {
cmd.arg("-i");
}
FormatMode::Check => {
cmd.arg("--dry-run").arg("-Werror");
}
}
for path in &files {
cmd.arg(path);
}
let output = match cmd.output() {
Ok(output) => output,
Err(err) => {
let executable = request.executable.to_string_lossy().into_owned();
if err.kind() == std::io::ErrorKind::NotFound {
return Err(FormatError::ExecutableNotFound {
executable,
env: CABIN_FMT_ENV,
});
}
return Err(FormatError::SpawnFailed {
executable,
source: err,
});
}
};
let status = output.status;
let stderr = String::from_utf8_lossy(&output.stderr)
.trim_end()
.to_owned();
match request.mode {
FormatMode::Write => {
if status.success() {
Ok(FormatReport::Wrote {
files_processed: files.len(),
})
} else {
Err(FormatError::InvocationFailed {
executable: request.executable.to_string_lossy().into_owned(),
status: cabin_core::exit_status_kind(status),
stderr,
})
}
}
FormatMode::Check => {
if status.success() {
return Ok(FormatReport::Clean {
files_inspected: files.len(),
});
}
if status.code() == Some(1) {
return Ok(FormatReport::NeedsFormatting {
files_inspected: files.len(),
stderr,
});
}
Err(FormatError::InvocationFailed {
executable: request.executable.to_string_lossy().into_owned(),
status: cabin_core::exit_status_kind(status),
stderr,
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env_returning<'a>(
pairs: &'a [(&'a str, &'a str)],
) -> impl Fn(&str) -> Option<OsString> + 'a {
move |key| {
pairs
.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| OsString::from(*v))
}
}
#[test]
fn default_executable_is_clang_format_when_env_unset() {
let resolved = resolve_formatter_executable(|_| None);
assert_eq!(resolved, OsString::from(DEFAULT_FORMATTER_EXECUTABLE));
}
#[test]
fn env_override_wins() {
let env = env_returning(&[(CABIN_FMT_ENV, "/opt/llvm/bin/clang-format")]);
let resolved = resolve_formatter_executable(env);
assert_eq!(resolved, OsString::from("/opt/llvm/bin/clang-format"));
}
#[test]
fn empty_env_value_falls_back_to_default() {
let env = env_returning(&[(CABIN_FMT_ENV, "")]);
let resolved = resolve_formatter_executable(env);
assert_eq!(resolved, OsString::from(DEFAULT_FORMATTER_EXECUTABLE));
}
#[test]
fn empty_files_is_a_clean_no_op() {
let req = FormatRequest {
executable: OsString::from("this-binary-should-not-be-invoked"),
files: Vec::new(),
mode: FormatMode::Check,
};
let report = run_formatter(&req).unwrap();
assert!(matches!(report, FormatReport::Clean { files_inspected: 0 }));
let req = FormatRequest {
mode: FormatMode::Write,
..req
};
let report = run_formatter(&req).unwrap();
assert!(matches!(report, FormatReport::Wrote { files_processed: 0 }));
}
#[test]
fn missing_executable_yields_actionable_error() {
let req = FormatRequest {
executable: OsString::from("/no-such/clang-format-binary"),
files: vec![PathBuf::from("/no-such/file.cc")],
mode: FormatMode::Write,
};
let err = run_formatter(&req).unwrap_err();
match err {
FormatError::ExecutableNotFound { executable, env } => {
assert_eq!(executable, "/no-such/clang-format-binary");
assert_eq!(env, CABIN_FMT_ENV);
}
other => panic!("unexpected error: {other:?}"),
}
}
}