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

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1664,6 +1664,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
56 changes: 56 additions & 0 deletions src/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,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 @@ -661,6 +662,61 @@ fn uuid(_context: Context) -> FunctionResult {
Ok(uuid::Uuid::new_v4().to_string())
}

fn which(context: Context, s: &str) -> FunctionResult {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's handle the empty case up-front:

if s.is_empty() {
  return Err("empty command".into());
}

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about this version? I found the original logic a little hard to follow:

(Also included some comments which don't need to go into the final PR.)

fn which(context: Context, command: &str) -> FunctionResult {
  use std::path::Component;

  let command = Path::new(command);

  let relative = match command.components().next() {
    None => return Err("empty command".into()),
    // Any path that starts with `.` or `..` can't be joined to elements of `$PATH` and should be considered on its own. (Is this actually true? What about `C:foo` on windows? Is that a thing?
    Some(Component::CurDir) | Some(Component::ParentDir) => vec![command.into()],
    _ => {
      let paths = env::var_os("PATH").ok_or("`PATH` environment variable not set")?;

      env::split_paths(&paths)
        .map(|path| path.join(command))
        .collect()
    }
  };

  let working_directory = context.evaluator.context.working_directory();

  let absolute = relative
    .into_iter()
    // note that an path.join(absolute_path) winds up being absolute_path
    // lexiclean is hear to remove unnecessary `.` and `..`
    .map(|relative| working_directory.join(relative).lexiclean())
    .collect::<Vec<PathBuf>>();

  for candidate in absolute {
    if is_executable::is_executable(&candidate) {
      return candidate
        .to_str()
        .map(str::to_string)
        .ok_or_else(|| format!("Executable path not unicode: {}", candidate.display()));
    }
  }

  Ok(String::new())
}

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")?;
env::split_paths(&path_var).map(|path| path.join(cmd.clone())).collect()
}
_ => {
// cmd contains a path separator, treat it as a path
vec![cmd]
}
};

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.
let cwd = context
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think relative paths should be joined with the working directory, which you can get with:

context.evaluator.context.working_directory()

.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 @@ -119,6 +119,7 @@ mod timestamps;
mod undefined_variables;
mod unexport;
mod unstable;
mod which_exec;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
mod which_exec;
mod which;

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately this doesn't work because of the name conflict with the which crate, which is imported and used in this test suite:

which::which,

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh, gotcha

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm happy to rename it from which_exec to something, e.g., which_fn, if you prefer. I chose which_exec to evoke something like "which_executable is cmd"

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about which_function?

#[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! {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I try to avoid temptree! these days. I wrote it, but I think it's overcomplicated.

I try to use Test::write, to create individual files in the test tempdir.

Can this be expressed with something like:

  Test::new()
    .justfile(r#"p := which("hello.exe")"#)
    .write("subdir/hello.exe": "#!/usr/bin/env bash\necho hello\n")
    .make_executable("subdir/hello.exe")
    .env("PATH", "subdir")
    .args(["--evaluate", "p"])
    .stdout(format!("{}", tmp.path().join("hello.exe").display()))
    .run();

"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")"#)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can avoid needing r#"…"# strings if you use single quotes:

Suggested change
.justfile(r#"p := which("hi")"#)
.justfile("p := which('hi')")

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