Skip to content

Commit

Permalink
feat: expose lightweight API for resolving a command path (#121)
Browse files Browse the repository at this point in the history
  • Loading branch information
dsherret committed Sep 1, 2024
1 parent c663919 commit a99f6b6
Show file tree
Hide file tree
Showing 4 changed files with 185 additions and 168 deletions.
177 changes: 11 additions & 166 deletions src/shell/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ use anyhow::Result;
use futures::FutureExt;
use thiserror::Error;

use super::which::CommandPathResolutionError;

#[derive(Debug, Clone)]
pub struct UnresolvedCommandName {
pub name: String,
Expand Down Expand Up @@ -78,15 +80,15 @@ struct ResolvedCommand<'a> {
#[derive(Error, Debug)]
enum ResolveCommandError {
#[error(transparent)]
CommandPath(#[from] ResolveCommandPathError),
CommandPath(#[from] CommandPathResolutionError),
#[error(transparent)]
FailedShebang(#[from] FailedShebangError),
}

#[derive(Error, Debug)]
enum FailedShebangError {
#[error(transparent)]
CommandPath(#[from] ResolveCommandPathError),
CommandPath(#[from] CommandPathResolutionError),
#[error(transparent)]
Any(#[from] anyhow::Error),
}
Expand Down Expand Up @@ -201,127 +203,17 @@ async fn parse_shebang_args(
)
}

/// Errors for executable commands.
#[derive(Error, Debug, PartialEq)]
pub enum ResolveCommandPathError {
#[error("{}: command not found", .0)]
CommandNotFound(String),
#[error("command name was empty")]
CommandEmpty,
}

impl ResolveCommandPathError {
pub fn exit_code(&self) -> i32 {
match self {
// Use the Exit status that is used in bash: https://www.gnu.org/software/bash/manual/bash.html#Exit-Status
ResolveCommandPathError::CommandNotFound(_) => 127,
ResolveCommandPathError::CommandEmpty => 1,
}
}
}

pub fn resolve_command_path(
command_name: &str,
base_dir: &Path,
state: &ShellState,
) -> Result<PathBuf, ResolveCommandPathError> {
resolve_command_path_inner(command_name, base_dir, state, || {
Ok(std::env::current_exe()?)
})
}

fn resolve_command_path_inner(
command_name: &str,
base_dir: &Path,
state: &ShellState,
current_exe: impl FnOnce() -> Result<PathBuf>,
) -> Result<PathBuf, ResolveCommandPathError> {
if command_name.is_empty() {
return Err(ResolveCommandPathError::CommandEmpty);
}

// Special handling to use the current executable for deno.
// This is to ensure deno tasks that use deno work in environments
// that don't have deno on the path and to ensure it use the current
// version of deno being executed rather than the one on the path,
// which has caused some confusion.
if command_name == "deno" {
if let Ok(exe_path) = current_exe() {
// this condition exists to make the tests pass because it's not
// using the deno as the current executable
let file_stem = exe_path.file_stem().map(|s| s.to_string_lossy());
if file_stem.map(|s| s.to_string()) == Some("deno".to_string()) {
return Ok(exe_path);
}
}
}

// check for absolute
if PathBuf::from(command_name).is_absolute() {
return Ok(PathBuf::from(command_name));
}

// then relative
if command_name.contains('/')
|| (cfg!(windows) && command_name.contains('\\'))
{
return Ok(base_dir.join(command_name));
}

// now search based on the current environment state
let mut search_dirs = vec![base_dir.to_path_buf()];
if let Some(path) = state.get_var("PATH") {
for folder in path.split(if cfg!(windows) { ';' } else { ':' }) {
search_dirs.push(PathBuf::from(folder));
}
}
let path_exts = if cfg!(windows) {
let uc_command_name = command_name.to_uppercase();
let path_ext = state
.get_var("PATHEXT")
.map(|s| s.as_str())
.unwrap_or(".EXE;.CMD;.BAT;.COM");
let command_exts = path_ext
.split(';')
.map(|s| s.trim().to_uppercase())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>();
if command_exts.is_empty()
|| command_exts
.iter()
.any(|ext| uc_command_name.ends_with(ext))
{
None // use the command name as-is
} else {
Some(command_exts)
}
} else {
None
};

for search_dir in search_dirs {
let paths = if let Some(path_exts) = &path_exts {
let mut paths = Vec::new();
for path_ext in path_exts {
paths.push(search_dir.join(format!("{command_name}{path_ext}")))
}
paths
} else {
vec![search_dir.join(command_name)]
};
for path in paths {
// don't use tokio::fs::metadata here as it was never returning
// in some circumstances for some reason
if let Ok(metadata) = std::fs::metadata(&path) {
if metadata.is_file() {
return Ok(path);
}
}
}
}
Err(ResolveCommandPathError::CommandNotFound(
command_name.to_string(),
))
) -> Result<PathBuf, CommandPathResolutionError> {
super::which::resolve_command_path(
command_name,
base_dir,
|name| state.get_var(name).map(|s| Cow::Borrowed(s.as_str())),
std::env::current_exe,
)
}

struct Shebang {
Expand Down Expand Up @@ -366,50 +258,3 @@ fn resolve_shebang(
}
}))
}

#[cfg(test)]
mod local_test {
use super::*;

#[test]
fn should_resolve_current_exe_path_for_deno() {
let cwd = std::env::current_dir().unwrap();
let state = ShellState::new(
Default::default(),
&std::env::current_dir().unwrap(),
Default::default(),
);
let path = resolve_command_path_inner("deno", &cwd, &state, || {
Ok(PathBuf::from("/bin/deno"))
})
.unwrap();
assert_eq!(path, PathBuf::from("/bin/deno"));

let path = resolve_command_path_inner("deno", &cwd, &state, || {
Ok(PathBuf::from("/bin/deno.exe"))
})
.unwrap();
assert_eq!(path, PathBuf::from("/bin/deno.exe"));
}

#[test]
fn should_error_on_unknown_command() {
let cwd = std::env::current_dir().unwrap();
let state = ShellState::new(Default::default(), &cwd, Default::default());
// Command not found
let result = resolve_command_path_inner("foobar", &cwd, &state, || {
Ok(PathBuf::from("/bin/deno"))
});
assert_eq!(
result,
Err(ResolveCommandPathError::CommandNotFound(
"foobar".to_string()
))
);
// Command empty
let result = resolve_command_path_inner("", &cwd, &state, || {
Ok(PathBuf::from("/bin/deno"))
});
assert_eq!(result, Err(ResolveCommandPathError::CommandEmpty));
}
}
2 changes: 1 addition & 1 deletion src/shell/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// Copyright 2018-2024 the Deno authors. MIT license.

pub use command::ResolveCommandPathError;
pub use commands::ExecutableCommand;
pub use commands::ExecuteCommandArgsContext;
pub use commands::ShellCommand;
Expand All @@ -20,6 +19,7 @@ mod commands;
mod execute;
mod fs_util;
mod types;
pub mod which;

#[cfg(test)]
mod test;
Expand Down
2 changes: 1 addition & 1 deletion src/shell/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ impl ShellState {
pub fn resolve_command_path(
&self,
command_name: &str,
) -> Result<PathBuf, crate::ResolveCommandPathError> {
) -> Result<PathBuf, crate::which::CommandPathResolutionError> {
super::command::resolve_command_path(command_name, self.cwd(), self)
}

Expand Down
Loading

0 comments on commit a99f6b6

Please sign in to comment.