use std::ffi::OsString;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use ktrs_lint::editorconfig::KtlintVersion;
use crate::ktlint::console::Console;
use crate::ktlint::hand_off_args::hand_off_args;
use crate::ktlint::sha256::sha256;
pub const KTLINT_JAR_ENV: &str = "KTRS_KTLINT_JAR";
struct Release {
version: &'static str,
url: &'static str,
sha256: &'static str,
}
fn release_of(ktlint_version: KtlintVersion) -> Release {
match ktlint_version {
KtlintVersion::V1_8 => Release {
version: "1.8.0",
url: "https://github.com/pinterest/ktlint/releases/download/1.8.0/ktlint",
sha256: "a3fd620207d5c40da6ca789b95e7f823c54e854b7fade7f613e91096a3706d75",
},
KtlintVersion::V2_0 => Release {
version: "2.0.0-ALPHA-4",
url: "https://github.com/ktlint/ktlint/releases/download/2.0.0-ALPHA-4/ktlint",
sha256: "fb28b3cd57116d1de78867ebd8ce398ede91e91b280b33dc367e0108336b79f1",
},
}
}
#[derive(Clone, Debug, Default)]
pub struct JvmEnv {
pub java_home: Option<PathBuf>,
pub path: Option<OsString>,
pub jar: Option<PathBuf>,
pub cache_dir: Option<PathBuf>,
}
impl JvmEnv {
pub fn from_env() -> JvmEnv {
let var = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty());
JvmEnv {
java_home: var("JAVA_HOME").map(PathBuf::from),
path: var("PATH"),
jar: var(KTLINT_JAR_ENV).map(PathBuf::from),
cache_dir: default_cache_dir(var),
}
}
}
fn default_cache_dir(var: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
let base = if cfg!(windows) {
var("LOCALAPPDATA").map(PathBuf::from)
} else if cfg!(target_os = "macos") {
var("HOME").map(|home| Path::new(&home).join("Library/Caches"))
} else {
var("XDG_CACHE_HOME").map(PathBuf::from).or_else(|| var("HOME").map(|home| Path::new(&home).join(".cache")))
};
base.map(|base| base.join("ktrs"))
}
pub fn run_ktlint_jar(env: &JvmEnv, ktlint_version: KtlintVersion, args: &[String], working_dir: &Path, console: &Console, cause: &str) -> i32 {
match launch(env, ktlint_version, args, working_dir, console) {
Ok(code) => code,
Err(message) => {
console.err(&format!(
"ktrs: '{cause}' is a ktlint plugin JAR ktrs can not run natively, so this run is handed to ktlint {} on the JVM, \
but {message}\n",
release_of(ktlint_version).version
));
1
}
}
}
fn launch(env: &JvmEnv, ktlint_version: KtlintVersion, args: &[String], working_dir: &Path, console: &Console) -> Result<i32, String> {
let java = find_java(env).ok_or("no `java` was found: install a JDK (17+) and set JAVA_HOME or put `java` on PATH.")?;
let jar = ktlint_jar(env, ktlint_version, console)?;
let hand_off = hand_off_args(args, working_dir)?;
let mut command = Command::new(&java);
command.args(jvm_options(java_major_version(&java))).arg("-jar").arg(&jar).args(&hand_off.args).current_dir(working_dir);
if console.is_process_streams() {
return if hand_off.has_temp_files() { status(command) } else { exec(command) };
}
let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("`{}` could not be started: {e}", java.display()))?;
let input = console.read_stdin();
let mut stdin = child.stdin.take().unwrap();
let writer = std::thread::spawn(move || stdin.write_all(&input));
let output = child.wait_with_output().map_err(|e| e.to_string())?;
let _ = writer.join();
console.out(&String::from_utf8_lossy(&output.stdout));
console.err(&String::from_utf8_lossy(&output.stderr));
Ok(output.status.code().unwrap_or(1))
}
#[cfg(unix)]
fn exec(mut command: Command) -> Result<i32, String> {
use std::os::unix::process::CommandExt;
let program = command.get_program().to_string_lossy().into_owned();
let error = command.exec();
Err(format!("`{program}` could not be started: {error}"))
}
#[cfg(not(unix))]
fn exec(command: Command) -> Result<i32, String> {
status(command)
}
fn status(mut command: Command) -> Result<i32, String> {
let status = command.status().map_err(|e| format!("`{}` could not be started: {e}", command.get_program().to_string_lossy()))?;
Ok(status.code().unwrap_or(1))
}
fn jvm_options(java_major_version: Option<u32>) -> Vec<&'static str> {
let mut options = Vec::new();
if java_major_version.is_some_and(|v| v >= 24) {
options.extend(["--sun-misc-unsafe-memory-access=allow", "--enable-native-access=ALL-UNNAMED"]);
}
options.push("-Xmx512m");
options
}
fn java_major_version(java: &Path) -> Option<u32> {
let output = Command::new(java).arg("-version").stdin(Stdio::null()).output().ok()?;
let text = String::from_utf8_lossy(&output.stderr);
let version = text.split("version \"").nth(1)?;
version.split(['.', '-', '"']).next()?.parse().ok()
}
fn find_java(env: &JvmEnv) -> Option<PathBuf> {
let exe = if cfg!(windows) { "java.exe" } else { "java" };
let from_home = env.java_home.as_ref().map(|home| home.join("bin").join(exe)).filter(|java| java.is_file());
from_home.or_else(|| std::env::split_paths(env.path.as_ref()?).map(|dir| dir.join(exe)).find(|java| java.is_file()))
}
fn ktlint_jar(env: &JvmEnv, ktlint_version: KtlintVersion, console: &Console) -> Result<PathBuf, String> {
if let Some(jar) = &env.jar {
return if jar.is_file() { Ok(jar.clone()) } else { Err(format!("{KTLINT_JAR_ENV} names '{}', which does not exist.", jar.display())) };
}
let release = release_of(ktlint_version);
let cache_dir = env.cache_dir.as_ref().ok_or(format!("there is no user cache directory to download it to: set {KTLINT_JAR_ENV}."))?;
let jar = cache_dir.join(format!("ktlint-{}.jar", release.version));
if !jar.is_file() {
download(&release, &jar, console)?;
}
Ok(jar)
}
fn download(release: &Release, jar: &Path, console: &Console) -> Result<(), String> {
let manual = format!("download {} yourself and set {KTLINT_JAR_ENV} to its path.", release.url);
std::fs::create_dir_all(jar.parent().unwrap()).map_err(|e| format!("'{}' can not be created ({e}): {manual}", jar.display()))?;
console.err(&format!("ktrs: downloading ktlint {} (once) to {}\n", release.version, jar.display()));
let part = jar.with_extension(format!("{}.part", std::process::id()));
let status = Command::new("curl").args(["-fsSL", "--retry", "2", "-o"]).arg(&part).arg(release.url).stdin(Stdio::null()).status();
let result = match status {
Ok(status) if status.success() => verify(&part, release.sha256).map_err(|e| format!("{e}: {manual}")),
Ok(status) => Err(format!("curl failed ({status}): {manual}")),
Err(e) => Err(format!("`curl` could not be run ({e}): {manual}")),
};
let result = result.and_then(|()| std::fs::rename(&part, jar).map_err(|e| format!("'{}' can not be written ({e}): {manual}", jar.display())));
let _ = std::fs::remove_file(&part);
result
}
fn verify(file: &Path, expected: &str) -> Result<(), String> {
let bytes = std::fs::read(file).map_err(|e| e.to_string())?;
let actual: String = sha256(&bytes).iter().map(|b| format!("{b:02x}")).collect();
if actual == expected { Ok(()) } else { Err(format!("the download's SHA-256 is {actual}, not the pinned {expected}")) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn launcher_options_follow_the_java_version() {
assert_eq!(jvm_options(Some(21)), ["-Xmx512m"]);
assert_eq!(jvm_options(None), ["-Xmx512m"]);
assert_eq!(jvm_options(Some(25)), ["--sun-misc-unsafe-memory-access=allow", "--enable-native-access=ALL-UNNAMED", "-Xmx512m"]);
}
#[test]
fn missing_java_is_an_error_naming_the_jar() {
let env = JvmEnv { path: Some(OsString::new()), ..JvmEnv::default() };
let (console, out, err) = Console::capture(b"");
let code = run_ktlint_jar(&env, KtlintVersion::V1_8, &[], Path::new("."), &console, "custom.jar");
assert_eq!(code, 1);
assert_eq!(out.text(), "");
assert!(err.text().starts_with("ktrs: 'custom.jar' is a ktlint plugin JAR"), "{}", err.text());
assert!(err.text().contains("ktlint 1.8.0 on the JVM, but no `java` was found"), "{}", err.text());
}
}