Run scripts of any file extension in PATHEXT on Windows (#15611)

# Description
On Windows, I would like to be able to call a script directly in nushell
and have that script be found in the PATH and run based on filetype
associations and PATHEXT.

There have been previous discussions related to this feature, see
https://github.com/nushell/nushell/issues/6440 and
https://github.com/nushell/nushell/issues/15476. The latter issue is
only a few weeks old, and after taking a look at it and the resultant PR
I found that currently nushell is hardcoded to support only running
nushell (.nu) scripts in this way.

This PR seeks to make this functionality more generic. Instead of
checking that the file extension is explicitly `NU`, it instead checks
that it **is not** one of `COM`, `EXE`, `BAT`, `CMD`, or `PS1`. The
first four of these are extensions that Windows can figure out how to
run on its own. This is implied by the output of `ftype` for any of
these extensions, which shows that files are just run without a calling
command anyway.
```
>ftype batfile
batfile="%1" %*
```
PS1 files are ignored because they are handled as a special in later
logic.

In implementing this I initially tried to fetch the value of PATHEXT and
confirm that the file extension was indeed in PATHEXT. But I determined
that because `which()` respects PATHEXT, this would be redundant; any
executable that is found by `which` is already going to have an
extension in PATHEXT. It is thus only necessary to check that it isn't
one of the few extensions that should be called directly, without the
use of `cmd.exe`.


There are some small formatting changes to `run_external.rs` in the PR
as a result of running `cargo fmt` that are not entirely related to the
code I modified. I can back out those changes if that is desired.

# User-Facing Changes
<!-- List of all changes that impact the user experience here. This
helps us keep track of breaking changes. -->
Behavior for `.nu` scripts will not change. Users will still need to
ensure they have PATHEXT and filetype associations set correctly for
them to work, but this will now also apply to scripts of other types.
This commit is contained in:
Hayden Frentzel 2025-04-24 09:10:34 -05:00 committed by GitHub
parent f41b1460aa
commit b33f4b7f55
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -79,23 +79,30 @@ impl Command for External {
let paths = nu_engine::env::path_str(engine_state, stack, call.head)?; let paths = nu_engine::env::path_str(engine_state, stack, call.head)?;
// On Windows, the user could have run the cmd.exe built-in "assoc" command // On Windows, the user could have run the cmd.exe built-in commands "assoc"
// Example: "assoc .nu=nuscript" and then run the cmd.exe built-in "ftype" command // and "ftype" to create a file association for an arbitrary file extension.
// Example: "ftype nuscript=C:\path\to\nu.exe '%1' %*" and then added the nushell // They then could have added that extension to the PATHEXT environment variable.
// script extension ".NU" to the PATHEXT environment variable. In this case, we use // For example, a nushell script with extension ".nu" can be set up with
// the which command, which will find the executable with or without the extension. // "assoc .nu=nuscript" and "ftype nuscript=C:\path\to\nu.exe '%1' %*",
// If it "which" returns true, that means that we've found the nushell script and we // and then by adding ".NU" to PATHEXT. In this case we use the which command,
// believe the user wants to use the windows association to run the script. The only // which will find the executable with or without the extension. If "which"
// returns true, that means that we've found the script and we believe the
// user wants to use the windows association to run the script. The only
// easy way to do this is to run cmd.exe with the script as an argument. // easy way to do this is to run cmd.exe with the script as an argument.
let potential_nuscript_in_windows = if cfg!(windows) { // File extensions of .COM, .EXE, .BAT, and .CMD are ignored because Windows
// let's make sure it's a .nu script // can run those files directly. PS1 files are also ignored and that
// extension is handled in a separate block below.
let pathext_script_in_windows = if cfg!(windows) {
if let Some(executable) = which(&expanded_name, &paths, cwd.as_ref()) { if let Some(executable) = which(&expanded_name, &paths, cwd.as_ref()) {
let ext = executable let ext = executable
.extension() .extension()
.unwrap_or_default() .unwrap_or_default()
.to_string_lossy() .to_string_lossy()
.to_uppercase(); .to_uppercase();
ext == "NU"
!["COM", "EXE", "BAT", "CMD", "PS1"]
.iter()
.any(|c| *c == ext)
} else { } else {
false false
} }
@ -122,29 +129,28 @@ impl Command for External {
// Find the absolute path to the executable. On Windows, set the // Find the absolute path to the executable. On Windows, set the
// executable to "cmd.exe" if it's a CMD internal command. If the // executable to "cmd.exe" if it's a CMD internal command. If the
// command is not found, display a helpful error message. // command is not found, display a helpful error message.
let executable = if cfg!(windows) let executable =
&& (is_cmd_internal_command(&name_str) || potential_nuscript_in_windows) if cfg!(windows) && (is_cmd_internal_command(&name_str) || pathext_script_in_windows) {
{ PathBuf::from("cmd.exe")
PathBuf::from("cmd.exe") } else if cfg!(windows) && potential_powershell_script {
} else if cfg!(windows) && potential_powershell_script { // If we're on Windows and we're trying to run a PowerShell script, we'll use
// If we're on Windows and we're trying to run a PowerShell script, we'll use // `powershell.exe` to run it. We shouldn't have to check for powershell.exe because
// `powershell.exe` to run it. We shouldn't have to check for powershell.exe because // it's automatically installed on all modern windows systems.
// it's automatically installed on all modern windows systems. PathBuf::from("powershell.exe")
PathBuf::from("powershell.exe") } else {
} else { // Determine the PATH to be used and then use `which` to find it - though this has no
// Determine the PATH to be used and then use `which` to find it - though this has no // effect if it's an absolute path already
// effect if it's an absolute path already let Some(executable) = which(&expanded_name, &paths, cwd.as_ref()) else {
let Some(executable) = which(&expanded_name, &paths, cwd.as_ref()) else { return Err(command_not_found(
return Err(command_not_found( &name_str,
&name_str, call.head,
call.head, engine_state,
engine_state, stack,
stack, &cwd,
&cwd, ));
)); };
executable
}; };
executable
};
// Create the command. // Create the command.
let mut command = std::process::Command::new(&executable); let mut command = std::process::Command::new(&executable);
@ -160,7 +166,7 @@ impl Command for External {
// Configure args. // Configure args.
let args = eval_external_arguments(engine_state, stack, call_args.to_vec())?; let args = eval_external_arguments(engine_state, stack, call_args.to_vec())?;
#[cfg(windows)] #[cfg(windows)]
if is_cmd_internal_command(&name_str) || potential_nuscript_in_windows { if is_cmd_internal_command(&name_str) || pathext_script_in_windows {
// The /D flag disables execution of AutoRun commands from registry. // The /D flag disables execution of AutoRun commands from registry.
// The /C flag followed by a command name instructs CMD to execute // The /C flag followed by a command name instructs CMD to execute
// that command and quit. // that command and quit.