Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add which function for finding executables in PATH #2440

Merged
merged 34 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
6fb2400
Add which function for finding executables in PATH
0xzhzh Oct 25, 2024
f1980b5
Change version of which function to master branch
0xzhzh Nov 4, 2024
0c6f5e8
Use internal implementation of which()
0xzhzh Nov 4, 2024
389b2ae
Add tests for internal implementation of which()
0xzhzh Dec 1, 2024
2a535c0
Remove stray addition to justfile
0xzhzh Dec 1, 2024
34f2ea6
Remove dependency on either
0xzhzh Dec 13, 2024
740c0ae
Merge remote-tracking branch 'origin/master' into add-which
casey Dec 20, 2024
c89e182
Handle empty command string up front
0xzhzh Dec 28, 2024
aad3831
Resolve relative paths relative to working directory
0xzhzh Dec 28, 2024
5aa3c07
Clean up implementation of which()
0xzhzh Dec 30, 2024
e7e9d56
Rename which_exec -> which_function
0xzhzh Dec 30, 2024
6a98c39
Use single quotes to avoid r# strings
0xzhzh Dec 30, 2024
1f40ec3
Remove use of temptree! macro
0xzhzh Dec 30, 2024
d642b1d
Add some tests for relative paths
0xzhzh Dec 30, 2024
ea958df
Merge branch 'master' into add-which
0xzhzh Dec 30, 2024
f967dc6
Add lexiclean to which path
0xzhzh Jan 12, 2025
5eaf570
Make HELLO_SCRIPT constant
0xzhzh Jan 12, 2025
1a3ddf4
Merge remote-tracking branch 'origin/master' into add-which
casey Jan 16, 2025
775d769
Sort function list alphabetically
casey Jan 16, 2025
941a5b9
Avoid import
casey Jan 16, 2025
a7fe156
Remove greeting
0xzhzh Jan 17, 2025
0beadec
Add require
0xzhzh Jan 17, 2025
f5c5809
Make which() function unstable
0xzhzh Jan 17, 2025
2ac4966
Merge remote-tracking branch 'origin/master' into add-which
casey Jan 22, 2025
a8e8ea4
Format
0xzhzh Jan 22, 2025
87a315b
Fix tests + add unstable test
0xzhzh Jan 22, 2025
f4501dd
Add documentation
0xzhzh Jan 22, 2025
e63fb7d
Merge remote-tracking branch 'origin/master' into add-which
casey Jan 22, 2025
2e17660
Remove a few unnecessary uses of format!()
casey Jan 22, 2025
86753cb
Expected stdout defaults to empty string
casey Jan 22, 2025
42c4e5a
Change readme section
casey Jan 22, 2025
6ccf803
Adapt
casey Jan 22, 2025
1f9abb5
Fix Windows test
casey Jan 22, 2025
2f40780
Try to fix Windows tests
casey Jan 22, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1597,6 +1597,22 @@ $ just
name `key`, returning `default` if it is not present.
- `env(key)`<sup>1.15.0</sup> — Alias for `env_var(key)`.
- `env(key, default)`<sup>1.15.0</sup> — Alias for `env_var_or_default(key, default)`.
- `which(exe)`<sup>master</sup> — 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

Expand Down
57 changes: 57 additions & 0 deletions src/function.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use {
super::*,
either::Either,
heck::{
ToKebabCase, ToLowerCamelCase, ToShoutyKebabCase, ToShoutySnakeCase, ToSnakeCase, ToTitleCase,
ToUpperCamelCase,
Expand Down Expand Up @@ -110,6 +111,7 @@ pub(crate) fn get(name: &str) -> Option<Function> {
"uppercase" => Unary(uppercase),
"uuid" => Nullary(uuid),
"without_extension" => Unary(without_extension),
"which" => Unary(which),
_ => return None,
};
Some(function)
Expand Down Expand Up @@ -667,6 +669,61 @@ fn uuid(_context: Context) -> FunctionResult {
Ok(uuid::Uuid::new_v4().to_string())
}

fn which(context: Context, s: &str) -> FunctionResult {
0xzhzh marked this conversation as resolved.
Show resolved Hide resolved
casey marked this conversation as resolved.
Show resolved Hide resolved
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())))
0xzhzh marked this conversation as resolved.
Show resolved Hide resolved
}
_ => {
// 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
0xzhzh marked this conversation as resolved.
Show resolved Hide resolved
.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()
Expand Down
1 change: 1 addition & 0 deletions tests/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ mod timestamps;
mod undefined_variables;
mod unexport;
mod unstable;
mod which_exec;
0xzhzh marked this conversation as resolved.
Show resolved Hide resolved
#[cfg(windows)]
mod windows;
#[cfg(target_family = "windows")]
Expand Down
170 changes: 170 additions & 0 deletions tests/which_exec.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
use super::*;

trait TempDirExt {
fn executable(self, file: impl AsRef<Path>) -> Self;
}

impl TempDirExt for TempDir {
fn executable(self, file: impl AsRef<Path>) -> Self {
let file = self.path().join(file.as_ref());

// Make sure it exists first, as a sanity check.
assert!(
file.exists(),
"executable 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(not(windows))]
{
let perms = std::os::unix::fs::PermissionsExt::from_mode(0o755);
fs::set_permissions(file, perms).unwrap();
}

self
}
}

#[test]
fn finds_executable() {
let tmp = temptree! {
0xzhzh marked this conversation as resolved.
Show resolved Hide resolved
"hello.exe": "#!/usr/bin/env bash\necho hello\n",
}
.executable("hello.exe");

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 = temptree! {
"hello.exe": "#!/usr/bin/env bash\necho hello\n",
}
.executable("hello.exe");

Test::new()
.justfile(r#"p := which("goodbye.exe")"#)
.env("PATH", tmp.path().to_str().unwrap())
.args(["--evaluate", "p"])
.stdout("")
.run();
}

#[test]
fn skips_non_executable_files() {
let tmp = temptree! {
"hello.exe": "#!/usr/bin/env bash\necho hello\n",
"hi": "just some regular file",
}
.executable("hello.exe");

Test::new()
.justfile(r#"p := which("hi")"#)
0xzhzh marked this conversation as resolved.
Show resolved Hide resolved
.env("PATH", tmp.path().to_str().unwrap())
.args(["--evaluate", "p"])
.stdout("")
.run();
}

#[test]
fn supports_multiple_paths() {
let tmp1 = temptree! {
"hello1.exe": "#!/usr/bin/env bash\necho hello\n",
}
.executable("hello1.exe");

let tmp2 = temptree! {
"hello2.exe": "#!/usr/bin/env bash\necho hello\n",
}
.executable("hello2.exe");

let path =
env::join_paths([tmp1.path().to_str().unwrap(), tmp2.path().to_str().unwrap()]).unwrap();

Test::new()
.justfile(r#"p := which("hello1.exe")"#)
.env("PATH", path.to_str().unwrap())
.args(["--evaluate", "p"])
.stdout(format!("{}", tmp1.path().join("hello1.exe").display()))
.run();

Test::new()
.justfile(r#"p := which("hello2.exe")"#)
.env("PATH", path.to_str().unwrap())
.args(["--evaluate", "p"])
.stdout(format!("{}", tmp2.path().join("hello2.exe").display()))
.run();
}

#[test]
fn supports_shadowed_executables() {
let tmp1 = temptree! {
"shadowed.exe": "#!/usr/bin/env bash\necho hello\n",
}
.executable("shadowed.exe");

let tmp2 = temptree! {
"shadowed.exe": "#!/usr/bin/env bash\necho hello\n",
}
.executable("shadowed.exe");

// which should never resolve to this directory, no matter where or how many
// times it appears in PATH, because the "shadowed" file is not executable.
let dummy = if cfg!(windows) {
temptree! {
"shadowed": "#!/usr/bin/env bash\necho hello\n",
}
} else {
temptree! {
"shadowed.exe": "#!/usr/bin/env bash\necho hello\n",
}
};

// This PATH should give priority to tmp1/shadowed.exe
let tmp1_path = env::join_paths([
dummy.path().to_str().unwrap(),
tmp1.path().to_str().unwrap(),
dummy.path().to_str().unwrap(),
tmp2.path().to_str().unwrap(),
dummy.path().to_str().unwrap(),
])
.unwrap();

// This PATH should give priority to tmp2/shadowed.exe
let tmp2_path = env::join_paths([
dummy.path().to_str().unwrap(),
tmp2.path().to_str().unwrap(),
dummy.path().to_str().unwrap(),
tmp1.path().to_str().unwrap(),
dummy.path().to_str().unwrap(),
])
.unwrap();

Test::new()
.justfile(r#"p := which("shadowed.exe")"#)
.env("PATH", tmp1_path.to_str().unwrap())
.args(["--evaluate", "p"])
.stdout(format!("{}", tmp1.path().join("shadowed.exe").display()))
.run();

Test::new()
.justfile(r#"p := which("shadowed.exe")"#)
.env("PATH", tmp2_path.to_str().unwrap())
.args(["--evaluate", "p"])
.stdout(format!("{}", tmp2.path().join("shadowed.exe").display()))
.run();
}
Loading