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(); +}