Writing zsh tab completions can be straightforward

How I add tab completion for the zsh command line
A panther. Etching by S. C. Miger, ca. 1808, after N. Maréchal
A panther. Etching by S. C. Miger, ca. 1808, after N. Maréchal. Maréchal, Nicolas, -1803.. Public Domain Mark. Source: Wellcome Collection.

Zsh has robust support for enhancing commands’ terminal UX with tab completions. Making sense of how to take advantage of that, and add tab completions to commands, can be tough. I find the official documentation hard to understand. The zsh-users org’s “how to” guide agrees, but I find that hard to understand too. zsh-users has many completions to study, but they use a range of code patterns, and many are quite complex.

For many commands it doesn’t have to be that difficult.

 

Contents

Completions?

The completion system isn’t enabled out of the box. If you don’t know whether you’ve enabled it or not, in a terminal type print - and then Tab. If you see completion suggestions, the completion system has been initialized (ctrl c will clear the suggestions). If you don’t, the way to initialize it depends on the rest of your setup.

If you use a zsh plugin manager, check its documentation to see if it has an idiomatic way of initializing completions. For example, as of this writing, I use the zsh plugin manager zcomet, which has its own zcomet compinit function (docs). If you don’t use a plugin manager or framework, or you use one that doesn’t have its own way of initializing the completion system, add this to your .zshrc file:

~/.zshrc
shell
shell
autoload -U compinit
compinit
shell
autoload -U compinit
compinit

(“compinit”? It initializes the completion system).

Prepare the file

To add completion for the command mycommand, start by

  1. creating a file _mycommand

  2. adding the file’s directory to fpath, the array of folders zsh will look in for (among other things) completion definitions

    _mycommand
    shell
    shell
    fpath+=absolute/path/to/_mycommand
    shell
    fpath+=absolute/path/to/_mycommand

The file can live anywhere. When writing completions for my own zsh software, I colocate the completion file with the command file

command line
shell
% cd path/to/mycommand
% tree
.
├── completions
│   └── _mycommand
└── mycommand
shell
% cd path/to/mycommand
% tree
.
├── completions
│   └── _mycommand
└── mycommand

I like to use this plugin wrapper pattern:

command line
shell
% cd path/to/mycommand
% tree
.
├── completions
│   └── _mycommand
├── mycommand.plugin.zsh
└── mycommand.zsh
% cat mycommand.plugin.zsh
fpath+=${0:A:h}/completions
source ${0:A:h}/mycommand.zsh
% cat mycommand.zsh
#!/usr/bin/env zsh
# ---snip---
mycommand() {
# ---snip---
}
shell
% cd path/to/mycommand
% tree
.
├── completions
│   └── _mycommand
├── mycommand.plugin.zsh
└── mycommand.zsh
% cat mycommand.plugin.zsh
fpath+=${0:A:h}/completions
source ${0:A:h}/mycommand.zsh
% cat mycommand.zsh
#!/usr/bin/env zsh
# ---snip---
mycommand() {
# ---snip---
}

When adding completions to software I didn’t write, I put the file in ~/.config/zsh/completions/<command name>.

Completions code

The pattern I use supports long and short options, and options which a file as an argument. That pretty well covers the completions I’ve wanted to write. For a command with these possibilities

text
mycommand subcommand [(--my-flag | -mf)] [(--file <file>)]
mycommand (different-subcommand | ds) [(--my-flag | -mf)]
mycommand (--help | help)
mycommand (--version | -v)
text
mycommand subcommand [(--my-flag | -mf)] [(--file <file>)]
mycommand (different-subcommand | ds) [(--my-flag | -mf)]
mycommand (--help | help)
mycommand (--version | -v)

to get this tab completion behavior

command line
shell
% mycommand <tab>
--help help -- Show the manpage.
--version -v -- Show the current version.
different-subcommand ds -- Different description
subcommand -- The description
% mycommand su<tab> # expands to `mycommand subcommand`
% mycommand subcommand -<tab>
--file -- path to a file
--my-flag -mf -- the my-flag description
% mycommand subcommand --file <tab>
# files listed here
shell
% mycommand <tab>
--help help -- Show the manpage.
--version -v -- Show the current version.
different-subcommand ds -- Different description
subcommand -- The description
% mycommand su<tab> # expands to `mycommand subcommand`
% mycommand subcommand -<tab>
--file -- path to a file
--my-flag -mf -- the my-flag description
% mycommand subcommand --file <tab>
# files listed here

I would use this completion file

_mycommand
shell
shell
#compdef mycommand
# -------------------------------
#
# info about mycommand goes here
# (e.g. author, license, etc)
#
# -------------------------------
local line state
local -i ret
ret=1
_arguments -C \
'1: :->cmds' \
'*:: :->args' \
&& ret=0
case $state in
cmds)
# mycommand subcommand
# mycommand (different-subcommand | ds)
# mycommand (--help | help)
# mycommand (--version | v)
_values "mycommand command" \
"subcommand[The description]" \
"different-subcommand[Different description]" \
"ds[Different description]" \
"help[Show the manpage.]" \
"--help[Show the manpage.]" \
"-v[Show the current version.]" \
"--version[Show the current version.]"
ret=0
;;
args)
case $line[1] in
subcommand)
# mycommand subcommand [(--my-flag | -mf)] [--file <file path>]
_arguments \
"(--my-flag)--my-flag[the my-flag description]" \
"(-mf)-mf[the my-flag description]" \
"(--file)--file[path to a file]:file:_files -/"
ret=0
;;
different-subcommand|\
ds)
# mycommand (different-subcommand | ds) [(--flag | -f)]
_arguments \
"(--my-flag)--my-flag[the --my-flag description]" \
"(-mf)-mf[the --my-flag description]" \
ret=0
;;
esac
;;
esac
return ret
shell
#compdef mycommand
# -------------------------------
#
# info about mycommand goes here
# (e.g. author, license, etc)
#
# -------------------------------
local line state
local -i ret
ret=1
_arguments -C \
'1: :->cmds' \
'*:: :->args' \
&& ret=0
case $state in
cmds)
# mycommand subcommand
# mycommand (different-subcommand | ds)
# mycommand (--help | help)
# mycommand (--version | v)
_values "mycommand command" \
"subcommand[The description]" \
"different-subcommand[Different description]" \
"ds[Different description]" \
"help[Show the manpage.]" \
"--help[Show the manpage.]" \
"-v[Show the current version.]" \
"--version[Show the current version.]"
ret=0
;;
args)
case $line[1] in
subcommand)
# mycommand subcommand [(--my-flag | -mf)] [--file <file path>]
_arguments \
"(--my-flag)--my-flag[the my-flag description]" \
"(-mf)-mf[the my-flag description]" \
"(--file)--file[path to a file]:file:_files -/"
ret=0
;;
different-subcommand|\
ds)
# mycommand (different-subcommand | ds) [(--flag | -f)]
_arguments \
"(--my-flag)--my-flag[the --my-flag description]" \
"(-mf)-mf[the --my-flag description]" \
ret=0
;;
esac
;;
esac
return ret

Tip

The comments under cmds) and args) are not necessary, but I find them to be helpful documentation.

Breaking it down

In the completions file, this much is boilerplate, with the caveat that the two instances of mycommand need to be changed to the real command name.

_mycommand
shell
shell
#compdef mycommand
local line state
local -i ret
ret=1
_arguments -C \
'1: :->cmds' \
'*:: :->args' \
&& ret=0
case $state in
cmds)
_values "mycommand command"
ret=0
;;
args)
case $line[1] in
esac
;;
esac
return ret
shell
#compdef mycommand
local line state
local -i ret
ret=1
_arguments -C \
'1: :->cmds' \
'*:: :->args' \
&& ret=0
case $state in
cmds)
_values "mycommand command"
ret=0
;;
args)
case $line[1] in
esac
;;
esac
return ret

Subcommands and top-level flags are handled in cmds) _values. Values with the same description will be displayed together on the command line as synonyms. Here’s the pattern:

_mycommand
shell
shell
# snip
case $state in
cmds)
# <command in manpage-like format>
# <another command in manpage-like format>
# etc
_values "<command-name> command" \
"<subcommand-with-synonym>[<description-1-shared-by-the-synonyms>]" \
"<the-subcommand-synonym>[<description-1-shared-by-the-synonyms>]" \
"<subcommand-without-synonym>[<description-2>]"
# snip
shell
# snip
case $state in
cmds)
# <command in manpage-like format>
# <another command in manpage-like format>
# etc
_values "<command-name> command" \
"<subcommand-with-synonym>[<description-1-shared-by-the-synonyms>]" \
"<the-subcommand-synonym>[<description-1-shared-by-the-synonyms>]" \
"<subcommand-without-synonym>[<description-2>]"
# snip

Subcommands’ arguments and flags are handled the args)’s case statement. Here again, arguments with the same description will be displayed together on the command line as synonyms. Here’s the pattern:

_mycommand
shell
shell
# snip
# <command in manpage-like format>
<subcommand-with-synonym>|\
<the-subcommand-synonym>)
_arguments \
"(<argument>)<argument>[<description>]"
ret=0
;;
<subcommand-without-synonym>)
# <command in manpage-like format>
_arguments \
"(<argument-with-synonym>)<argument-with-synonym>[description-1]" \
"(<argument-synonym>)<argument-synonym>[description-1]" \
"(<argument-2>)<argument-2>[<description-2>]"
ret=0
;;
# snip
shell
# snip
# <command in manpage-like format>
<subcommand-with-synonym>|\
<the-subcommand-synonym>)
_arguments \
"(<argument>)<argument>[<description>]"
ret=0
;;
<subcommand-without-synonym>)
# <command in manpage-like format>
_arguments \
"(<argument-with-synonym>)<argument-with-synonym>[description-1]" \
"(<argument-synonym>)<argument-synonym>[description-1]" \
"(<argument-2>)<argument-2>[<description-2>]"
ret=0
;;
# snip

Special case: arguments which take a file

If an argument takes a file as its argument —for example, to support mycommand --input-file <file path>— use this pattern in the _arguments, replacing <argument> with the argument and <description> with the description.

text
"(<argument>)<argument>[<description>]:file:_files -/"
text
"(<argument>)<argument>[<description>]:file:_files -/"

Real-life examples

There are other ways to program the same completions, and file arguments are just one of the many special cases zsh’s completion system supports. Of the ones I’ve seen, I find this one easiest to understand. And it’s met most of my needs.

See it in action in zsh-abbr and in zsh-test-runner.


Updates

Feb 1, 2024: Added plugin wrapper pattern.

Articles You Might Enjoy

Algolia Search in VuePress Without Joining DocSearch

Or Go To All Articles