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 5fd19ac992..d8f7d42519 100644
--- a/README.md
+++ b/README.md
@@ -1674,6 +1674,43 @@ set unstable
foo := env('FOO') || 'DEFAULT_VALUE'
```
+#### Executables
+
+- `require(name)`master — 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)`master — 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 66e7c6e2dd..d5b9f192bc 100644
--- a/src/function.rs
+++ b/src/function.rs
@@ -90,6 +90,7 @@ pub(crate) fn get(name: &str) -> Option {
"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 {
"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 42e9f26cca..9305a42db5 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 70e26fabcf..daa2270b1e 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."),
}
}
}
diff --git a/tests/lib.rs b/tests/lib.rs
index a86a3da769..04133b9f7b 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 e02b5a85b6..78c4e6e5df 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) -> 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, 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 0000000000..754075e4c2
--- /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();
+}