Repository: casey/just
Pull request #2594 (2025-01-22 19:53:56)

Move `which` function


Patch:
diff --git a/src/function.rs b/src/function.rs
index d5b9f192..16468891 100644
--- a/src/function.rs
+++ b/src/function.rs
@@ -513,13 +513,8 @@ 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 require(context: Context, name: &str) -> FunctionResult {
+  crate::which(context, name)?.ok_or_else(|| format!("could not find executable `{name}`"))
 }
 
 fn replace_regex(_context: Context, s: &str, regex: &str, replacement: &str) -> FunctionResult {
@@ -672,50 +667,8 @@ 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 which(context: Context, name: &str) -> FunctionResult {
+  Ok(crate::which(context, name)?.unwrap_or_default())
 }
 
 fn without_extension(_context: Context, path: &str) -> FunctionResult {
diff --git a/src/lib.rs b/src/lib.rs
index 30777e51..6820a3c7 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -97,6 +97,7 @@ pub(crate) use {
     variables::Variables,
     verbosity::Verbosity,
     warning::Warning,
+    which::which,
   },
   camino::Utf8Path,
   clap::ValueEnum,
@@ -273,3 +274,4 @@ mod use_color;
 mod variables;
 mod verbosity;
 mod warning;
+mod which;
diff --git a/src/which.rs b/src/which.rs
new file mode 100644
index 00000000..1a1e4de0
--- /dev/null
+++ b/src/which.rs
@@ -0,0 +1,48 @@
+use super::*;
+
+pub(crate) fn which(context: function::Context, name: &str) -> Result<Option<String>, String> {
+  let name = Path::new(name);
+
+  let candidates = match name.components().count() {
+    0 => return Err("empty command".into()),
+    1 => {
+      // cmd is a regular command
+      env::split_paths(&env::var_os("PATH").ok_or("`PATH` environment variable not set")?)
+        .map(|path| path.join(name))
+        .collect()
+    }
+    _ => {
+      // cmd contains a path separator, treat it as a path
+      vec![name.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(|candidate| Some(candidate.into()))
+        .ok_or_else(|| {
+          format!(
+            "Executable path is not valid unicode: {}",
+            candidate.display()
+          )
+        });
+    }
+  }
+
+  Ok(None)
+}


Test patch:
diff --git a/tests/which_function.rs b/tests/which_function.rs
index 754075e4..31993fc4 100644
--- a/tests/which_function.rs
+++ b/tests/which_function.rs
@@ -231,6 +231,39 @@ fn is_unstable() {
     .make_executable("hello.exe")
     .env("PATH", path.to_str().unwrap())
     .stderr_regex(r".*The `which\(\)` function is currently unstable\..*")
-    .status(1)
+    .status(EXIT_FAILURE)
+    .run();
+}
+
+#[test]
+fn require_error() {
+  Test::new()
+    .justfile("p := require('asdfasdf')")
+    .args(["--evaluate", "p"])
+    .stderr(
+      "
+        error: Call to function `require` failed: could not find executable `asdfasdf`
+         ——▶ justfile:1:6
+          │
+        1 │ p := require('asdfasdf')
+          │      ^^^^^^^
+      ",
+    )
+    .status(EXIT_FAILURE)
+    .run();
+}
+
+#[test]
+fn require_success() {
+  let tmp = tempdir();
+  let path = PathBuf::from(tmp.path());
+
+  Test::with_tempdir(tmp)
+    .justfile("p := require('hello.exe')")
+    .args(["--evaluate", "p"])
+    .write("hello.exe", HELLO_SCRIPT)
+    .make_executable("hello.exe")
+    .env("PATH", path.to_str().unwrap())
+    .stdout(path.join("hello.exe").display().to_string())
     .run();
 }