From 6fb24008599cb5388f601a5afc7496d70d9e8d8c Mon Sep 17 00:00:00 2001 From: 0xzhzh <178814591+0xzhzh@users.noreply.github.com> Date: Thu, 24 Oct 2024 23:42:28 -0700 Subject: [PATCH 1/6] Add which function for finding executables in PATH Closes #2109 (but with a function name that is shorter and more familiar) --- Cargo.toml | 1 + README.md | 16 ++++++++++++++++ src/function.rs | 11 +++++++++++ tests/lib.rs | 1 + tests/which_exec.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 71 insertions(+) create mode 100644 tests/which_exec.rs diff --git a/Cargo.toml b/Cargo.toml index f74367d02f..8a516d9935 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ tempfile = "3.0.0" typed-arena = "2.0.1" unicode-width = "0.2.0" uuid = { version = "1.0.0", features = ["v4"] } +which = "6.0.0" [dev-dependencies] executable-path = "1.0.0" diff --git a/README.md b/README.md index 535b2bc402..82b94272ce 100644 --- a/README.md +++ b/README.md @@ -1597,6 +1597,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)`1.37.0 — 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 a714a8d0fd..ff9c93b6a6 100644 --- a/src/function.rs +++ b/src/function.rs @@ -110,6 +110,7 @@ pub(crate) fn get(name: &str) -> Option { "uppercase" => Unary(uppercase), "uuid" => Nullary(uuid), "without_extension" => Unary(without_extension), + "which" => Unary(which_exec), _ => return None, }; Some(function) @@ -667,6 +668,16 @@ fn uuid(_context: Context) -> FunctionResult { Ok(uuid::Uuid::new_v4().to_string()) } +fn which_exec(_context: Context, s: &str) -> FunctionResult { + let path = which::which(s).unwrap_or_default(); + path.to_str().map(str::to_string).ok_or_else(|| { + format!( + "unable to convert which executable path to string: {}", + path.display() + ) + }) +} + 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 ec71c665da..0552851f69 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -110,6 +110,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..1b685e96a8 --- /dev/null +++ b/tests/which_exec.rs @@ -0,0 +1,42 @@ +use super::*; + +fn make_path() -> TempDir { + let tmp = temptree! { + "hello.exe": "#!/usr/bin/env bash\necho hello\n", + }; + + #[cfg(not(windows))] + { + let exe = tmp.path().join("hello.exe"); + let perms = std::os::unix::fs::PermissionsExt::from_mode(0o755); + fs::set_permissions(exe, perms).unwrap(); + } + + tmp +} + +#[test] +fn finds_executable() { + let tmp = make_path(); + let mut path = env::current_dir().unwrap(); + path.push("bin"); + 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 = make_path(); + let mut path = env::current_dir().unwrap(); + path.push("bin"); + Test::new() + .justfile(r#"p := which("goodbye.exe")"#) + .env("PATH", tmp.path().to_str().unwrap()) + .args(["--evaluate", "p"]) + .stdout("") + .run(); +} From f1980b5150f7720aac4139daa1cbeaa86cb0b616 Mon Sep 17 00:00:00 2001 From: 0xzhzh <178814591+0xzhzh@users.noreply.github.com> Date: Sun, 3 Nov 2024 18:53:11 -0800 Subject: [PATCH 2/6] Change version of which function to master branch --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 82b94272ce..5b165b02e8 100644 --- a/README.md +++ b/README.md @@ -1597,7 +1597,7 @@ $ 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)`1.37.0 — Retrieves the full path of `exe` according +- `which(exe)`master — Retrieves the full path of `exe` according to the `PATH`. Returns an empty string if no executable named `exe` exists. ```just From 0c6f5e8376eb3c3ed2024637d2dd05fff8298638 Mon Sep 17 00:00:00 2001 From: 0xzhzh <178814591+0xzhzh@users.noreply.github.com> Date: Sun, 3 Nov 2024 23:28:42 -0800 Subject: [PATCH 3/6] Use internal implementation of which() --- Cargo.lock | 11 +++++++++ Cargo.toml | 3 ++- justfile | 2 ++ src/function.rs | 64 ++++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 80a7276d9d..1ba1463bd6 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" @@ -535,8 +544,10 @@ dependencies = [ "dirs", "dotenvy", "edit-distance", + "either", "executable-path", "heck", + "is_executable", "lexiclean", "libc", "num_cpus", diff --git a/Cargo.toml b/Cargo.toml index 8a516d9935..bda81c5ec1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,9 @@ derivative = "2.0.0" dirs = "5.0.1" dotenvy = "0.15" edit-distance = "2.0.0" +either = "1.13.0" heck = "0.5.0" +is_executable = "1.0.4" lexiclean = "0.0.1" libc = "0.2.0" num_cpus = "1.15.0" @@ -51,7 +53,6 @@ tempfile = "3.0.0" typed-arena = "2.0.1" unicode-width = "0.2.0" uuid = { version = "1.0.0", features = ["v4"] } -which = "6.0.0" [dev-dependencies] executable-path = "1.0.0" diff --git a/justfile b/justfile index d0ff455c67..cf65778280 100755 --- a/justfile +++ b/justfile @@ -6,6 +6,8 @@ alias t := test log := "warn" +bingus := which("bash") + export JUST_LOG := log [group: 'dev'] diff --git a/src/function.rs b/src/function.rs index ff9c93b6a6..25c328b9c5 100644 --- a/src/function.rs +++ b/src/function.rs @@ -1,5 +1,6 @@ use { super::*, + either::Either, heck::{ ToKebabCase, ToLowerCamelCase, ToShoutyKebabCase, ToShoutySnakeCase, ToSnakeCase, ToTitleCase, ToUpperCamelCase, @@ -110,7 +111,7 @@ pub(crate) fn get(name: &str) -> Option { "uppercase" => Unary(uppercase), "uuid" => Nullary(uuid), "without_extension" => Unary(without_extension), - "which" => Unary(which_exec), + "which" => Unary(which), _ => return None, }; Some(function) @@ -668,14 +669,59 @@ fn uuid(_context: Context) -> FunctionResult { Ok(uuid::Uuid::new_v4().to_string()) } -fn which_exec(_context: Context, s: &str) -> FunctionResult { - let path = which::which(s).unwrap_or_default(); - path.to_str().map(str::to_string).ok_or_else(|| { - format!( - "unable to convert which executable path to string: {}", - path.display() - ) - }) +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")?; + Either::Left(env::split_paths(&path_var).map(|path| path.join(cmd.clone()))) + } + _ => { + // cmd contains a path separator, treat it as a path + Either::Right(iter::once(cmd)) + } + }; + + for mut candidate in candidates.into_iter() { + 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 { From 389b2ae4af5910ff2fceefedab8ee668db4115f1 Mon Sep 17 00:00:00 2001 From: 0xzhzh <178814591+0xzhzh@users.noreply.github.com> Date: Sun, 1 Dec 2024 17:47:48 -0500 Subject: [PATCH 4/6] Add tests for internal implementation of which() --- tests/which_exec.rs | 162 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 145 insertions(+), 17 deletions(-) diff --git a/tests/which_exec.rs b/tests/which_exec.rs index 1b685e96a8..edc09d723d 100644 --- a/tests/which_exec.rs +++ b/tests/which_exec.rs @@ -1,25 +1,46 @@ use super::*; -fn make_path() -> TempDir { - let tmp = temptree! { - "hello.exe": "#!/usr/bin/env bash\necho hello\n", - }; +trait TempDirExt { + fn executable(self, file: impl AsRef) -> Self; +} - #[cfg(not(windows))] - { - let exe = tmp.path().join("hello.exe"); - let perms = std::os::unix::fs::PermissionsExt::from_mode(0o755); - fs::set_permissions(exe, perms).unwrap(); - } +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(); + } - tmp + self + } } #[test] fn finds_executable() { - let tmp = make_path(); - let mut path = env::current_dir().unwrap(); - path.push("bin"); + 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()) @@ -30,9 +51,11 @@ fn finds_executable() { #[test] fn prints_empty_string_for_missing_executable() { - let tmp = make_path(); - let mut path = env::current_dir().unwrap(); - path.push("bin"); + 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()) @@ -40,3 +63,108 @@ fn prints_empty_string_for_missing_executable() { .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(); +} From 2a535c0fbe70712c4dcdd52e27a6aa5966656074 Mon Sep 17 00:00:00 2001 From: 0xzhzh <178814591+0xzhzh@users.noreply.github.com> Date: Sun, 1 Dec 2024 18:00:27 -0500 Subject: [PATCH 5/6] Remove stray addition to justfile --- justfile | 2 -- 1 file changed, 2 deletions(-) diff --git a/justfile b/justfile index cf65778280..d0ff455c67 100755 --- a/justfile +++ b/justfile @@ -6,8 +6,6 @@ alias t := test log := "warn" -bingus := which("bash") - export JUST_LOG := log [group: 'dev'] From 34f2ea607d4758349844a8e8635e4f90e97cd171 Mon Sep 17 00:00:00 2001 From: 0xzhzh <178814591+0xzhzh@users.noreply.github.com> Date: Fri, 13 Dec 2024 15:14:21 -0800 Subject: [PATCH 6/6] Remove dependency on either --- Cargo.lock | 1 - Cargo.toml | 1 - src/function.rs | 7 +++---- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1ba1463bd6..8958927e23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -544,7 +544,6 @@ dependencies = [ "dirs", "dotenvy", "edit-distance", - "either", "executable-path", "heck", "is_executable", diff --git a/Cargo.toml b/Cargo.toml index bda81c5ec1..8c5be83add 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,6 @@ derivative = "2.0.0" dirs = "5.0.1" dotenvy = "0.15" edit-distance = "2.0.0" -either = "1.13.0" heck = "0.5.0" is_executable = "1.0.4" lexiclean = "0.0.1" diff --git a/src/function.rs b/src/function.rs index 25c328b9c5..795a5297fd 100644 --- a/src/function.rs +++ b/src/function.rs @@ -1,6 +1,5 @@ use { super::*, - either::Either, heck::{ ToKebabCase, ToLowerCamelCase, ToShoutyKebabCase, ToShoutySnakeCase, ToSnakeCase, ToTitleCase, ToUpperCamelCase, @@ -680,15 +679,15 @@ fn which(context: Context, s: &str) -> FunctionResult { 1 => { // cmd is a regular command path_var = env::var_os("PATH").ok_or("Environment variable `PATH` is not set")?; - Either::Left(env::split_paths(&path_var).map(|path| path.join(cmd.clone()))) + env::split_paths(&path_var).map(|path| path.join(cmd.clone())).collect() } _ => { // cmd contains a path separator, treat it as a path - Either::Right(iter::once(cmd)) + vec![cmd] } }; - for mut candidate in candidates.into_iter() { + 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.