mod bash;
mod brush;
mod cmd_exe;
mod nodejs;
mod nushell;
mod perl;
mod powershell;
mod python;
mod r;
mod ruby;
use std::path::{Path, PathBuf};
use rattler_conda_types::Platform;
use rattler_shell::activation::prefix_path_entries;
use crate::runtime::RuntimeEnv;
#[derive(Debug, thiserror::Error)]
pub enum InterpreterError {
#[error("IO Error: {0}")]
ExecutionFailed(#[from] std::io::Error),
#[error("interpreter '{0}' was not found in the build environment")]
InterpreterNotFound(String),
#[error("interpreter '{interpreter}' was found but is not valid: {reason}")]
InvalidInterpreter { interpreter: String, reason: String },
#[error("unsupported interpreter '{0}'")]
UnsupportedInterpreter(String),
}
type InvocationFactory = fn() -> Box<dyn InterpreterInvocation>;
const INTERPRETERS: &[(&str, InvocationFactory)] = &[
("bash", || Box::new(bash::BashInvocation)),
("cmd", || Box::new(cmd_exe::CmdExeInvocation)),
("brush", || Box::new(brush::BrushInvocation)),
("nushell", || Box::new(nushell::NuShellInvocation)),
("nu", || Box::new(nushell::NuShellInvocation)),
("python", || Box::new(python::PythonInvocation)),
("perl", || Box::new(perl::PerlInvocation)),
("rscript", || Box::new(r::RInvocation)),
("ruby", || Box::new(ruby::RubyInvocation)),
("node", || Box::new(nodejs::NodeJsInvocation)),
("nodejs", || Box::new(nodejs::NodeJsInvocation)),
("powershell", || Box::new(powershell::PowerShellInvocation)),
];
pub fn closest_interpreter(name: &str) -> Option<&'static str> {
const SIMILARITY_THRESHOLD: f64 = 0.8;
let name = name.to_lowercase();
INTERPRETERS
.iter()
.map(|(candidate, _)| (strsim::jaro_winkler(&name, candidate), *candidate))
.filter(|(similarity, _)| *similarity >= SIMILARITY_THRESHOLD)
.max_by(|(a, _), (b, _)| a.total_cmp(b))
.map(|(_, candidate)| candidate)
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct InterpreterSearchScope {
search_build: bool,
search_host: bool,
system_fallback: bool,
}
impl InterpreterSearchScope {
pub(crate) fn build_only() -> Self {
Self {
search_build: true,
search_host: false,
system_fallback: false,
}
}
pub(crate) fn build_and_host_with_system_fallback() -> Self {
Self {
search_build: true,
search_host: true,
system_fallback: true,
}
}
pub(crate) fn allows_system_fallback(&self) -> bool {
self.system_fallback
}
}
pub(crate) fn find_interpreter(
name: &str,
build_prefix: Option<&Path>,
run_prefix: &Path,
runtime: &RuntimeEnv,
scope: InterpreterSearchScope,
) -> Option<PathBuf> {
let exe_name = format!("{}{}", name, runtime.exe_suffix());
let platform = runtime.platform();
let mut search_path = Vec::new();
if scope.search_build {
let build_env = build_prefix.unwrap_or(run_prefix);
search_path.extend(prefix_path_entries(build_env, &platform));
}
if scope.search_host {
search_path.extend(prefix_path_entries(run_prefix, &platform));
}
if scope.system_fallback {
search_path.extend(std::env::split_paths(runtime.path()));
}
if search_path.is_empty() {
return None;
}
which::which_in_global(exe_name, std::env::join_paths(search_path).ok())
.ok()?
.next()
}
pub(crate) trait InterpreterInvocation: Send + Sync {
fn executable_names(&self, build_platform: &Platform) -> &'static [&'static str];
fn search_scope(&self, _build_platform: &Platform) -> InterpreterSearchScope {
InterpreterSearchScope::build_only()
}
fn extension(&self) -> &'static str;
fn script_contents(&self, raw: &str) -> String {
raw.to_string()
}
fn join_commands(&self, commands: &[String]) -> String {
commands.join("\n")
}
fn is_usable_executable(&self, _executable: &Path) -> Result<(), InterpreterError> {
Ok(())
}
fn resolve_executable(
&self,
build_prefix: Option<&Path>,
run_prefix: &Path,
runtime: &RuntimeEnv,
) -> Result<PathBuf, InterpreterError> {
let platform = runtime.platform();
let scope = self.search_scope(&platform);
let mut unusable_candidate = None;
for executable_name in self.executable_names(&platform) {
match find_interpreter(executable_name, build_prefix, run_prefix, runtime, scope) {
Some(path) => match self.is_usable_executable(&path) {
Ok(()) => return Ok(path),
Err(err) => unusable_candidate = Some((path, err)),
},
None => continue,
}
}
if let Some((path, err)) = unusable_candidate {
return Err(InterpreterError::InvalidInterpreter {
interpreter: path.display().to_string(),
reason: err.to_string(),
});
}
Err(InterpreterError::InterpreterNotFound(
self.executable_names(&platform)
.first()
.copied()
.unwrap_or("<unknown>")
.to_string(),
))
}
fn args(&self, script_path: &Path) -> Vec<String>;
}
fn interpreter_invocation(interpreter: &str) -> Option<Box<dyn InterpreterInvocation>> {
INTERPRETERS
.iter()
.find(|(name, _)| *name == interpreter)
.map(|(_, invocation)| invocation())
}
pub(crate) struct SelectedInterpreter {
user_name: String,
invocation: Box<dyn InterpreterInvocation>,
}
impl SelectedInterpreter {
pub(crate) fn from_recipe_name(name: &str) -> Option<Self> {
interpreter_invocation(name).map(|invocation| Self {
user_name: name.to_string(),
invocation,
})
}
pub(crate) fn extension(&self) -> &'static str {
self.invocation.extension()
}
pub(crate) fn script_contents(&self, raw: &str) -> String {
self.invocation.script_contents(raw)
}
pub(crate) fn join_commands(&self, commands: &[String]) -> String {
self.invocation.join_commands(commands)
}
pub(crate) fn args(&self, script_path: &Path) -> Vec<String> {
self.invocation.args(script_path)
}
pub(crate) fn resolve_executable(
&self,
build_prefix: Option<&Path>,
run_prefix: &Path,
runtime: &RuntimeEnv,
) -> Result<PathBuf, InterpreterError> {
self.invocation
.resolve_executable(build_prefix, run_prefix, runtime)
.map_err(|err| match err {
InterpreterError::InterpreterNotFound(_) => {
InterpreterError::InterpreterNotFound(self.user_name.clone())
}
InterpreterError::InvalidInterpreter { reason, .. } => {
InterpreterError::InvalidInterpreter {
interpreter: self.user_name.clone(),
reason,
}
}
other => other,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::execution::{ExecutionArgs, ResolvedScriptContents};
use fs_err as fs;
use indexmap::IndexMap;
use rattler_conda_types::Platform;
use rattler_shell::activation::prefix_path_entries;
use std::path::{Path, PathBuf};
fn execution_args(
work_dir: PathBuf,
run_prefix: PathBuf,
script: ResolvedScriptContents,
interpreter: Option<&str>,
) -> ExecutionArgs {
ExecutionArgs {
script,
interpreter: interpreter.map(str::to_string),
env_vars: IndexMap::new(),
secrets: IndexMap::new(),
runtime: RuntimeEnv::current(),
build_prefix: None,
run_prefix,
work_dir,
sandbox_config: None,
env_isolation: crate::execution::EnvironmentIsolation::None,
}
}
fn native_build_script_path(work_dir: &Path) -> PathBuf {
work_dir.join(if cfg!(windows) {
"conda_build.bat"
} else {
"conda_build.sh"
})
}
fn create_fake_executable(prefix: &Path, name: &str) -> PathBuf {
let exe_name = format!("{}{}", name, std::env::consts::EXE_SUFFIX);
let bin_dir = prefix_path_entries(prefix, &Platform::current())
.into_iter()
.next()
.expect("prefix has executable path entries");
fs::create_dir_all(&bin_dir).unwrap();
let exe = bin_dir.join(exe_name);
fs::write(&exe, "").unwrap();
#[cfg(unix)]
{
use std::{fs::Permissions, os::unix::fs::PermissionsExt};
fs::set_permissions(&exe, Permissions::from_mode(0o755)).unwrap();
}
exe
}
#[tokio::test]
async fn inline_without_interpreter_is_native_body() {
let tmp = tempfile::tempdir().unwrap();
let prefix = tmp.path().join("prefix");
fs::create_dir_all(&prefix).unwrap();
let args = execution_args(
tmp.path().to_path_buf(),
prefix,
ResolvedScriptContents::Inline("echo native".to_string()),
None,
);
crate::execution::generate_build_script(&args)
.await
.unwrap();
let build_script = fs::read_to_string(native_build_script_path(tmp.path())).unwrap();
assert!(build_script.contains("echo native"));
assert!(!tmp.path().join("conda_build_script.py").exists());
}
#[tokio::test]
async fn explicit_interpreter_writes_script_file_and_invocation() {
let tmp = tempfile::tempdir().unwrap();
let prefix = tmp.path().join("prefix");
fs::create_dir_all(&prefix).unwrap();
let python = create_fake_executable(&prefix, "python");
let args = execution_args(
tmp.path().to_path_buf(),
prefix,
ResolvedScriptContents::Inline("print('script file')".to_string()),
Some("python"),
);
crate::execution::generate_build_script(&args)
.await
.unwrap();
let script_file = tmp.path().join("conda_build_script.py");
assert_eq!(
fs::read_to_string(&script_file).unwrap(),
"print('script file')"
);
let build_script = fs::read_to_string(native_build_script_path(tmp.path())).unwrap();
assert!(build_script.contains(&python.to_string_lossy().to_string()));
assert!(build_script.contains(&script_file.to_string_lossy().to_string()));
}
#[tokio::test]
async fn inferred_file_interpreter_invokes_original_path() {
let tmp = tempfile::tempdir().unwrap();
let prefix = tmp.path().join("prefix");
fs::create_dir_all(&prefix).unwrap();
create_fake_executable(&prefix, "python");
let source_script = tmp.path().join("build.py");
let args = execution_args(
tmp.path().to_path_buf(),
prefix,
ResolvedScriptContents::Path(source_script.clone(), "print('from file')".to_string()),
None,
);
crate::execution::generate_build_script(&args)
.await
.unwrap();
assert!(!tmp.path().join("conda_build_script.py").exists());
let build_script = fs::read_to_string(native_build_script_path(tmp.path())).unwrap();
assert!(build_script.contains(&source_script.to_string_lossy().to_string()));
assert!(!build_script.contains("print('from file')"));
}
#[tokio::test]
async fn interpreter_matching_wrapper_shell_is_native_body() {
let tmp = tempfile::tempdir().unwrap();
let prefix = tmp.path().join("prefix");
fs::create_dir_all(&prefix).unwrap();
let native = if cfg!(windows) { "cmd" } else { "bash" };
let marker = "echo wrapper-shell-body";
let args = execution_args(
tmp.path().to_path_buf(),
prefix,
ResolvedScriptContents::Inline(marker.to_string()),
Some(native),
);
crate::execution::generate_build_script(&args)
.await
.expect("native wrapper shell must not be resolved from the build environment");
let build_script = fs::read_to_string(native_build_script_path(tmp.path())).unwrap();
assert!(
build_script.contains(marker),
"wrapper should inline the script body, got:\n{build_script}"
);
assert!(!tmp.path().join("conda_build_script.bat").exists());
assert!(!tmp.path().join("conda_build_script.sh").exists());
}
#[tokio::test]
async fn unknown_file_extension_without_interpreter_is_native_body() {
let tmp = tempfile::tempdir().unwrap();
let prefix = tmp.path().join("prefix");
fs::create_dir_all(&prefix).unwrap();
let args = execution_args(
tmp.path().to_path_buf(),
prefix,
ResolvedScriptContents::Path(
tmp.path().join("build.custom"),
"echo custom".to_string(),
),
None,
);
crate::execution::generate_build_script(&args)
.await
.unwrap();
let build_script = fs::read_to_string(native_build_script_path(tmp.path())).unwrap();
assert!(build_script.contains("echo custom"));
}
#[tokio::test]
async fn build_prefix_only_interpreter_missing_errors() {
let tmp = tempfile::tempdir().unwrap();
let prefix = tmp.path().join("prefix");
fs::create_dir_all(&prefix).unwrap();
let args = execution_args(
tmp.path().to_path_buf(),
prefix,
ResolvedScriptContents::Inline("echo missing".to_string()),
Some("brush"),
);
let err = crate::execution::generate_build_script(&args)
.await
.unwrap_err();
assert!(
matches!(err, InterpreterError::InterpreterNotFound(ref name) if name == "brush"),
"expected missing brush error, got {err:?}"
);
}
struct RejectFirstStub;
impl InterpreterInvocation for RejectFirstStub {
fn executable_names(&self, _build_platform: &Platform) -> &'static [&'static str] {
&["stub_first", "stub_second"]
}
fn search_scope(&self, _build_platform: &Platform) -> InterpreterSearchScope {
InterpreterSearchScope::build_only()
}
fn extension(&self) -> &'static str {
"stub"
}
fn is_usable_executable(&self, executable: &Path) -> Result<(), InterpreterError> {
let stem = executable
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default();
if stem == "stub_first" {
Err(InterpreterError::InvalidInterpreter {
interpreter: executable.display().to_string(),
reason: "rejected by test stub".to_string(),
})
} else {
Ok(())
}
}
fn args(&self, script_path: &Path) -> Vec<String> {
vec![script_path.to_string_lossy().into_owned()]
}
}
#[test]
fn rejected_first_candidate_falls_through_to_second() {
let tmp = tempfile::tempdir().unwrap();
let prefix = tmp.path().join("prefix");
fs::create_dir_all(&prefix).unwrap();
create_fake_executable(&prefix, "stub_first");
let second = create_fake_executable(&prefix, "stub_second");
let stub = RejectFirstStub;
let resolved = stub
.resolve_executable(
Some(prefix.as_path()),
prefix.as_path(),
&RuntimeEnv::current(),
)
.expect("second candidate should resolve");
assert_eq!(resolved, second);
}
#[test]
fn only_candidate_rejected_yields_invalid_interpreter() {
let tmp = tempfile::tempdir().unwrap();
let prefix = tmp.path().join("prefix");
fs::create_dir_all(&prefix).unwrap();
let first = create_fake_executable(&prefix, "stub_first");
let stub = RejectFirstStub;
let err = stub
.resolve_executable(
Some(prefix.as_path()),
prefix.as_path(),
&RuntimeEnv::current(),
)
.expect_err("only candidate is rejected");
match err {
InterpreterError::InvalidInterpreter { interpreter, .. } => {
assert_eq!(interpreter, first.display().to_string());
}
other => panic!("expected InvalidInterpreter, got {other:?}"),
}
}
#[test]
fn selected_interpreter_remaps_invalid_to_user_name() {
let tmp = tempfile::tempdir().unwrap();
let prefix = tmp.path().join("prefix");
fs::create_dir_all(&prefix).unwrap();
create_fake_executable(&prefix, "stub_first");
let selected = SelectedInterpreter {
user_name: "my-recipe-interp".to_string(),
invocation: Box::new(RejectFirstStub),
};
let err = selected
.resolve_executable(
Some(prefix.as_path()),
prefix.as_path(),
&RuntimeEnv::current(),
)
.expect_err("only candidate is rejected");
match err {
InterpreterError::InvalidInterpreter {
interpreter,
reason,
} => {
assert_eq!(interpreter, "my-recipe-interp");
assert!(reason.contains("rejected by test stub"), "reason: {reason}");
}
other => panic!("expected InvalidInterpreter, got {other:?}"),
}
}
#[test]
fn system_fallback_scope_finds_path_only_exe() {
let tmp = tempfile::tempdir().unwrap();
let prefix = tmp.path().join("prefix");
fs::create_dir_all(&prefix).unwrap();
let path_dir = tmp.path().join("path_only");
fs::create_dir_all(&path_dir).unwrap();
let exe_name = format!("rb_path_only_tool{}", std::env::consts::EXE_SUFFIX);
let exe = path_dir.join(&exe_name);
fs::write(&exe, "").unwrap();
#[cfg(unix)]
{
use std::{fs::Permissions, os::unix::fs::PermissionsExt};
fs::set_permissions(&exe, Permissions::from_mode(0o755)).unwrap();
}
let runtime = RuntimeEnv::for_test(Platform::current())
.with_var("PATH", path_dir.to_string_lossy().into_owned());
let found_via_path = find_interpreter(
"rb_path_only_tool",
Some(prefix.as_path()),
prefix.as_path(),
&runtime,
InterpreterSearchScope::build_and_host_with_system_fallback(),
);
let found_build_only = find_interpreter(
"rb_path_only_tool",
Some(prefix.as_path()),
prefix.as_path(),
&runtime,
InterpreterSearchScope::build_only(),
);
assert!(
found_via_path.is_some(),
"system-fallback scope should find the exe on PATH"
);
assert_eq!(found_via_path.unwrap(), exe);
assert!(
found_build_only.is_none(),
"build_only must not find an exe that lives only on PATH"
);
}
#[tokio::test]
async fn generated_wrapper_quotes_spaced_interpreter_path() {
let tmp = tempfile::tempdir().unwrap();
let prefix = tmp.path().join("pre fix");
fs::create_dir_all(&prefix).unwrap();
let python = create_fake_executable(&prefix, "python");
assert!(python.to_string_lossy().contains(' '));
let args = execution_args(
tmp.path().to_path_buf(),
prefix,
ResolvedScriptContents::Inline("print('hi')".to_string()),
Some("python"),
);
crate::execution::generate_build_script(&args)
.await
.unwrap();
let wrapper = fs::read_to_string(native_build_script_path(tmp.path())).unwrap();
let path = python.to_string_lossy();
let quoted = if cfg!(windows) {
format!("\"{path}\"")
} else {
format!("'{path}'")
};
assert!(
wrapper.contains("ed),
"wrapper must quote the spaced interpreter path `{quoted}`, got:\n{wrapper}"
);
}
#[test]
fn host_prefix_searched_only_with_system_fallback_scope() {
let tmp = tempfile::tempdir().unwrap();
let build_prefix = tmp.path().join("build");
let host_prefix = tmp.path().join("host");
fs::create_dir_all(&build_prefix).unwrap();
fs::create_dir_all(&host_prefix).unwrap();
let tool = create_fake_executable(&host_prefix, "rb_host_tool");
let runtime = RuntimeEnv::for_test(Platform::current()).with_var("PATH", "");
let found = find_interpreter(
"rb_host_tool",
Some(build_prefix.as_path()),
host_prefix.as_path(),
&runtime,
InterpreterSearchScope::build_and_host_with_system_fallback(),
);
assert_eq!(found.as_deref(), Some(tool.as_path()));
let build_only = find_interpreter(
"rb_host_tool",
Some(build_prefix.as_path()),
host_prefix.as_path(),
&runtime,
InterpreterSearchScope::build_only(),
);
assert!(
build_only.is_none(),
"build_only must not search the host prefix"
);
}
#[test]
fn powershell_lists_pwsh_then_powershell_on_windows() {
let names = super::powershell::PowerShellInvocation.executable_names(&Platform::Win64);
assert_eq!(names, &["pwsh", "powershell"]);
}
#[test]
fn missing_first_candidate_falls_through_to_second() {
let tmp = tempfile::tempdir().unwrap();
let prefix = tmp.path().join("prefix");
fs::create_dir_all(&prefix).unwrap();
let second = create_fake_executable(&prefix, "stub_second");
let resolved = RejectFirstStub
.resolve_executable(
Some(prefix.as_path()),
prefix.as_path(),
&RuntimeEnv::current(),
)
.expect("second candidate should resolve when the first is absent");
assert_eq!(resolved, second);
}
#[test]
fn cmd_uses_comspec_special_case() {
let tmp = tempfile::tempdir().unwrap();
let fake_cmd = tmp.path().join("system32").join("cmd.exe");
fs::create_dir_all(fake_cmd.parent().unwrap()).unwrap();
fs::write(&fake_cmd, "").unwrap();
let runtime = RuntimeEnv::for_test(Platform::Win64)
.with_var("COMSPEC", fake_cmd.to_string_lossy().into_owned());
let resolved =
super::cmd_exe::CmdExeInvocation.resolve_executable(None, tmp.path(), &runtime);
assert_eq!(resolved.unwrap(), fake_cmd);
}
#[test]
fn factory_maps_recipe_names_to_extensions() {
assert_eq!(
SelectedInterpreter::from_recipe_name("nushell")
.unwrap()
.extension(),
"nu"
);
assert_eq!(
SelectedInterpreter::from_recipe_name("nu")
.unwrap()
.extension(),
"nu"
);
assert_eq!(
SelectedInterpreter::from_recipe_name("brush")
.unwrap()
.extension(),
"sh"
);
assert_eq!(
SelectedInterpreter::from_recipe_name("python")
.unwrap()
.extension(),
"py"
);
}
#[test]
fn interpreter_table_has_no_duplicate_names() {
let mut names: Vec<&str> = INTERPRETERS.iter().map(|(name, _)| *name).collect();
names.sort_unstable();
let before = names.len();
names.dedup();
assert_eq!(before, names.len(), "duplicate name in INTERPRETERS");
}
#[test]
fn closest_interpreter_suggests_typos_only() {
assert_eq!(closest_interpreter("brus"), Some("brush"));
assert_eq!(closest_interpreter("pyton"), Some("python"));
assert_eq!(closest_interpreter("powershel"), Some("powershell"));
assert_eq!(closest_interpreter("pwsh"), Some("powershell"));
assert_eq!(closest_interpreter("zsh"), None);
assert_eq!(closest_interpreter("not-a-real-interp"), None);
}
#[test]
fn closest_interpreter_is_case_insensitive() {
assert_eq!(closest_interpreter("Bash"), Some("bash"));
assert_eq!(closest_interpreter("PYTHON"), Some("python"));
assert_eq!(closest_interpreter("PowerShell"), Some("powershell"));
}
#[test]
fn join_commands_is_interpreter_specific() {
let commands = vec!["echo Hello".to_string(), "echo World".to_string()];
assert_eq!(
super::cmd_exe::CmdExeInvocation.join_commands(&commands),
"echo Hello\nif %errorlevel% neq 0 exit /b %errorlevel%\n\
echo World\nif %errorlevel% neq 0 exit /b %errorlevel%"
);
assert_eq!(
super::bash::BashInvocation.join_commands(&commands),
"echo Hello\necho World"
);
assert_eq!(
super::python::PythonInvocation.join_commands(&commands),
"echo Hello\necho World"
);
}
}