-
Notifications
You must be signed in to change notification settings - Fork 498
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
base: master
Are you sure you want to change the base?
Changes from all commits
6fb2400
f1980b5
0c6f5e8
389b2ae
2a535c0
34f2ea6
740c0ae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) | ||
|
@@ -661,6 +662,61 @@ fn uuid(_context: Context) -> FunctionResult { | |
Ok(uuid::Uuid::new_v4().to_string()) | ||
} | ||
|
||
fn which(context: Context, s: &str) -> FunctionResult { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
|
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
|
@@ -119,6 +119,7 @@ mod timestamps; | |||||||
mod undefined_variables; | ||||||||
mod unexport; | ||||||||
mod unstable; | ||||||||
mod which_exec; | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unfortunately this doesn't work because of the name conflict with the Line 28 in 4f31853
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ahh, gotcha There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm happy to rename it from There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How about |
||||||||
#[cfg(windows)] | ||||||||
mod windows; | ||||||||
#[cfg(target_family = "windows")] | ||||||||
|
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! { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I try to avoid I try to use 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")"#) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can avoid needing
Suggested change
|
||||||
.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(); | ||||||
} |
There was a problem hiding this comment.
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: