zshzsh-completion

Why is my zsh tab completion slow on commands but not directories?


I am using zsh with completion turned on. When I try to tab-complete, sometimes the command hangs for a long time. After a few seconds it completes and correctly presents my options. On the other hand, if I interrupt it with Ctrl-C, I get the following message:

Killed by signal in _path_commands after 2s

If I try to tab-complete directories (e.g. in ls) it works just fine, there is no lag.

Note that I am running on Windows using WSL2, though I can vaguely recall it happening on other systems, too. Haven't gone back to confirm, but when I just tested on my server, I couldn't reproduce it there, so it's something about the environment.


Solution

  • 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:

    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.