Providing an answer for my own question to share what I found. If others have a better idea, I would love to accept their answer. However, when googling I could not find anything on this error (not helped by zsh's obscure syntax making it about as easy to google as perl expressions).
The tl;dr solution is as follows: Run unsetopt pathdirs
and the issue should go away. Put it in your ~/.zshrc
and it should be resolved. What follows is the explanation.
Turning on tracing for _path_commands
to see where it hangs: autoload -t _path_commands
:
+_path_commands:46> ret=0
+_path_commands:51> [[ -o path_dirs ]]
+_path_commands:52> local -a path_dirs
So let's have a look at that function via which _path_commands
(note you need to do a completion once for zsh to load it). I'll provide the relevant snippet:
if [[ -o path_dirs ]]
then
local -a path_dirs
path_dirs=(${^path}/*(/N:t))
(( ${#path_dirs} )) && _wanted path-dirs expl 'directory in path' compadd "$@" -a path_dirs && ret=0
if [[ $PREFIX$SUFFIX = */* ]]
then
_wanted commands expl 'external command' _path_files -W path -g '*(*)' && ret=0
fi
fi
The last line we get when it hangs is local -a path_dirs
which just defines an empty array. That's likely not it, but if I execute the next command it hangs for a long time: path_dirs=(${^path}/*(/N:t))
. Good luck googling that if you're not familiar with the language. I'll explain:
- We are creating an array with
( ... )
- We reference the parameter
$path
- We turn on
RC_EXPAND_PARAM
with the ^
chracter ${^path}
, see 14.3 Parameter Expansion. It's not our culprit so I'll skip the explanation. The only bit to understand is that we have an array here.
- Do globbing inside each directory via
/*
. This is the same as if you did this on your command line: ls *
, for example. Except here it does it for all elements of the array, like a loop. A good culprit, but if we try echo ${^path}/*
it's still very quick.
- Lastly we add three glob qualifiers, essentially filters on the results of that expansion:
/
only returns directories
N
sets nullglob
, basically "remove empty elements"
:t
sets the modifier to remove the full path and leave only the basename
output.
If we play around with the full expression e.g. ${^path}/*(/N:t)
we notice that it's only slow if the /
character is present. Removing it makes everything fast. With some additional debugging you can even find what's slow, e.g. write a loop and see when it hangs:
for item in $path; do echo "${item}: " ${item}/*(/); done
In my case I notice it hanging on a lot of Windows paths (/mnt/c/Windows/system32
, for example). At this point I gave up: I don't know why this expansion is so slow for Windows paths and I don't know how to debug it or do some form of "caching" that speeds it up (it might just be slow due to WSL filesystem issues).
Instead, notice how there is a condition: if [[ -o path_dirs ]]
before entering this code path? The conditional test -o
checks for an option, i.e. if path_dirs
is set. This is described in the options manual:
PATH_DIRS (-Q)
Perform a path search even on command names with slashes in them. Thus if ‘/usr/local/bin’ is in the user’s path, and he or she types ‘X11/xinit’, the command ‘/usr/local/bin/X11/xinit’ will be executed (assuming it exists). Commands explicitly beginning with ‘/’, ‘./’ or ‘../’ are not subject to the path search. This also applies to the ‘.’ and source builtins.
If we can live without this feature (I think I can), we can stop here: Simply turn it off, e.g. via unsetopt pathdirs
and call it a day. Once that's done, this code branch is no longer executed and the problem goes away.
path
points to a Network share? In particular on Windows. this could explain the delay. Another point worth to check: Do you also see the delay immediately after doing arehash
? – Ichthyosaur