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