use std::ffi::OsStr;
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
const MIN_PY_REV: (u32, u32) = (3, 7);
const MIN_PY_REV_STR: &str = "≥3.7";
#[cfg(target_os = "windows")]
const REL_PY_PATH: &[&str] = &["Scripts", "python3.exe"];
#[cfg(not(target_os = "windows"))]
const REL_PY_PATH: &[&str] = &["bin", "python3"];
const RUFF_CONFIG_PATH: &[&str] = &["src", "tools", "tidy", "config", "ruff.toml"];
const BLACK_CONFIG_PATH: &[&str] = &["src", "tools", "tidy", "config", "black.toml"];
const RUFF_CACH_PATH: &[&str] = &["cache", "ruff_cache"];
const PIP_REQ_PATH: &[&str] = &["src", "tools", "tidy", "config", "requirements.txt"];
pub fn check(
root_path: &Path,
outdir: &Path,
bless: bool,
extra_checks: Option<&str>,
pos_args: &[String],
bad: &mut bool,
) {
if let Err(e) = check_impl(root_path, outdir, bless, extra_checks, pos_args) {
tidy_error!(bad, "{e}");
}
}
fn check_impl(
root_path: &Path,
outdir: &Path,
bless: bool,
extra_checks: Option<&str>,
pos_args: &[String],
) -> Result<(), Error> {
let show_diff = std::env::var("TIDY_PRINT_DIFF")
.map_or(false, |v| v.eq_ignore_ascii_case("true") || v == "1");
let lint_args = match extra_checks {
Some(s) => s.strip_prefix("--extra-checks=").unwrap().split(',').collect(),
None => vec![],
};
let python_all = lint_args.contains(&"py");
let python_lint = lint_args.contains(&"py:lint") || python_all;
let python_fmt = lint_args.contains(&"py:fmt") || python_all;
let shell_all = lint_args.contains(&"shell");
let shell_lint = lint_args.contains(&"shell:lint") || shell_all;
let mut py_path = None;
let (cfg_args, file_args): (Vec<_>, Vec<_>) = pos_args
.into_iter()
.map(OsStr::new)
.partition(|arg| arg.to_str().is_some_and(|s| s.starts_with("-")));
if python_lint || python_fmt {
let venv_path = outdir.join("venv");
let mut reqs_path = root_path.to_owned();
reqs_path.extend(PIP_REQ_PATH);
py_path = Some(get_or_create_venv(&venv_path, &reqs_path)?);
}
if python_lint {
eprintln!("linting python files");
let mut cfg_args_ruff = cfg_args.clone();
let mut file_args_ruff = file_args.clone();
let mut cfg_path = root_path.to_owned();
cfg_path.extend(RUFF_CONFIG_PATH);
let mut cache_dir = outdir.to_owned();
cache_dir.extend(RUFF_CACH_PATH);
cfg_args_ruff.extend([
"--config".as_ref(),
cfg_path.as_os_str(),
"--cache-dir".as_ref(),
cache_dir.as_os_str(),
]);
if file_args_ruff.is_empty() {
file_args_ruff.push(root_path.as_os_str());
}
let mut args = merge_args(&cfg_args_ruff, &file_args_ruff);
let res = py_runner(py_path.as_ref().unwrap(), "ruff", &args);
if res.is_err() && show_diff {
eprintln!("\npython linting failed! Printing diff suggestions:");
args.insert(0, "--diff".as_ref());
let _ = py_runner(py_path.as_ref().unwrap(), "ruff", &args);
}
let _ = res?;
}
if python_fmt {
let mut cfg_args_black = cfg_args.clone();
let mut file_args_black = file_args.clone();
if bless {
eprintln!("formatting python files");
} else {
eprintln!("checking python file formatting");
cfg_args_black.push("--check".as_ref());
}
let mut cfg_path = root_path.to_owned();
cfg_path.extend(BLACK_CONFIG_PATH);
cfg_args_black.extend(["--config".as_ref(), cfg_path.as_os_str()]);
if file_args_black.is_empty() {
file_args_black.push(root_path.as_os_str());
}
let mut args = merge_args(&cfg_args_black, &file_args_black);
let res = py_runner(py_path.as_ref().unwrap(), "black", &args);
if res.is_err() && show_diff {
eprintln!("\npython formatting does not match! Printing diff:");
args.insert(0, "--diff".as_ref());
let _ = py_runner(py_path.as_ref().unwrap(), "black", &args);
}
let _ = res?;
}
if shell_lint {
eprintln!("linting shell files");
let mut file_args_shc = file_args.clone();
let files;
if file_args_shc.is_empty() {
files = find_with_extension(root_path, "sh")?;
file_args_shc.extend(files.iter().map(|p| p.as_os_str()));
}
shellcheck_runner(&merge_args(&cfg_args, &file_args_shc))?;
}
Ok(())
}
fn merge_args<'a>(cfg_args: &[&'a OsStr], file_args: &[&'a OsStr]) -> Vec<&'a OsStr> {
let mut args = cfg_args.to_owned();
args.push("--".as_ref());
args.extend(file_args);
args
}
fn py_runner(py_path: &Path, bin: &'static str, args: &[&OsStr]) -> Result<(), Error> {
let status = Command::new(py_path).arg("-m").arg(bin).args(args).status()?;
if status.success() { Ok(()) } else { Err(Error::FailedCheck(bin)) }
}
fn get_or_create_venv(venv_path: &Path, src_reqs_path: &Path) -> Result<PathBuf, Error> {
let mut should_create = true;
let dst_reqs_path = venv_path.join("requirements.txt");
let mut py_path = venv_path.to_owned();
py_path.extend(REL_PY_PATH);
if let Ok(req) = fs::read_to_string(&dst_reqs_path) {
if req == fs::read_to_string(src_reqs_path)? {
should_create = false;
} else {
eprintln!("requirements.txt file mismatch, recreating environment");
}
}
if should_create {
eprintln!("removing old virtual environment");
if venv_path.is_dir() {
fs::remove_dir_all(venv_path).unwrap_or_else(|_| {
panic!("failed to remove directory at {}", venv_path.display())
});
}
create_venv_at_path(venv_path)?;
install_requirements(&py_path, src_reqs_path, &dst_reqs_path)?;
}
verify_py_version(&py_path)?;
Ok(py_path)
}
fn create_venv_at_path(path: &Path) -> Result<(), Error> {
const TRY_PY: &[&str] = &[
"python3.11",
"python3.10",
"python3.9",
"python3.8",
"python3.7",
"python3",
"python",
"python3.12",
"python3.13",
];
let mut sys_py = None;
let mut found = Vec::new();
for py in TRY_PY {
match verify_py_version(Path::new(py)) {
Ok(_) => {
sys_py = Some(*py);
break;
}
Err(Error::Io(e)) if e.kind() == io::ErrorKind::NotFound => (),
Err(Error::Version { installed, .. }) => found.push(installed),
Err(e) => eprintln!("note: error running '{py}': {e}"),
}
}
let Some(sys_py) = sys_py else {
let ret = if found.is_empty() {
Error::MissingReq("python3", "python file checks", None)
} else {
found.sort();
found.dedup();
Error::Version {
program: "python3",
required: MIN_PY_REV_STR,
installed: found.join(", "),
}
};
return Err(ret);
};
eprintln!("creating virtual environment at '{}' using '{sys_py}'", path.display());
let out = Command::new(sys_py).args(["-m", "virtualenv"]).arg(path).output().unwrap();
if out.status.success() {
return Ok(());
}
let err = if String::from_utf8_lossy(&out.stderr).contains("No module named virtualenv") {
Error::Generic(format!(
"virtualenv not found: you may need to install it \
(`python3 -m pip install venv`)"
))
} else {
Error::Generic(format!("failed to create venv at '{}' using {sys_py}", path.display()))
};
Err(err)
}
fn verify_py_version(py_path: &Path) -> Result<(), Error> {
let out = Command::new(py_path).arg("--version").output()?;
let outstr = String::from_utf8_lossy(&out.stdout);
let vers = outstr.trim().split_ascii_whitespace().nth(1).unwrap().trim();
let mut vers_comps = vers.split('.');
let major: u32 = vers_comps.next().unwrap().parse().unwrap();
let minor: u32 = vers_comps.next().unwrap().parse().unwrap();
if (major, minor) < MIN_PY_REV {
Err(Error::Version {
program: "python",
required: MIN_PY_REV_STR,
installed: vers.to_owned(),
})
} else {
Ok(())
}
}
fn install_requirements(
py_path: &Path,
src_reqs_path: &Path,
dst_reqs_path: &Path,
) -> Result<(), Error> {
let stat = Command::new(py_path)
.args(["-m", "pip", "install", "--upgrade", "pip"])
.status()
.expect("failed to launch pip");
if !stat.success() {
return Err(Error::Generic(format!("pip install failed with status {stat}")));
}
let stat = Command::new(py_path)
.args(["-m", "pip", "install", "--require-hashes", "-r"])
.arg(src_reqs_path)
.status()?;
if !stat.success() {
return Err(Error::Generic(format!(
"failed to install requirements at {}",
src_reqs_path.display()
)));
}
fs::copy(src_reqs_path, dst_reqs_path)?;
assert_eq!(
fs::read_to_string(src_reqs_path).unwrap(),
fs::read_to_string(dst_reqs_path).unwrap()
);
Ok(())
}
fn shellcheck_runner(args: &[&OsStr]) -> Result<(), Error> {
match Command::new("shellcheck").arg("--version").status() {
Ok(_) => (),
Err(e) if e.kind() == io::ErrorKind::NotFound => {
return Err(Error::MissingReq(
"shellcheck",
"shell file checks",
Some(
"see <https://github.com/koalaman/shellcheck#installing> \
for installation instructions"
.to_owned(),
),
));
}
Err(e) => return Err(e.into()),
}
let status = Command::new("shellcheck").args(args).status()?;
if status.success() { Ok(()) } else { Err(Error::FailedCheck("black")) }
}
fn find_with_extension(root_path: &Path, extension: &str) -> Result<Vec<PathBuf>, Error> {
let stat_output =
Command::new("git").arg("-C").arg(root_path).args(["status", "--short"]).output()?.stdout;
if String::from_utf8_lossy(&stat_output).lines().filter(|ln| ln.starts_with('?')).count() > 0 {
eprintln!("found untracked files, ignoring");
}
let mut output = Vec::new();
let binding = Command::new("git").arg("-C").arg(root_path).args(["ls-files"]).output()?;
let tracked = String::from_utf8_lossy(&binding.stdout);
for line in tracked.lines() {
let line = line.trim();
let path = Path::new(line);
if path.extension() == Some(OsStr::new(extension)) {
output.push(path.to_owned());
}
}
Ok(output)
}
#[derive(Debug)]
enum Error {
Io(io::Error),
MissingReq(&'static str, &'static str, Option<String>),
FailedCheck(&'static str),
Generic(String),
Version {
program: &'static str,
required: &'static str,
installed: String,
},
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingReq(a, b, ex) => {
write!(
f,
"{a} is required to run {b} but it could not be located. Is it installed?"
)?;
if let Some(s) = ex {
write!(f, "\n{s}")?;
};
Ok(())
}
Self::Version { program, required, installed } => write!(
f,
"insufficient version of '{program}' to run external tools: \
{required} required but found {installed}",
),
Self::Generic(s) => f.write_str(s),
Self::Io(e) => write!(f, "IO error: {e}"),
Self::FailedCheck(s) => write!(f, "checks with external tool '{s}' failed"),
}
}
}
impl From<io::Error> for Error {
fn from(value: io::Error) -> Self {
Self::Io(value)
}
}