Zsh’s tab completion system is so powerful it can feel like magic, but the real trick is that it’s just incredibly well-designed plumbing.
Let’s see it in action. Imagine you’re in a directory with files like report_2023_q1.txt, report_2023_q2.txt, and summary_2023.txt.
$ cat report_2023_
Hit Tab. Zsh doesn’t just show you report_2023_q1.txt and report_2023_q2.txt. It understands you’re likely trying to complete a filename, and if you hit Tab again, it might even offer to expand to report_2023_q1.txt report_2023_q2.txt.
$ cat report_2023_q1.txt
Now, let’s say you’re using git.
$ git ch
Hit Tab. Zsh knows git is a command. It then looks for completions for git, and offers checkout, cherry-pick, clean, clone, commit, config, etc.
$ git checkout main
This isn’t just a static list. Zsh can dynamically generate completions based on the context. For example, if you’re using kubectl to manage Kubernetes resources, tab completion can show you available namespaces, deployment names, pod names, and even specific container names within a pod.
$ kubectl get pods -n my-namespace
Hit Tab after -n my-namespace. Zsh might list your namespaces: default, kube-system, my-namespace, other-namespace.
$ kubectl get pods -n my-namespace <TAB>
Hit Tab again. Zsh now knows you’re trying to get pods in my-namespace, so it lists the pods within that namespace: frontend-abcde, backend-fghij.
The system works by defining "completion functions" for commands. These functions are scripts that Zsh executes when you press Tab after a command or its arguments. These functions can do anything: query the filesystem, run external commands, parse output, and generate a list of possible completions.
The core of Zsh’s completion system is the compinit function, which initializes the completion system. You typically find this in your .zshrc:
autoload -Uz compinit && compinit
compinit scans directories specified in $fpath for completion definition files, usually named _commandname. When you type _commandname, Zsh looks for _commandname in $fpath and executes it.
The power comes from how these completion functions are written. They can be incredibly sophisticated. For instance, a completion for docker run might list available images from your local Docker daemon, and a completion for ssh could list hosts from your ~/.ssh/config file or even query an external inventory system.
You can extend completions by adding your own functions to a directory that’s in your $fpath. A common practice is to create a completions directory in your home folder:
mkdir ~/.zsh/completions
export fpath=(~/.zsh/completions $fpath)
Then, you can create a file named _mycommand inside ~/.zsh/completions. This file will contain the Zsh script that defines how mycommand should be completed.
For example, to create completions for a hypothetical mytool that has subcommands start and stop, and an option --port that expects a number, your _mytool might look something like this:
#compdef mytool
_mytool() {
local context state line
typeset -A opt_args
_arguments \
'1: :->command' \
'*: :->args'
case $state in
command)
_values \
'commands' \
'start' \
'stop' \
'status'
;;
args)
case $line[1] in
start|stop)
_arguments \
'--port=[port number]' \
'*:filename:_files'
;;
status)
# No arguments for status, or maybe just a specific host
_arguments \
'*:hostname'
;;
esac
;;
esac
}
_mytool "$@"
This script uses _arguments and _values to define the structure. _arguments handles options and positional arguments, while _values provides a list of discrete choices. _files is a built-in helper to complete filenames.
The magic behind dynamic completion for commands like git or kubectl often involves these completion functions executing external commands. For git, the completion scripts might run git branch --format='%(refname:short)' to get branch names, or git tag for tags. For kubectl, they’d likely call kubectl get namespaces -o jsonpath='{.items[*].metadata.name}' or similar to fetch resource names.
The most surprising thing is how much Zsh’s completion system can be customized without writing complex scripts. You can teach it to complete arguments based on file contents, SSH hostnames, or even the output of arbitrary commands, all through a declarative syntax. This means you can have completions for your custom scripts or tools that are as rich as those for built-in system commands.
Once you’ve got your custom completion functions set up and your $fpath configured, the next step is often integrating them with framework-provided completions, like those from Oh My Zsh or Prezto, which can sometimes override or interfere if not managed carefully.