use std::ffi::OsString;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
use tokio::task::JoinSet;
use tokio::time::timeout;
pub(crate) mod cache;
pub(crate) mod detect;
use crate::environment::PromptEnvironment;
const COMMAND_TIMEOUT: Duration = Duration::from_millis(250);
const OUTPUT_LIMIT: u64 = 4_097;
const MAX_FIELD_BYTES: usize = 4_096;
macro_rules! define_runtimes {
($($variant:ident = $id:literal => $name:literal),+ $(,)?) => {
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[repr(u8)]
pub(crate) enum Runtime {
$($variant = $id),+
}
impl Runtime {
pub(crate) const ALL: [Self; [$(stringify!($variant)),+].len()] = [
$(Self::$variant),+
];
pub(crate) const fn id(self) -> u8 {
self as u8
}
pub(crate) const fn name(self) -> &'static str {
match self {
$(Self::$variant => $name),+
}
}
pub(crate) const fn from_id(value: u8) -> Option<Self> {
match value {
$($id => Some(Self::$variant),)+
_ => None,
}
}
pub(crate) fn from_name(value: &str) -> Option<Self> {
match value {
$($name => Some(Self::$variant),)+
_ => None,
}
}
}
};
}
define_runtimes! {
Python = 0 => "python",
Perl = 1 => "perl",
Java = 2 => "java",
Kotlin = 3 => "kotlin",
Scala = 4 => "scala",
Rust = 5 => "rust",
Go = 6 => "go",
Bun = 7 => "bun",
Deno = 8 => "deno",
Node = 9 => "node",
Ruby = 10 => "ruby",
Php = 11 => "php",
Dotnet = 12 => "dotnet",
C = 13 => "c",
Cpp = 14 => "cpp",
Swift = 15 => "swift",
Lua = 16 => "lua",
}
#[derive(Clone, Debug)]
pub(crate) struct RuntimeValue {
pub(crate) runtime: Runtime,
pub(crate) version: String,
pub(crate) label: Option<String>,
pub(crate) environment: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct CachedRuntimeValue {
pub(crate) runtime: Runtime,
pub(crate) version: String,
pub(crate) label: Option<String>,
}
#[derive(Debug)]
pub(crate) enum RuntimeOutcome {
Value(CachedRuntimeValue),
MissingExecutable,
TransientFailure,
}
#[derive(Debug)]
pub(crate) struct RuntimeExecution {
pub(crate) runtime: Runtime,
pub(crate) outcome: RuntimeOutcome,
}
pub(crate) fn materialize(
cached: CachedRuntimeValue,
environment: &PromptEnvironment,
) -> RuntimeValue {
RuntimeValue {
runtime: cached.runtime,
version: cached.version,
label: cached.label,
environment: runtime_environment(cached.runtime, environment),
}
}
fn runtime_environment(runtime: Runtime, environment: &PromptEnvironment) -> Option<String> {
match runtime {
Runtime::Python => python_environment(environment),
Runtime::Perl => perl_environment(environment),
Runtime::Rust => rust_environment(environment),
Runtime::Ruby => ruby_environment(environment),
_ => None,
}
}
pub(crate) async fn execute_plans(
plans: Vec<cache::RuntimePlan>,
cwd: PathBuf,
environment: Arc<PromptEnvironment>,
) -> Vec<RuntimeExecution> {
let mut tasks = JoinSet::new();
for plan in plans {
let cwd = cwd.clone();
let environment = Arc::clone(&environment);
tasks.spawn(async move { execute_plan(plan, &cwd, &environment).await });
}
let mut executions = Vec::new();
while let Some(result) = tasks.join_next().await {
if let Ok(execution) = result {
executions.push(execution);
}
}
executions.sort_unstable_by_key(|execution| execution.runtime);
executions
}
pub(crate) fn encode(values: &[CachedRuntimeValue]) -> io::Result<Vec<u8>> {
let count = u8::try_from(values.len()).map_err(|_| invalid_data("too many runtime values"))?;
let mut output = Vec::with_capacity(128);
output.push(count);
for value in values {
output.push(value.runtime.id());
write_text(&mut output, &value.version)?;
write_optional_text(&mut output, value.label.as_deref())?;
}
Ok(output)
}
pub(crate) fn decode(bytes: &[u8]) -> io::Result<Vec<CachedRuntimeValue>> {
let mut decoder = Decoder::new(bytes);
let count = usize::from(decoder.u8()?);
if count > Runtime::ALL.len() {
return Err(invalid_data("runtime cache contains too many values"));
}
let mut values = Vec::with_capacity(count);
for _ in 0..count {
let runtime = Runtime::from_id(decoder.u8()?)
.ok_or_else(|| invalid_data("runtime cache contains an unknown runtime"))?;
values.push(CachedRuntimeValue {
runtime,
version: decoder.text()?,
label: decoder.optional_text()?,
});
}
if !decoder.is_empty() {
return Err(invalid_data("runtime cache contains trailing data"));
}
values.sort_unstable_by_key(|value| value.runtime);
if values
.windows(2)
.any(|pair| pair[0].runtime == pair[1].runtime)
{
return Err(invalid_data("runtime cache contains duplicate runtimes"));
}
Ok(values)
}
#[derive(Clone, Debug)]
pub(super) struct RuntimeSpec {
pub(super) program: OsString,
pub(super) arguments: &'static [&'static str],
pub(super) merge_stderr: bool,
pub(super) label: Option<&'static str>,
pub(super) parse_version: fn(&str) -> Option<String>,
}
pub(super) fn spec(runtime: Runtime) -> RuntimeSpec {
match runtime {
Runtime::Python => RuntimeSpec {
program: OsString::from("python"),
arguments: &["--version"],
merge_stderr: true,
label: None,
parse_version: parse_python,
},
Runtime::Perl => RuntimeSpec {
program: OsString::from("perl"),
arguments: &["-e", "printf \"%vd\\n\", $^V"],
merge_stderr: false,
label: None,
parse_version: parse_line,
},
Runtime::Java => static_spec("java", &["-version"], true, parse_java),
Runtime::Kotlin => static_spec("kotlinc", &["-version"], true, parse_numeric),
Runtime::Scala => static_spec("scala", &["-version"], true, parse_numeric),
Runtime::Rust => static_spec("rustc", &["--version"], false, parse_second_word),
Runtime::Go => static_spec("go", &["version"], false, parse_go),
Runtime::Bun => static_spec("bun", &["--version"], false, parse_line),
Runtime::Deno => static_spec("deno", &["--version"], false, parse_second_word),
Runtime::Node => static_spec("node", &["--version"], false, parse_node),
Runtime::Ruby => static_spec("ruby", &["--version"], false, parse_second_word),
Runtime::Php => static_spec("php", &["--version"], false, parse_second_word),
Runtime::Dotnet => static_spec("dotnet", &["--version"], false, parse_line),
Runtime::C => compiler_spec(false, true),
Runtime::Cpp => compiler_spec(true, true),
Runtime::Swift => static_spec("swift", &["--version"], false, parse_numeric),
Runtime::Lua => static_spec("lua", &["-v"], true, parse_second_word),
}
}
fn static_spec(
program: &'static str,
arguments: &'static [&'static str],
merge_stderr: bool,
parse_version: fn(&str) -> Option<String>,
) -> RuntimeSpec {
RuntimeSpec {
program: OsString::from(program),
arguments,
merge_stderr,
label: None,
parse_version,
}
}
pub(super) fn compiler_spec(cpp: bool, clang: bool) -> RuntimeSpec {
if clang {
let program = if cpp { "clang++" } else { "clang" };
return RuntimeSpec {
label: Some("clang"),
..static_spec(program, &["--version"], false, parse_numeric)
};
}
let program = if cpp { "g++" } else { "gcc" };
RuntimeSpec {
label: Some("gcc"),
..static_spec(
program,
&["-dumpfullversion", "-dumpversion"],
false,
parse_numeric,
)
}
}
async fn execute_plan(
plan: cache::RuntimePlan,
cwd: &Path,
environment: &PromptEnvironment,
) -> RuntimeExecution {
let runtime = plan.runtime;
let result = capture(&plan, cwd, environment).await;
let outcome = match result {
Ok(output) => {
(plan.spec.parse_version)(&output).map_or(RuntimeOutcome::TransientFailure, |version| {
RuntimeOutcome::Value(CachedRuntimeValue {
runtime,
version,
label: plan.spec.label.map(str::to_owned),
})
})
}
Err(CaptureError::MissingExecutable) => RuntimeOutcome::MissingExecutable,
Err(CaptureError::TransientFailure) => RuntimeOutcome::TransientFailure,
};
RuntimeExecution { runtime, outcome }
}
enum CaptureError {
MissingExecutable,
TransientFailure,
}
async fn capture(
plan: &cache::RuntimePlan,
cwd: &Path,
environment: &PromptEnvironment,
) -> Result<String, CaptureError> {
let mut command = Command::new(&plan.program);
if plan.is_cacheable() {
cache::apply_cacheable_environment(&mut command, plan, environment);
} else {
environment.apply_to_command(&mut command);
}
command
.args(plan.spec.arguments)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(if plan.spec.merge_stderr {
Stdio::piped()
} else {
Stdio::null()
})
.kill_on_drop(true);
let mut child = command.spawn().map_err(|error| {
if error.kind() == io::ErrorKind::NotFound {
CaptureError::MissingExecutable
} else {
CaptureError::TransientFailure
}
})?;
let stdout = child.stdout.take().ok_or(CaptureError::TransientFailure)?;
let mut stderr = child.stderr.take();
let merge_stderr = plan.spec.merge_stderr;
let collected = timeout(COMMAND_TIMEOUT, async move {
let stdout_task = async {
let mut bytes = Vec::new();
stdout
.take(OUTPUT_LIMIT)
.read_to_end(&mut bytes)
.await
.map_err(|_| CaptureError::TransientFailure)?;
Ok(bytes)
};
let stderr_task = async {
let mut bytes = Vec::new();
if let Some(ref mut stderr) = stderr {
stderr
.take(OUTPUT_LIMIT)
.read_to_end(&mut bytes)
.await
.map_err(|_| CaptureError::TransientFailure)?;
}
Ok(bytes)
};
let (stdout, stderr, status) = tokio::join!(stdout_task, stderr_task, child.wait());
let status = status.map_err(|_| CaptureError::TransientFailure)?;
if !status.success() {
return Err(CaptureError::TransientFailure);
}
Ok((stdout?, stderr?))
})
.await
.map_err(|_| CaptureError::TransientFailure)??;
let mut bytes = collected.0;
if merge_stderr {
bytes.extend_from_slice(&collected.1);
}
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) >= OUTPUT_LIMIT {
return Err(CaptureError::TransientFailure);
}
String::from_utf8(bytes).map_err(|_| CaptureError::TransientFailure)
}
fn first_line(output: &str) -> Option<&str> {
output
.lines()
.find(|line| !line.trim().is_empty())
.map(str::trim)
}
fn parse_python(output: &str) -> Option<String> {
first_line(output)?
.strip_prefix("Python ")
.map(str::to_owned)
}
fn parse_line(output: &str) -> Option<String> {
let value = first_line(output)?;
(!value.is_empty()).then(|| value.to_owned())
}
fn parse_java(output: &str) -> Option<String> {
first_line(output)?.split('"').nth(1).map(str::to_owned)
}
fn parse_numeric(output: &str) -> Option<String> {
numeric_word(first_line(output)?)
}
fn parse_second_word(output: &str) -> Option<String> {
first_line(output)?
.split_whitespace()
.nth(1)
.map(str::to_owned)
}
fn parse_go(output: &str) -> Option<String> {
first_line(output)?
.split_whitespace()
.nth(2)?
.strip_prefix("go")
.map(str::to_owned)
}
fn parse_node(output: &str) -> Option<String> {
let line = first_line(output)?;
let value = line.strip_prefix('v').unwrap_or(line);
(!value.is_empty()).then(|| value.to_owned())
}
fn numeric_word(line: &str) -> Option<String> {
line.split_whitespace().find_map(|word| {
let word = word.trim_matches(|character: char| {
!character.is_ascii_alphanumeric() && character != '.' && character != '-'
});
let starts_numeric = word.as_bytes().first().is_some_and(u8::is_ascii_digit);
(starts_numeric && word.contains('.')).then(|| word.to_owned())
})
}
fn python_environment(environment: &PromptEnvironment) -> Option<String> {
environment
.virtual_env
.as_deref()
.and_then(|path| PathBuf::from(path).file_name().map(OsString::from))
.or_else(|| environment.conda_default_env.clone())
.or_else(|| {
environment
.conda_prefix
.as_deref()
.and_then(|path| PathBuf::from(path).file_name().map(OsString::from))
})
.and_then(|value| value.into_string().ok())
}
fn perl_environment(environment: &PromptEnvironment) -> Option<String> {
environment
.perlbrew_perl
.clone()
.or_else(|| environment.plenv_version.clone())
.and_then(|value| value.into_string().ok())
}
fn rust_environment(environment: &PromptEnvironment) -> Option<String> {
environment
.rustup_toolchain
.clone()
.and_then(|value| value.into_string().ok())
}
fn ruby_environment(environment: &PromptEnvironment) -> Option<String> {
environment
.rbenv_version
.clone()
.or_else(|| environment.ruby_version.clone())
.and_then(|value| value.into_string().ok())
}
fn write_text(output: &mut Vec<u8>, value: &str) -> io::Result<()> {
if value.len() > MAX_FIELD_BYTES {
return Err(invalid_data("runtime cache field exceeds size limit"));
}
let length =
u16::try_from(value.len()).map_err(|_| invalid_data("runtime cache field is too large"))?;
output.extend_from_slice(&length.to_be_bytes());
output.extend_from_slice(value.as_bytes());
Ok(())
}
fn write_optional_text(output: &mut Vec<u8>, value: Option<&str>) -> io::Result<()> {
if let Some(value) = value {
output.push(1);
return write_text(output, value);
}
output.push(0);
Ok(())
}
struct Decoder<'a> {
bytes: &'a [u8],
position: usize,
}
impl<'a> Decoder<'a> {
const fn new(bytes: &'a [u8]) -> Self {
Self { bytes, position: 0 }
}
fn take(&mut self, length: usize) -> io::Result<&'a [u8]> {
let end = self
.position
.checked_add(length)
.ok_or_else(|| invalid_data("runtime cache length overflow"))?;
let value = self
.bytes
.get(self.position..end)
.ok_or_else(|| invalid_data("runtime cache is truncated"))?;
self.position = end;
Ok(value)
}
fn u8(&mut self) -> io::Result<u8> {
self.take(1)?
.first()
.copied()
.ok_or_else(|| invalid_data("runtime cache is truncated"))
}
fn u16(&mut self) -> io::Result<u16> {
let bytes: [u8; 2] = self
.take(2)?
.try_into()
.map_err(|_| invalid_data("runtime cache is truncated"))?;
Ok(u16::from_be_bytes(bytes))
}
fn text(&mut self) -> io::Result<String> {
let length = usize::from(self.u16()?);
if length > MAX_FIELD_BYTES {
return Err(invalid_data("runtime cache field exceeds size limit"));
}
String::from_utf8(self.take(length)?.to_vec())
.map_err(|_| invalid_data("runtime cache field is not UTF-8"))
}
fn optional_text(&mut self) -> io::Result<Option<String>> {
match self.u8()? {
0 => Ok(None),
1 => self.text().map(Some),
_ => Err(invalid_data("runtime cache optional field is invalid")),
}
}
fn is_empty(&self) -> bool {
self.position == self.bytes.len()
}
}
fn invalid_data(message: &'static str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use super::{CachedRuntimeValue, Runtime, decode, encode, spec};
fn parse_version(runtime: Runtime, output: &str) -> Option<String> {
(spec(runtime).parse_version)(output)
}
#[test]
fn runtime_identities_are_unique_and_round_trip() {
let ids = Runtime::ALL
.into_iter()
.map(Runtime::id)
.collect::<HashSet<_>>();
let names = Runtime::ALL
.into_iter()
.map(Runtime::name)
.collect::<HashSet<_>>();
assert_eq!(ids.len(), Runtime::ALL.len());
assert_eq!(names.len(), Runtime::ALL.len());
for runtime in Runtime::ALL {
assert_eq!(Runtime::from_id(runtime.id()), Some(runtime));
assert_eq!(Runtime::from_name(runtime.name()), Some(runtime));
}
}
#[test]
fn parses_supported_runtime_versions() {
let cases = [
(Runtime::Python, "Python 3.14.6\n", "3.14.6"),
(Runtime::Perl, "5.40.2\n", "5.40.2"),
(
Runtime::Java,
"openjdk version \"25.0.1\" 2025-10-21\n",
"25.0.1",
),
(
Runtime::Kotlin,
"info: kotlinc-jvm 2.2.20 (JRE 25)\n",
"2.2.20",
),
(Runtime::Scala, "Scala code runner version 3.7.3\n", "3.7.3"),
(
Runtime::Rust,
"rustc 1.97.1 (8bab26f4f 2026-07-14)\n",
"1.97.1",
),
(Runtime::Go, "go version go1.25.1 darwin/arm64\n", "1.25.1"),
(Runtime::Bun, "1.2.20\n", "1.2.20"),
(Runtime::Deno, "deno 2.4.5\nv8 13.7\n", "2.4.5"),
(Runtime::Node, "v24.5.0\n", "24.5.0"),
(Runtime::Ruby, "ruby 3.4.5 (2025-07-16 revision)\n", "3.4.5"),
(Runtime::Php, "PHP 8.4.11 (cli)\n", "8.4.11"),
(Runtime::Dotnet, "9.0.304\n", "9.0.304"),
(Runtime::C, "Apple clang version 17.0.0\n", "17.0.0"),
(Runtime::Cpp, "g++ (GCC) 15.2.1 20250730\n", "15.2.1"),
(
Runtime::Swift,
"Apple Swift version 6.2 (swiftlang)\n",
"6.2",
),
(Runtime::Lua, "Lua 5.4.8 Copyright (C)\n", "5.4.8"),
];
for (runtime, output, expected) in cases {
assert_eq!(
parse_version(runtime, output).as_deref(),
Some(expected),
"{}",
runtime.name()
);
}
}
#[test]
fn rejects_unparseable_runtime_versions() {
assert_eq!(parse_version(Runtime::Python, "python unknown"), None);
assert_eq!(parse_version(Runtime::Go, "go version unknown"), None);
assert_eq!(
parse_version(Runtime::Java, "openjdk version unknown"),
None
);
assert_eq!(parse_version(Runtime::Rust, "\n\n"), None);
}
#[test]
fn runtime_cache_round_trips_and_sorts_values() {
let values = [
CachedRuntimeValue {
runtime: Runtime::Rust,
version: "1.97.1".to_owned(),
label: None,
},
CachedRuntimeValue {
runtime: Runtime::Python,
version: "3.14.6".to_owned(),
label: Some("cpython".to_owned()),
},
];
let decoded = decode(&encode(&values).unwrap()).unwrap();
assert_eq!(decoded.len(), 2);
assert_eq!(decoded[0].runtime, Runtime::Python);
assert_eq!(decoded[0].version, "3.14.6");
assert_eq!(decoded[0].label.as_deref(), Some("cpython"));
assert_eq!(decoded[1].runtime, Runtime::Rust);
}
#[test]
fn runtime_cache_rejects_malformed_records() {
let duplicate = [
2,
Runtime::Rust.id(),
0,
1,
b'1',
0,
0,
Runtime::Rust.id(),
0,
1,
b'2',
0,
0,
];
assert!(decode(&duplicate).is_err());
assert!(decode(&[1, u8::MAX]).is_err());
assert!(decode(&[1, Runtime::Rust.id(), 0, 0, 2]).is_err());
let valid = encode(&[CachedRuntimeValue {
runtime: Runtime::Node,
version: "24.5.0".to_owned(),
label: None,
}])
.unwrap();
for length in 0..valid.len() {
assert!(
decode(&valid[..length]).is_err(),
"accepted length {length}"
);
}
let mut trailing = valid;
trailing.push(0);
assert!(decode(&trailing).is_err());
}
}