diff --git a/src/shell/command.rs b/src/shell/command.rs index 9ca8a5b..003cc1e 100644 --- a/src/shell/command.rs +++ b/src/shell/command.rs @@ -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, @@ -78,7 +80,7 @@ struct ResolvedCommand<'a> { #[derive(Error, Debug)] enum ResolveCommandError { #[error(transparent)] - CommandPath(#[from] ResolveCommandPathError), + CommandPath(#[from] CommandPathResolutionError), #[error(transparent)] FailedShebang(#[from] FailedShebangError), } @@ -86,7 +88,7 @@ enum ResolveCommandError { #[derive(Error, Debug)] enum FailedShebangError { #[error(transparent)] - CommandPath(#[from] ResolveCommandPathError), + CommandPath(#[from] CommandPathResolutionError), #[error(transparent)] Any(#[from] anyhow::Error), } @@ -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 { - 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, -) -> Result { - 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::>(); - 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 { + 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 { @@ -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)); - } -} diff --git a/src/shell/mod.rs b/src/shell/mod.rs index 2d4b53f..cd7f3ce 100644 --- a/src/shell/mod.rs +++ b/src/shell/mod.rs @@ -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; @@ -20,6 +19,7 @@ mod commands; mod execute; mod fs_util; mod types; +pub mod which; #[cfg(test)] mod test; diff --git a/src/shell/types.rs b/src/shell/types.rs index f78ba66..5633bdf 100644 --- a/src/shell/types.rs +++ b/src/shell/types.rs @@ -150,7 +150,7 @@ impl ShellState { pub fn resolve_command_path( &self, command_name: &str, - ) -> Result { + ) -> Result { super::command::resolve_command_path(command_name, self.cwd(), self) } diff --git a/src/shell/which.rs b/src/shell/which.rs new file mode 100644 index 0000000..f7f1f71 --- /dev/null +++ b/src/shell/which.rs @@ -0,0 +1,172 @@ +// Copyright 2018-2024 the Deno authors. MIT license. + +use std::borrow::Cow; +use std::path::Path; +use std::path::PathBuf; + +use thiserror::Error; + +/// Error when a command path could not be resolved. +#[derive(Error, Debug, PartialEq)] +pub enum CommandPathResolutionError { + #[error("{}: command not found", .0)] + CommandNotFound(String), + #[error("command name was empty")] + CommandEmpty, +} + +impl CommandPathResolutionError { + 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 + CommandPathResolutionError::CommandNotFound(_) => 127, + CommandPathResolutionError::CommandEmpty => 1, + } + } +} + +/// Resolves a command name to an absolute path. +pub fn resolve_command_path<'a>( + command_name: &str, + base_dir: &Path, + get_var: impl Fn(&str) -> Option>, + current_exe: impl FnOnce() -> std::io::Result, +) -> Result { + if command_name.is_empty() { + return Err(CommandPathResolutionError::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) = 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 = + get_var("PATHEXT").unwrap_or(Cow::Borrowed(".EXE;.CMD;.BAT;.COM")); + let command_exts = path_ext + .split(';') + .map(|s| s.trim().to_uppercase()) + .filter(|s| !s.is_empty()) + .collect::>(); + 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(CommandPathResolutionError::CommandNotFound( + command_name.to_string(), + )) +} + +#[cfg(test)] +mod local_test { + use super::*; + + #[test] + fn should_resolve_current_exe_path_for_deno() { + let cwd = std::env::current_dir().unwrap(); + let path = resolve_command_path( + "deno", + &cwd, + |_| None, + || Ok(PathBuf::from("/bin/deno")), + ) + .unwrap(); + assert_eq!(path, PathBuf::from("/bin/deno")); + + let path = resolve_command_path( + "deno", + &cwd, + |_| None, + || 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(); + // Command not found + let result = resolve_command_path( + "foobar", + &cwd, + |_| None, + || Ok(PathBuf::from("/bin/deno")), + ); + assert_eq!( + result, + Err(CommandPathResolutionError::CommandNotFound( + "foobar".to_string() + )) + ); + // Command empty + let result = resolve_command_path( + "", + &cwd, + |_| None, + || Ok(PathBuf::from("/bin/deno")), + ); + assert_eq!(result, Err(CommandPathResolutionError::CommandEmpty)); + } +}