diff --git a/crates/nu-command/src/system/run_external.rs b/crates/nu-command/src/system/run_external.rs index 90a08ed22..5c3784809 100644 --- a/crates/nu-command/src/system/run_external.rs +++ b/crates/nu-command/src/system/run_external.rs @@ -101,6 +101,7 @@ impl Command for External { } } +#[derive(Clone)] pub struct ExternalCommand { pub name: Spanned, pub args: Vec>, @@ -121,28 +122,75 @@ impl ExternalCommand { let ctrlc = engine_state.ctrlc.clone(); let mut process = self.create_process(&input, false, head)?; - let child; + // mut is used in the windows branch only, suppress warning on other platforms + #[allow(unused_mut)] + let mut child; #[cfg(windows)] { - // Some common Windows commands are actually built in to cmd.exe, not executables in their own right. - // To support those commands, we "shell out" to cmd.exe. + // Running external commands on Windows has 2 points of complication: + // 1. Some common Windows commands are actually built in to cmd.exe, not executables in their own right. + // 2. We need to let users run batch scripts etc. (.bat, .cmd) without typing their extension - // This has the full list of cmd.exe "internal" commands: https://ss64.com/nt/syntax-internal.html - // I (Reilly) went through the full list and whittled it down to ones that are potentially useful: - const CMD_INTERNAL_COMMANDS: [&str; 8] = [ - "ASSOC", "DIR", "ECHO", "FTYPE", "MKLINK", "START", "VER", "VOL", - ]; - - let command_name_upper = self.name.item.to_uppercase(); - let use_cmd = CMD_INTERNAL_COMMANDS - .iter() - .any(|&cmd| command_name_upper == cmd); + // To support these situations, we have a fallback path that gets run if a command + // fails to be run as a normal executable: + // 1. "shell out" to cmd.exe if the command is a known cmd.exe internal command + // 2. Otherwise, use `which-rs` to look for batch files etc. then run those in cmd.exe match process.spawn() { - Err(_) => { - let mut fg_process = self.create_process(&input, use_cmd, head)?; - child = fg_process.spawn(); + Err(err) => { + // set the default value, maybe we'll override it later + child = Err(err); + + // This has the full list of cmd.exe "internal" commands: https://ss64.com/nt/syntax-internal.html + // I (Reilly) went through the full list and whittled it down to ones that are potentially useful: + const CMD_INTERNAL_COMMANDS: [&str; 8] = [ + "ASSOC", "DIR", "ECHO", "FTYPE", "MKLINK", "START", "VER", "VOL", + ]; + let command_name_upper = self.name.item.to_uppercase(); + let looks_like_cmd_internal = CMD_INTERNAL_COMMANDS + .iter() + .any(|&cmd| command_name_upper == cmd); + + if looks_like_cmd_internal { + let mut cmd_process = self.create_process(&input, true, head)?; + child = cmd_process.spawn(); + } else { + #[cfg(feature = "which-support")] + { + // maybe it's a batch file (foo.cmd) and the user typed `foo`. Try to find it with `which-rs` + // TODO: clean this up with an if-let chain once those are stable + if let Ok(path) = + nu_engine::env::path_str(engine_state, stack, self.name.span) + { + if let Some(cwd) = self.env_vars.get("PWD") { + // append cwd to PATH so `which-rs` looks in the cwd too. + // this approximates what cmd.exe does. + let path_with_cwd = format!("{};{}", cwd, path); + if let Ok(which_path) = + which::which_in(&self.name.item, Some(path_with_cwd), cwd) + { + if let Some(file_name) = which_path.file_name() { + let file_name_upper = + file_name.to_string_lossy().to_uppercase(); + if file_name_upper != command_name_upper { + // which-rs found an executable file with a slightly different name + // than the one the user tried. Let's try running it + let mut new_command = self.clone(); + new_command.name = Spanned { + item: file_name.to_string_lossy().to_string(), + span: self.name.span, + }; + let mut cmd_process = new_command + .create_process(&input, true, head)?; + child = cmd_process.spawn(); + } + } + } + } + } + } + } } Ok(process) => { child = Ok(process); diff --git a/crates/nu-command/tests/commands/run_external.rs b/crates/nu-command/tests/commands/run_external.rs index e16fe7b64..58c8a8c95 100644 --- a/crates/nu-command/tests/commands/run_external.rs +++ b/crates/nu-command/tests/commands/run_external.rs @@ -1,4 +1,4 @@ -use nu_test_support::fs::Stub::EmptyFile; +use nu_test_support::fs::Stub::{EmptyFile, FileWithContent}; use nu_test_support::playground::Playground; use nu_test_support::{nu, pipeline}; @@ -259,3 +259,60 @@ fn single_quote_does_not_expand_path_glob_windows() { assert!(actual.out.contains("D&D_volume_2.txt")); }); } + +#[cfg(windows)] +#[test] +fn can_run_batch_files() { + Playground::setup("run a Windows batch file", |dirs, sandbox| { + sandbox.with_files(vec![FileWithContent( + "foo.cmd", + r#" + @echo off + echo Hello World + "#, + )]); + + let actual = nu!(cwd: dirs.test(), pipeline("foo.cmd")); + assert!(actual.out.contains("Hello World")); + }); +} + +#[cfg(windows)] +#[test] +fn can_run_batch_files_without_cmd_extension() { + Playground::setup( + "run a Windows batch file without specifying the extension", + |dirs, sandbox| { + sandbox.with_files(vec![FileWithContent( + "foo.cmd", + r#" + @echo off + echo Hello World + "#, + )]); + + let actual = nu!(cwd: dirs.test(), pipeline("foo")); + assert!(actual.out.contains("Hello World")); + }, + ); +} + +#[cfg(windows)] +#[test] +fn can_run_batch_files_without_bat_extension() { + Playground::setup( + "run a Windows batch file without specifying the extension", + |dirs, sandbox| { + sandbox.with_files(vec![FileWithContent( + "foo.bat", + r#" + @echo off + echo Hello World + "#, + )]); + + let actual = nu!(cwd: dirs.test(), pipeline("foo")); + assert!(actual.out.contains("Hello World")); + }, + ); +}