Repository: xonsh/xonsh

Issue:
Windows: case of commands is incorrectly upper-cased on Windows
## Current Behavior

As reported and investigated in https://github.com/rust-lang/rustup/issues/3848#issuecomment-2143100269, on Windows, it seems that xonsh will transform an executable name to a non-canonical uppercase form before invoking in a subprocess.

```
 ~ # import xonsh.environ as xenv
 ~ # xenv.locate_binary('./rustup-init')
'C:\\Users\\jaraco\\RUSTUP-INIT.EXE'
```

This causes problems when an executable is named `rustup-init` and that executable depends on recognizing its own name to dispatch its behavior but fails when `RUSTUP-INIT.EXE` is what appears in ARG0.

This undesirable upper casing is almost certainly happening in one or more of the Windows-specific `upper()` calls in [commands_cache](https://github.com/xonsh/xonsh/blob/main/xonsh/commands_cache.py).

This mismatch of case led to obscure and annoying problems while attempting to install Rust from xonsh using the recommended install technique.

## Expected Behavior

At the very least, xonsh should not alter the case of the characters typed by the user for the command. If they typed `rustup-init`, then those characters should appear in arg0 of the subprocess.

Even better would be to do what Powershell does and use case-insensitive matching to locate the command, but then use the case of the characters in the filename itself for arg0. That way, if the user types `./RUSTUP-INIT`, the arg0 will be `full\path\to\rustup-init.exe`. For example:

```
 ~ # powershell -c '.\\Print_ARG0.py'
C:\Users\jaraco\print_arg0.py
```

## xonfig

<details>

```xsh
 ~ # xonfig
+------------------+-----------------+
| xonsh            | 0.16.0          |
| Python           | 3.12.2          |
| PLY              | 3.11            |
| have readline    | False           |
| prompt toolkit   | 3.0.40          |
| shell type       | prompt_toolkit  |
| history backend  | json            |
| pygments         | 2.18.0          |
| on posix         | False           |
| on linux         | False           |
| on darwin        | False           |
| on windows       | True            |
| on cygwin        | False           |
| on msys2         | False           |
| is superuser     | True            |
| default encoding | utf-8           |
| xonsh encoding   | utf-8           |
| encoding errors  | surrogateescape |
| xontrib 1        | vox             |
| xontrib 2        | voxapi          |
| xontrib 3        | jaraco.xonsh    |
| RC file          | []              |
+------------------+-----------------+
```

</details>

## For community
⬇️  **Please click the 👍 reaction instead of leaving a `+1` or 👍  comment**



Discussion:
Hey! Nice catch! PR is welcome!
I'll plan to look into it. Can you assign to me?

Also, if you're willing to give me triage permission (or contributor) to the project, I'd be happy to help out and will avoid any controversial changes without proper coordination/approval.
Great! @gforsyth will consider this proposal!
@jaraco , very happy to have you help out as a contributor. Let me see if this is easy to do from mobile, otherwise i can get to it next week when I'm back in front of a keyboard.

Resolving patch:
diff --git a/xonsh/commands_cache.py b/xonsh/commands_cache.py
--- a/xonsh/commands_cache.py
+++ b/xonsh/commands_cache.py
@@ -18,6 +18,11 @@
 from xonsh.platform import ON_POSIX, ON_WINDOWS, pathbasename
 from xonsh.tools import executables_in
 
+if ON_WINDOWS:
+    from case_insensitive_dict import CaseInsensitiveDict as CacheDict
+else:
+    CacheDict = dict
+
 
 class _Commands(tp.NamedTuple):
     mtime: float
@@ -80,12 +85,7 @@ def __iter__(self):
 
     def iter_commands(self):
         """Wrapper for handling windows path behaviour"""
-        for cmd, (path, is_alias) in self.all_commands.items():
-            if ON_WINDOWS and path is not None:
-                # All command keys are stored in uppercase on Windows.
-                # This ensures the original command name is returned.
-                cmd = pathbasename(path)
-            yield cmd, (path, is_alias)
+        return self.all_commands.items()
 
     def __len__(self):
         return len(self.all_commands)
@@ -99,15 +99,16 @@ def is_empty(self):
         return len(self._cmds_cache) == 0
 
     def get_possible_names(self, name):
-        """Generates the possible `PATHEXT` extension variants of a given executable
-        name on Windows as a list, conserving the ordering in `PATHEXT`.
-        Returns a list as `name` being the only item in it on other platforms."""
-        if ON_WINDOWS:
-            pathext = [""] + self.env.get("PATHEXT", [])
-            name = name.upper()
-            return [name + ext for ext in pathext]
-        else:
-            return [name]
+        """Expand name to all possible variants based on `PATHEXT`.
+
+        PATHEXT is a Windows convention containing extensions to be
+        considered when searching for an executable file.
+
+        Conserves order of any extensions found and gives precedence
+        to the bare name.
+        """
+        extensions = [""] + self.env.get("PATHEXT", [])
+        return [name + ext for ext in extensions]
 
     @staticmethod
     def remove_dups(paths):
@@ -159,11 +160,10 @@ def update_cache(self):
         # entries at the back.
         paths = tuple(reversed(tuple(self.remove_dups(env.get("PATH") or []))))
         if any(self._check_changes(paths)):
-            all_cmds = {}
+            all_cmds = CacheDict()
             for cmd, path in self._iter_binaries(paths):
-                key = cmd.upper() if ON_WINDOWS else cmd
                 # None     -> not in aliases
-                all_cmds[key] = (path, None)
+                all_cmds[cmd] = (path, None)
 
             # aliases override cmds
             for cmd in self.aliases:
@@ -178,9 +178,8 @@ def update_cache(self):
                     # (path, False) -> has same named alias
                     all_cmds[override_key] = (all_cmds[override_key][0], False)
                 else:
-                    key = cmd.upper() if ON_WINDOWS else cmd
                     # True -> pure alias
-                    all_cmds[key] = (cmd, True)
+                    all_cmds[cmd] = (cmd, True)
             self._cmds_cache = all_cmds
         return self._cmds_cache
 
@@ -218,13 +217,9 @@ def _iter_binaries(self, paths):
 
     def cached_name(self, name):
         """Returns the name that would appear in the cache, if it exists."""
-        if name is None:
-            return None
         cached = pathbasename(name) if os.pathsep in name else name
-        if ON_WINDOWS:
-            keys = self.get_possible_names(cached)
-            cached = next((k for k in keys if k in self._cmds_cache), None)
-        return cached
+        keys = self.get_possible_names(cached)
+        return next((k for k in keys if k in self._cmds_cache), name)
 
     def lazyin(self, key):
         """Checks if the value is in the current cache without the potential to
@@ -262,7 +257,6 @@ def locate_binary(self, name, ignore_alias=False):
             Force return of binary path even if alias of ``name`` exists
             (default ``False``)
         """
-        # make sure the cache is up to date by accessing the property
         self.update_cache()
         return self.lazy_locate_binary(name, ignore_alias)
 
@@ -278,12 +272,6 @@ def lazy_locate_binary(self, name, ignore_alias=False):
             (default ``False``)
         """
         possibilities = self.get_possible_names(name)
-        if ON_WINDOWS:
-            # Windows users expect to be able to execute files in the same
-            # directory without `./`
-            local_bin = next((fn for fn in possibilities if os.path.isfile(fn)), None)
-            if local_bin:
-                return os.path.abspath(local_bin)
         cached = next((cmd for cmd in possibilities if cmd in self._cmds_cache), None)
         if cached:
             (path, alias) = self._cmds_cache[cached]
@@ -329,18 +317,6 @@ def get_predictor_threadable(self, cmd0):
         """
         name = self.cached_name(cmd0)
         predictors = self.threadable_predictors
-        if ON_WINDOWS:
-            # On all names (keys) are stored in upper case so instead
-            # we get the original cmd or alias name
-            path, _ = self.lazyget(name, (None, None))
-            if path is None:
-                return predict_true
-            else:
-                name = pathbasename(path)
-            if name not in predictors:
-                pre, ext = os.path.splitext(name)
-                if pre in predictors:
-                    predictors[name] = predictors[pre]
         if name not in predictors:
             predictors[name] = self.default_predictor(name, cmd0)
         predictor = predictors[name]


Test patch:
diff --git a/tests/test_commands_cache.py b/tests/test_commands_cache.py
--- a/tests/test_commands_cache.py
+++ b/tests/test_commands_cache.py
@@ -157,7 +157,6 @@ def test_commands_cache_predictor_default(args, xession, tmp_path):
 
 
 class Test_is_only_functional_alias:
-    @skip_if_on_windows
     def test_cd(self, xession):
         xession.aliases["cd"] = lambda args: os.chdir(args[0])
         xession.env["PATH"] = []
@@ -204,7 +203,31 @@ def test_update_cache(xession, tmp_path):
     assert file2.samefile(cached[basename][0])
 
 
-@skip_if_on_windows
+@pytest.fixture
+def faux_binary(tmp_path):
+    """
+    A fake binary in the temp path.
+
+    Uses mixed case so tests may make assertions about it.
+    """
+    binary = tmp_path / "RunMe.exe"
+    binary.touch()
+    binary.chmod(0o755)
+    return binary
+
+
+def test_find_binary_retains_case(faux_binary):
+    cache = CommandsCache({"PATH": []})
+    loc = cache.locate_binary(str(faux_binary))
+    assert faux_binary.name in loc
+
+
+def test_exes_in_cwd_are_not_matched(faux_binary, monkeypatch):
+    monkeypatch.chdir(faux_binary.parent)
+    cache = CommandsCache({"PATH": []})
+    assert cache.locate_binary(faux_binary.name) is None
+
+
 def test_nixos_coreutils(tmp_path):
     """On NixOS the core tools are the symlinks to one universal ``coreutils`` binary file."""
     path = tmp_path / "core"