Skip to content

Commit 398eb29

Browse files
authored
Add which() and require() for finding executables (#2440)
1 parent 7720923 commit 398eb29

9 files changed

+373
-0
lines changed

Diff for: Cargo.lock

+10
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ dirs = "5.0.1"
3131
dotenvy = "0.15"
3232
edit-distance = "2.0.0"
3333
heck = "0.5.0"
34+
is_executable = "1.0.4"
3435
lexiclean = "0.0.1"
3536
libc = "0.2.0"
3637
num_cpus = "1.15.0"

Diff for: README.md

+37
Original file line numberDiff line numberDiff line change
@@ -1674,6 +1674,43 @@ set unstable
16741674
foo := env('FOO') || 'DEFAULT_VALUE'
16751675
```
16761676

1677+
#### Executables
1678+
1679+
- `require(name)`<sup>master</sup> — Search directories in the `PATH`
1680+
environment variable for the executable `name` and return its full path, or
1681+
halt with an error if no executable with `name` exists.
1682+
1683+
```just
1684+
bash := require("bash")
1685+
1686+
@test:
1687+
echo "bash: '{{bash}}'"
1688+
```
1689+
1690+
```console
1691+
$ just
1692+
bash: '/bin/bash'
1693+
```
1694+
1695+
- `which(name)`<sup>master</sup> — Search directories in the `PATH` environment
1696+
variable for the executable `name` and return its full path, or the empty
1697+
string if no executable with `name` exists. Currently unstable.
1698+
1699+
1700+
```just
1701+
set unstable
1702+
1703+
bosh := require("bosh")
1704+
1705+
@test:
1706+
echo "bosh: '{{bosh}}'"
1707+
```
1708+
1709+
```console
1710+
$ just
1711+
bosh: ''
1712+
```
1713+
16771714
#### Invocation Information
16781715

16791716
- `is_dependency()` - Returns the string `true` if the current recipe is being

Diff for: src/function.rs

+57
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ pub(crate) fn get(name: &str) -> Option<Function> {
9090
"read" => Unary(read),
9191
"replace" => Ternary(replace),
9292
"replace_regex" => Ternary(replace_regex),
93+
"require" => Unary(require),
9394
"semver_matches" => Binary(semver_matches),
9495
"sha256" => Unary(sha256),
9596
"sha256_file" => Unary(sha256_file),
@@ -111,6 +112,7 @@ pub(crate) fn get(name: &str) -> Option<Function> {
111112
"uppercamelcase" => Unary(uppercamelcase),
112113
"uppercase" => Unary(uppercase),
113114
"uuid" => Nullary(uuid),
115+
"which" => Unary(which),
114116
"without_extension" => Unary(without_extension),
115117
_ => return None,
116118
};
@@ -511,6 +513,15 @@ fn replace(_context: Context, s: &str, from: &str, to: &str) -> FunctionResult {
511513
Ok(s.replace(from, to))
512514
}
513515

516+
fn require(context: Context, s: &str) -> FunctionResult {
517+
let p = which(context, s)?;
518+
if p.is_empty() {
519+
Err(format!("could not find required executable: `{s}`"))
520+
} else {
521+
Ok(p)
522+
}
523+
}
524+
514525
fn replace_regex(_context: Context, s: &str, regex: &str, replacement: &str) -> FunctionResult {
515526
Ok(
516527
Regex::new(regex)
@@ -661,6 +672,52 @@ fn uuid(_context: Context) -> FunctionResult {
661672
Ok(uuid::Uuid::new_v4().to_string())
662673
}
663674

675+
fn which(context: Context, s: &str) -> FunctionResult {
676+
let cmd = Path::new(s);
677+
678+
let candidates = match cmd.components().count() {
679+
0 => return Err("empty command".into()),
680+
1 => {
681+
// cmd is a regular command
682+
let path_var = env::var_os("PATH").ok_or("Environment variable `PATH` is not set")?;
683+
env::split_paths(&path_var)
684+
.map(|path| path.join(cmd))
685+
.collect()
686+
}
687+
_ => {
688+
// cmd contains a path separator, treat it as a path
689+
vec![cmd.into()]
690+
}
691+
};
692+
693+
for mut candidate in candidates {
694+
if candidate.is_relative() {
695+
// This candidate is a relative path, either because the user invoked `which("rel/path")`,
696+
// or because there was a relative path in `PATH`. Resolve it to an absolute path,
697+
// relative to the working directory of the just invocation.
698+
candidate = context
699+
.evaluator
700+
.context
701+
.working_directory()
702+
.join(candidate);
703+
}
704+
705+
candidate = candidate.lexiclean();
706+
707+
if is_executable::is_executable(&candidate) {
708+
return candidate.to_str().map(str::to_string).ok_or_else(|| {
709+
format!(
710+
"Executable path is not valid unicode: {}",
711+
candidate.display()
712+
)
713+
});
714+
}
715+
}
716+
717+
// No viable candidates; return an empty string
718+
Ok(String::new())
719+
}
720+
664721
fn without_extension(_context: Context, path: &str) -> FunctionResult {
665722
let parent = Utf8Path::new(path)
666723
.parent()

Diff for: src/parser.rs

+5
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,11 @@ impl<'run, 'src> Parser<'run, 'src> {
698698

699699
if self.next_is(ParenL) {
700700
let arguments = self.parse_sequence()?;
701+
if name.lexeme() == "which" {
702+
self
703+
.unstable_features
704+
.insert(UnstableFeature::WhichFunction);
705+
}
701706
Ok(Expression::Call {
702707
thunk: Thunk::resolve(name, arguments)?,
703708
})

Diff for: src/unstable_feature.rs

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub(crate) enum UnstableFeature {
66
LogicalOperators,
77
ScriptAttribute,
88
ScriptInterpreterSetting,
9+
WhichFunction,
910
}
1011

1112
impl Display for UnstableFeature {
@@ -20,6 +21,7 @@ impl Display for UnstableFeature {
2021
Self::ScriptInterpreterSetting => {
2122
write!(f, "The `script-interpreter` setting is currently unstable.")
2223
}
24+
Self::WhichFunction => write!(f, "The `which()` function is currently unstable."),
2325
}
2426
}
2527
}

Diff for: tests/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ mod timestamps;
119119
mod undefined_variables;
120120
mod unexport;
121121
mod unstable;
122+
mod which_function;
122123
#[cfg(windows)]
123124
mod windows;
124125
#[cfg(target_family = "windows")]

Diff for: tests/test.rs

+24
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,30 @@ impl Test {
205205
self
206206
}
207207

208+
pub(crate) fn make_executable(self, path: impl AsRef<Path>) -> Self {
209+
let file = self.tempdir.path().join(path);
210+
211+
// Make sure it exists first, as a sanity check.
212+
assert!(file.exists(), "file does not exist: {}", file.display());
213+
214+
// Windows uses file extensions to determine whether a file is executable.
215+
// Other systems don't care. To keep these tests cross-platform, just make
216+
// sure all executables end with ".exe" suffix.
217+
assert!(
218+
file.extension() == Some("exe".as_ref()),
219+
"executable file does not end with .exe: {}",
220+
file.display()
221+
);
222+
223+
#[cfg(unix)]
224+
{
225+
let perms = std::os::unix::fs::PermissionsExt::from_mode(0o755);
226+
fs::set_permissions(file, perms).unwrap();
227+
}
228+
229+
self
230+
}
231+
208232
pub(crate) fn expect_file(mut self, path: impl AsRef<Path>, content: impl AsRef<[u8]>) -> Self {
209233
let path = path.as_ref();
210234
self

0 commit comments

Comments
 (0)