diff --git a/Cargo.lock b/Cargo.lock index 5bec3df72d..00ba16a50f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -498,6 +498,15 @@ dependencies = [ "cc", ] +[[package]] +name = "is_executable" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a1b5bad6f9072935961dfbf1cced2f3d129963d091b6f69f007fe04e758ae2" +dependencies = [ + "winapi", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -538,6 +547,7 @@ dependencies = [ "edit-distance", "executable-path", "heck", + "is_executable", "lexiclean", "libc", "num_cpus", diff --git a/Cargo.toml b/Cargo.toml index cd922e35ad..1b3d84dfcc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ dirs = "5.0.1" dotenvy = "0.15" edit-distance = "2.0.0" heck = "0.5.0" +is_executable = "1.0.4" lexiclean = "0.0.1" libc = "0.2.0" num_cpus = "1.15.0" diff --git a/README.md b/README.md index 74e3d50918..5ec3416e41 100644 --- a/README.md +++ b/README.md @@ -1664,6 +1664,22 @@ $ just name `key`, returning `default` if it is not present. - `env(key)`1.15.0 — Alias for `env_var(key)`. - `env(key, default)`1.15.0 — Alias for `env_var_or_default(key, default)`. +- `which(exe)`master — Retrieves the full path of `exe` according + to the `PATH`. Returns an empty string if no executable named `exe` exists. + +```just +bash := which("bash") +nexist := which("does-not-exist") + +@test: + echo "bash: '{{bash}}'" + echo "nexist: '{{nexist}}'" +``` + +```console +bash: '/bin/bash' +nexist: '' +``` #### Invocation Information diff --git a/src/function.rs b/src/function.rs index 66e7c6e2dd..51bd1bc733 100644 --- a/src/function.rs +++ b/src/function.rs @@ -112,6 +112,7 @@ pub(crate) fn get(name: &str) -> Option { "uppercase" => Unary(uppercase), "uuid" => Nullary(uuid), "without_extension" => Unary(without_extension), + "which" => Unary(which), _ => return None, }; Some(function) @@ -661,6 +662,61 @@ fn uuid(_context: Context) -> FunctionResult { Ok(uuid::Uuid::new_v4().to_string()) } +fn which(context: Context, s: &str) -> FunctionResult { + use is_executable::IsExecutable; + + let cmd = PathBuf::from(s); + + let path_var; + let candidates = match cmd.components().count() { + 0 => Err("empty command string".to_string())?, + 1 => { + // cmd is a regular command + path_var = env::var_os("PATH").ok_or("Environment variable `PATH` is not set")?; + env::split_paths(&path_var).map(|path| path.join(cmd.clone())).collect() + } + _ => { + // cmd contains a path separator, treat it as a path + vec![cmd] + } + }; + + for mut candidate in candidates { + if candidate.is_relative() { + // This candidate is a relative path, either because the user invoked `which("./rel/path")`, + // or because there was a relative path in `PATH`. Resolve it to an absolute path. + let cwd = context + .evaluator + .context + .search + .justfile + .parent() + .ok_or_else(|| { + format!( + "Could not resolve absolute path from `{}` relative to the justfile directory. Justfile `{}` had no parent.", + candidate.display(), + context.evaluator.context.search.justfile.display() + ) + })?; + let mut cwd = PathBuf::from(cwd); + cwd.push(candidate); + candidate = cwd; + } + + if candidate.is_executable() { + return candidate.to_str().map(str::to_string).ok_or_else(|| { + format!( + "Executable path is not valid unicode: {}", + candidate.display() + ) + }); + } + } + + // No viable candidates; return an empty string + Ok(String::new()) +} + fn without_extension(_context: Context, path: &str) -> FunctionResult { let parent = Utf8Path::new(path) .parent() diff --git a/tests/lib.rs b/tests/lib.rs index a86a3da769..fb2849719f 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -119,6 +119,7 @@ mod timestamps; mod undefined_variables; mod unexport; mod unstable; +mod which_exec; #[cfg(windows)] mod windows; #[cfg(target_family = "windows")] diff --git a/tests/which_exec.rs b/tests/which_exec.rs new file mode 100644 index 0000000000..edc09d723d --- /dev/null +++ b/tests/which_exec.rs @@ -0,0 +1,170 @@ +use super::*; + +trait TempDirExt { + fn executable(self, file: impl AsRef) -> Self; +} + +impl TempDirExt for TempDir { + fn executable(self, file: impl AsRef) -> Self { + let file = self.path().join(file.as_ref()); + + // Make sure it exists first, as a sanity check. + assert!( + file.exists(), + "executable file does not exist: {}", + file.display() + ); + + // Windows uses file extensions to determine whether a file is executable. + // Other systems don't care. To keep these tests cross-platform, just make + // sure all executables end with ".exe" suffix. + assert!( + file.extension() == Some("exe".as_ref()), + "executable file does not end with .exe: {}", + file.display() + ); + + #[cfg(not(windows))] + { + let perms = std::os::unix::fs::PermissionsExt::from_mode(0o755); + fs::set_permissions(file, perms).unwrap(); + } + + self + } +} + +#[test] +fn finds_executable() { + let tmp = temptree! { + "hello.exe": "#!/usr/bin/env bash\necho hello\n", + } + .executable("hello.exe"); + + Test::new() + .justfile(r#"p := which("hello.exe")"#) + .env("PATH", tmp.path().to_str().unwrap()) + .args(["--evaluate", "p"]) + .stdout(format!("{}", tmp.path().join("hello.exe").display())) + .run(); +} + +#[test] +fn prints_empty_string_for_missing_executable() { + let tmp = temptree! { + "hello.exe": "#!/usr/bin/env bash\necho hello\n", + } + .executable("hello.exe"); + + Test::new() + .justfile(r#"p := which("goodbye.exe")"#) + .env("PATH", tmp.path().to_str().unwrap()) + .args(["--evaluate", "p"]) + .stdout("") + .run(); +} + +#[test] +fn skips_non_executable_files() { + let tmp = temptree! { + "hello.exe": "#!/usr/bin/env bash\necho hello\n", + "hi": "just some regular file", + } + .executable("hello.exe"); + + Test::new() + .justfile(r#"p := which("hi")"#) + .env("PATH", tmp.path().to_str().unwrap()) + .args(["--evaluate", "p"]) + .stdout("") + .run(); +} + +#[test] +fn supports_multiple_paths() { + let tmp1 = temptree! { + "hello1.exe": "#!/usr/bin/env bash\necho hello\n", + } + .executable("hello1.exe"); + + let tmp2 = temptree! { + "hello2.exe": "#!/usr/bin/env bash\necho hello\n", + } + .executable("hello2.exe"); + + let path = + env::join_paths([tmp1.path().to_str().unwrap(), tmp2.path().to_str().unwrap()]).unwrap(); + + Test::new() + .justfile(r#"p := which("hello1.exe")"#) + .env("PATH", path.to_str().unwrap()) + .args(["--evaluate", "p"]) + .stdout(format!("{}", tmp1.path().join("hello1.exe").display())) + .run(); + + Test::new() + .justfile(r#"p := which("hello2.exe")"#) + .env("PATH", path.to_str().unwrap()) + .args(["--evaluate", "p"]) + .stdout(format!("{}", tmp2.path().join("hello2.exe").display())) + .run(); +} + +#[test] +fn supports_shadowed_executables() { + let tmp1 = temptree! { + "shadowed.exe": "#!/usr/bin/env bash\necho hello\n", + } + .executable("shadowed.exe"); + + let tmp2 = temptree! { + "shadowed.exe": "#!/usr/bin/env bash\necho hello\n", + } + .executable("shadowed.exe"); + + // which should never resolve to this directory, no matter where or how many + // times it appears in PATH, because the "shadowed" file is not executable. + let dummy = if cfg!(windows) { + temptree! { + "shadowed": "#!/usr/bin/env bash\necho hello\n", + } + } else { + temptree! { + "shadowed.exe": "#!/usr/bin/env bash\necho hello\n", + } + }; + + // This PATH should give priority to tmp1/shadowed.exe + let tmp1_path = env::join_paths([ + dummy.path().to_str().unwrap(), + tmp1.path().to_str().unwrap(), + dummy.path().to_str().unwrap(), + tmp2.path().to_str().unwrap(), + dummy.path().to_str().unwrap(), + ]) + .unwrap(); + + // This PATH should give priority to tmp2/shadowed.exe + let tmp2_path = env::join_paths([ + dummy.path().to_str().unwrap(), + tmp2.path().to_str().unwrap(), + dummy.path().to_str().unwrap(), + tmp1.path().to_str().unwrap(), + dummy.path().to_str().unwrap(), + ]) + .unwrap(); + + Test::new() + .justfile(r#"p := which("shadowed.exe")"#) + .env("PATH", tmp1_path.to_str().unwrap()) + .args(["--evaluate", "p"]) + .stdout(format!("{}", tmp1.path().join("shadowed.exe").display())) + .run(); + + Test::new() + .justfile(r#"p := which("shadowed.exe")"#) + .env("PATH", tmp2_path.to_str().unwrap()) + .args(["--evaluate", "p"]) + .stdout(format!("{}", tmp2.path().join("shadowed.exe").display())) + .run(); +}