Skip to content

Commit

Permalink
Add which() and require() for finding executables (#2440)
Browse files Browse the repository at this point in the history
  • Loading branch information
0xzhzh authored Jan 22, 2025
1 parent 7720923 commit 398eb29
Show file tree
Hide file tree
Showing 9 changed files with 373 additions and 0 deletions.
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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1674,6 +1674,43 @@ set unstable
foo := env('FOO') || 'DEFAULT_VALUE'
```

#### Executables

- `require(name)`<sup>master</sup> — 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)`<sup>master</sup> — 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
Expand Down
57 changes: 57 additions & 0 deletions src/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ pub(crate) fn get(name: &str) -> Option<Function> {
"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),
Expand All @@ -111,6 +112,7 @@ pub(crate) fn get(name: &str) -> Option<Function> {
"uppercamelcase" => Unary(uppercamelcase),
"uppercase" => Unary(uppercase),
"uuid" => Nullary(uuid),
"which" => Unary(which),
"without_extension" => Unary(without_extension),
_ => return None,
};
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?,
})
Expand Down
2 changes: 2 additions & 0 deletions src/unstable_feature.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub(crate) enum UnstableFeature {
LogicalOperators,
ScriptAttribute,
ScriptInterpreterSetting,
WhichFunction,
}

impl Display for UnstableFeature {
Expand All @@ -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."),
}
}
}
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_function;
#[cfg(windows)]
mod windows;
#[cfg(target_family = "windows")]
Expand Down
24 changes: 24 additions & 0 deletions tests/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,30 @@ impl Test {
self
}

pub(crate) fn make_executable(self, path: impl AsRef<Path>) -> 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<Path>, content: impl AsRef<[u8]>) -> Self {
let path = path.as_ref();
self
Expand Down
Loading

0 comments on commit 398eb29

Please sign in to comment.