diff --git a/Cargo.lock b/Cargo.lock index 80a7276d..1ba1463b 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 f74367d0..bda81c5e 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" diff --git a/README.md b/README.md index 5b596ca8..8308b989 100644 --- a/README.md +++ b/README.md @@ -1632,6 +1632,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)`master — 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/justfile b/justfile index d0ff455c..cf657782 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 a714a8d0..25c328b9 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,6 +111,7 @@ pub(crate) fn get(name: &str) -> Option { "uppercase" => Unary(uppercase), "uuid" => Nullary(uuid), "without_extension" => Unary(without_extension), + "which" => Unary(which), _ => return None, }; Some(function) @@ -667,6 +669,61 @@ fn uuid(_context: Context) -> FunctionResult { Ok(uuid::Uuid::new_v4().to_string()) } +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 { let parent = Utf8Path::new(path) .parent() diff --git a/tests/lib.rs b/tests/lib.rs index 7c85460b..dfa35652 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -111,6 +111,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 00000000..1b685e96 --- /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(); +}