Repository: casey/just (rust)

Issue:
`executable_exists` function to check whether a certain program exists and is executable on the path
As an example I want my install scripts to use `nala` if available and fall back to `apt-get` if not.

Pull request description:
Add which function for finding executables in PATH
Closes #2109.

I considered calling it `executable_exists` but that seemed limiting---`which` also returns the path of the executable, not just whether it exists. Then one can implement `executable_exists(name)` using `which(name) == ""`.

I wasn't sure whether to add more tests. Most of the interesting test cases are the responsibility of the `which` crate to get right (eg executable existing in multiple directories in PATH, executable existing in PATH but not having executable permissions, etc). At the end of the day this function is just a wrapper around `which::which`, so there is not much more to test here than what should already be tested by `which::which`.

Also not sure if documentation was added in the right place.

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 5bec3df7..00ba16a5 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 cd922e35..1b3d84df 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 5fd19ac9..d8f7d425 100644
--- a/README.md
+++ b/README.md
@@ -1674,6 +1674,43 @@ set unstable
 foo := env('FOO') || 'DEFAULT_VALUE'
 ```
 
+#### Executables
+
+- `require(name)`<sup>master</sup> — Search directories in the `PATH`
+  environment variable for the executable `name` and return its full path, or
+  halt with an error if no executable with `name` exists.
+
+  ```just
+  bash := require("bash")
+
+  @test:
+      echo "bash: '{{bash}}'"
+  ```
+
+  ```console
+  $ just
+  bash: '/bin/bash'
+  ```
+
+- `which(name)`<sup>master</sup> — Search directories in the `PATH` environment
+  variable for the executable `name` and return its full path, or the empty
+  string if no executable with `name` exists. Currently unstable.
+
+
+  ```just
+  set unstable
+
+  bosh := require("bosh")
+
+  @test:
+      echo "bosh: '{{bosh}}'"
+  ```
+
+  ```console
+  $ just
+  bosh: ''
+  ```
+
 #### Invocation Information
 
 - `is_dependency()` - Returns the string `true` if the current recipe is being
diff --git a/src/function.rs b/src/function.rs
index 66e7c6e2..d5b9f192 100644
--- a/src/function.rs
+++ b/src/function.rs
@@ -90,6 +90,7 @@ pub(crate) fn get(name: &str) -> Option<Function> {
     "read" => Unary(read),
     "replace" => Ternary(replace),
     "replace_regex" => Ternary(replace_regex),
+    "require" => Unary(require),
     "semver_matches" => Binary(semver_matches),
     "sha256" => Unary(sha256),
     "sha256_file" => Unary(sha256_file),
@@ -111,6 +112,7 @@ pub(crate) fn get(name: &str) -> Option<Function> {
     "uppercamelcase" => Unary(uppercamelcase),
     "uppercase" => Unary(uppercase),
     "uuid" => Nullary(uuid),
+    "which" => Unary(which),
     "without_extension" => Unary(without_extension),
     _ => return None,
   };
@@ -511,6 +513,15 @@ fn replace(_context: Context, s: &str, from: &str, to: &str) -> FunctionResult {
   Ok(s.replace(from, to))
 }
 
+fn require(context: Context, s: &str) -> FunctionResult {
+  let p = which(context, s)?;
+  if p.is_empty() {
+    Err(format!("could not find required executable: `{s}`"))
+  } else {
+    Ok(p)
+  }
+}
+
 fn replace_regex(_context: Context, s: &str, regex: &str, replacement: &str) -> FunctionResult {
   Ok(
     Regex::new(regex)
@@ -661,6 +672,52 @@ fn uuid(_context: Context) -> FunctionResult {
   Ok(uuid::Uuid::new_v4().to_string())
 }
 
+fn which(context: Context, s: &str) -> FunctionResult {
+  let cmd = Path::new(s);
+
+  let candidates = match cmd.components().count() {
+    0 => return Err("empty command".into()),
+    1 => {
+      // cmd is a regular command
+      let path_var = env::var_os("PATH").ok_or("Environment variable `PATH` is not set")?;
+      env::split_paths(&path_var)
+        .map(|path| path.join(cmd))
+        .collect()
+    }
+    _ => {
+      // cmd contains a path separator, treat it as a path
+      vec![cmd.into()]
+    }
+  };
+
+  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,
+      // relative to the working directory of the just invocation.
+      candidate = context
+        .evaluator
+        .context
+        .working_directory()
+        .join(candidate);
+    }
+
+    candidate = candidate.lexiclean();
+
+    if is_executable::is_executable(&candidate) {
+      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/src/parser.rs b/src/parser.rs
index 42e9f26c..9305a42d 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -698,6 +698,11 @@ impl<'run, 'src> Parser<'run, 'src> {
 
         if self.next_is(ParenL) {
           let arguments = self.parse_sequence()?;
+          if name.lexeme() == "which" {
+            self
+              .unstable_features
+              .insert(UnstableFeature::WhichFunction);
+          }
           Ok(Expression::Call {
             thunk: Thunk::resolve(name, arguments)?,
           })
diff --git a/src/unstable_feature.rs b/src/unstable_feature.rs
index 70e26fab..daa2270b 100644
--- a/src/unstable_feature.rs
+++ b/src/unstable_feature.rs
@@ -6,6 +6,7 @@ pub(crate) enum UnstableFeature {
   LogicalOperators,
   ScriptAttribute,
   ScriptInterpreterSetting,
+  WhichFunction,
 }
 
 impl Display for UnstableFeature {
@@ -20,6 +21,7 @@ impl Display for UnstableFeature {
       Self::ScriptInterpreterSetting => {
         write!(f, "The `script-interpreter` setting is currently unstable.")
       }
+      Self::WhichFunction => write!(f, "The `which()` function is currently unstable."),
     }
   }
 }


Test patch:
diff --git a/tests/lib.rs b/tests/lib.rs
index a86a3da7..04133b9f 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -119,6 +119,7 @@ mod timestamps;
 mod undefined_variables;
 mod unexport;
 mod unstable;
+mod which_function;
 #[cfg(windows)]
 mod windows;
 #[cfg(target_family = "windows")]
diff --git a/tests/test.rs b/tests/test.rs
index e02b5a85..78c4e6e5 100644
--- a/tests/test.rs
+++ b/tests/test.rs
@@ -205,6 +205,30 @@ impl Test {
     self
   }
 
+  pub(crate) fn make_executable(self, path: impl AsRef<Path>) -> Self {
+    let file = self.tempdir.path().join(path);
+
+    // Make sure it exists first, as a sanity check.
+    assert!(file.exists(), "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(unix)]
+    {
+      let perms = std::os::unix::fs::PermissionsExt::from_mode(0o755);
+      fs::set_permissions(file, perms).unwrap();
+    }
+
+    self
+  }
+
   pub(crate) fn expect_file(mut self, path: impl AsRef<Path>, content: impl AsRef<[u8]>) -> Self {
     let path = path.as_ref();
     self
diff --git a/tests/which_function.rs b/tests/which_function.rs
new file mode 100644
index 00000000..754075e4
--- /dev/null
+++ b/tests/which_function.rs
@@ -0,0 +1,236 @@
+use super::*;
+
+const HELLO_SCRIPT: &str = "#!/usr/bin/env bash
+echo hello
+";
+
+#[test]
+fn finds_executable() {
+  let tmp = tempdir();
+  let path = PathBuf::from(tmp.path());
+
+  Test::with_tempdir(tmp)
+    .justfile("p := which('hello.exe')")
+    .args(["--evaluate", "p"])
+    .write("hello.exe", HELLO_SCRIPT)
+    .make_executable("hello.exe")
+    .env("PATH", path.to_str().unwrap())
+    .env("JUST_UNSTABLE", "1")
+    .stdout(path.join("hello.exe").display().to_string())
+    .run();
+}
+
+#[test]
+fn prints_empty_string_for_missing_executable() {
+  let tmp = tempdir();
+  let path = PathBuf::from(tmp.path());
+
+  Test::with_tempdir(tmp)
+    .justfile("p := which('goodbye.exe')")
+    .args(["--evaluate", "p"])
+    .write("hello.exe", HELLO_SCRIPT)
+    .make_executable("hello.exe")
+    .env("PATH", path.to_str().unwrap())
+    .env("JUST_UNSTABLE", "1")
+    .run();
+}
+
+#[test]
+fn skips_non_executable_files() {
+  let tmp = tempdir();
+  let path = PathBuf::from(tmp.path());
+
+  Test::with_tempdir(tmp)
+    .justfile("p := which('hi')")
+    .args(["--evaluate", "p"])
+    .write("hello.exe", HELLO_SCRIPT)
+    .make_executable("hello.exe")
+    .write("hi", "just some regular file")
+    .env("PATH", path.to_str().unwrap())
+    .env("JUST_UNSTABLE", "1")
+    .run();
+}
+
+#[test]
+fn supports_multiple_paths() {
+  let tmp = tempdir();
+  let path = PathBuf::from(tmp.path());
+  let path_var = env::join_paths([
+    path.join("subdir1").to_str().unwrap(),
+    path.join("subdir2").to_str().unwrap(),
+  ])
+  .unwrap();
+
+  Test::with_tempdir(tmp)
+    .justfile("p := which('hello1.exe') + '+' + which('hello2.exe')")
+    .args(["--evaluate", "p"])
+    .write("subdir1/hello1.exe", HELLO_SCRIPT)
+    .make_executable("subdir1/hello1.exe")
+    .write("subdir2/hello2.exe", HELLO_SCRIPT)
+    .make_executable("subdir2/hello2.exe")
+    .env("PATH", path_var.to_str().unwrap())
+    .env("JUST_UNSTABLE", "1")
+    .stdout(format!(
+      "{}+{}",
+      path.join("subdir1").join("hello1.exe").display(),
+      path.join("subdir2").join("hello2.exe").display(),
+    ))
+    .run();
+}
+
+#[test]
+fn supports_shadowed_executables() {
+  enum Variation {
+    Dir1Dir2, // PATH=/tmp/.../dir1:/tmp/.../dir2
+    Dir2Dir1, // PATH=/tmp/.../dir2:/tmp/.../dir1
+  }
+
+  for variation in [Variation::Dir1Dir2, Variation::Dir2Dir1] {
+    let tmp = tempdir();
+    let path = PathBuf::from(tmp.path());
+
+    let path_var = match variation {
+      Variation::Dir1Dir2 => env::join_paths([
+        path.join("dir1").to_str().unwrap(),
+        path.join("dir2").to_str().unwrap(),
+      ]),
+      Variation::Dir2Dir1 => env::join_paths([
+        path.join("dir2").to_str().unwrap(),
+        path.join("dir1").to_str().unwrap(),
+      ]),
+    }
+    .unwrap();
+
+    let stdout = match variation {
+      Variation::Dir1Dir2 => format!("{}", path.join("dir1").join("shadowed.exe").display()),
+      Variation::Dir2Dir1 => format!("{}", path.join("dir2").join("shadowed.exe").display()),
+    };
+
+    Test::with_tempdir(tmp)
+      .justfile("p := which('shadowed.exe')")
+      .args(["--evaluate", "p"])
+      .write("dir1/shadowed.exe", HELLO_SCRIPT)
+      .make_executable("dir1/shadowed.exe")
+      .write("dir2/shadowed.exe", HELLO_SCRIPT)
+      .make_executable("dir2/shadowed.exe")
+      .env("PATH", path_var.to_str().unwrap())
+      .env("JUST_UNSTABLE", "1")
+      .stdout(stdout)
+      .run();
+  }
+}
+
+#[test]
+fn ignores_nonexecutable_candidates() {
+  let tmp = tempdir();
+  let path = PathBuf::from(tmp.path());
+
+  let path_var = env::join_paths([
+    path.join("dummy").to_str().unwrap(),
+    path.join("subdir").to_str().unwrap(),
+    path.join("dummy").to_str().unwrap(),
+  ])
+  .unwrap();
+
+  let dummy_exe = if cfg!(windows) {
+    "dummy/foo"
+  } else {
+    "dummy/foo.exe"
+  };
+
+  Test::with_tempdir(tmp)
+    .justfile("p := which('foo.exe')")
+    .args(["--evaluate", "p"])
+    .write("subdir/foo.exe", HELLO_SCRIPT)
+    .make_executable("subdir/foo.exe")
+    .write(dummy_exe, HELLO_SCRIPT)
+    .env("PATH", path_var.to_str().unwrap())
+    .env("JUST_UNSTABLE", "1")
+    .stdout(path.join("subdir").join("foo.exe").display().to_string())
+    .run();
+}
+
+#[test]
+fn handles_absolute_path() {
+  let tmp = tempdir();
+  let path = PathBuf::from(tmp.path());
+  let abspath = path.join("subdir").join("foo.exe");
+
+  Test::with_tempdir(tmp)
+    .justfile(format!("p := which('{}')", abspath.display()))
+    .write("subdir/foo.exe", HELLO_SCRIPT)
+    .make_executable("subdir/foo.exe")
+    .write("pathdir/foo.exe", HELLO_SCRIPT)
+    .make_executable("pathdir/foo.exe")
+    .env("PATH", path.join("pathdir").to_str().unwrap())
+    .env("JUST_UNSTABLE", "1")
+    .args(["--evaluate", "p"])
+    .stdout(abspath.display().to_string())
+    .run();
+}
+
+#[test]
+fn handles_dotslash() {
+  let tmp = tempdir();
+
+  let path = if cfg!(windows) {
+    tmp.path().into()
+  } else {
+    // canonicalize() is necessary here to account for the justfile prepending
+    // the canonicalized working directory to 'subdir/foo.exe'.
+    tmp.path().canonicalize().unwrap()
+  };
+
+  Test::with_tempdir(tmp)
+    .justfile("p := which('./foo.exe')")
+    .args(["--evaluate", "p"])
+    .write("foo.exe", HELLO_SCRIPT)
+    .make_executable("foo.exe")
+    .write("pathdir/foo.exe", HELLO_SCRIPT)
+    .make_executable("pathdir/foo.exe")
+    .env("PATH", path.join("pathdir").to_str().unwrap())
+    .env("JUST_UNSTABLE", "1")
+    .stdout(path.join("foo.exe").display().to_string())
+    .run();
+}
+
+#[test]
+fn handles_dir_slash() {
+  let tmp = tempdir();
+
+  let path = if cfg!(windows) {
+    tmp.path().into()
+  } else {
+    // canonicalize() is necessary here to account for the justfile prepending
+    // the canonicalized working directory to 'subdir/foo.exe'.
+    tmp.path().canonicalize().unwrap()
+  };
+
+  Test::with_tempdir(tmp)
+    .justfile("p := which('subdir/foo.exe')")
+    .args(["--evaluate", "p"])
+    .write("subdir/foo.exe", HELLO_SCRIPT)
+    .make_executable("subdir/foo.exe")
+    .write("pathdir/foo.exe", HELLO_SCRIPT)
+    .make_executable("pathdir/foo.exe")
+    .env("PATH", path.join("pathdir").to_str().unwrap())
+    .env("JUST_UNSTABLE", "1")
+    .stdout(path.join("subdir").join("foo.exe").display().to_string())
+    .run();
+}
+
+#[test]
+fn is_unstable() {
+  let tmp = tempdir();
+  let path = PathBuf::from(tmp.path());
+
+  Test::with_tempdir(tmp)
+    .justfile("p := which('hello.exe')")
+    .args(["--evaluate", "p"])
+    .write("hello.exe", HELLO_SCRIPT)
+    .make_executable("hello.exe")
+    .env("PATH", path.to_str().unwrap())
+    .stderr_regex(r".*The `which\(\)` function is currently unstable\..*")
+    .status(1)
+    .run();
+}