Writing zsh tab completions can be straightforward
Last Updated
![A panther. Etching by S. C. Miger, ca. 1808, after N. Maréchal](/posts/writing-tab-completions-for-zsh-commands-can-be-straightforward/UUDXE99s92-896.jpeg)
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:
shell
autoload -U compinitcompinit
shell
autoload -U compinitcompinit
(“compinit”? It initializes the completion system).
Prepare the file
To add completion for the command mycommand
, start by
-
creating a file
_mycommand
-
adding the file’s directory to
fpath
, the array of folders zsh will look in for (among other things) completion definitions_mycommandshellshellfpath+=absolute/path/to/_mycommandshellfpath+=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
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:
shell
% cd path/to/mycommand% tree.├── completions│ └── _mycommand├── mycommand.plugin.zsh└── mycommand.zsh% cat mycommand.plugin.zshfpath+=${0:A:h}/completionssource ${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.zshfpath+=${0:A:h}/completionssource ${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
shell
% mycommand <tab>--help help -- Show the manpage.--version -v -- Show the current version.different-subcommand ds -- Different descriptionsubcommand -- 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 descriptionsubcommand -- 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
shell
#compdef mycommand# -------------------------------## info about mycommand goes here# (e.g. author, license, etc)## -------------------------------local line statelocal -i retret=1_arguments -C \'1: :->cmds' \'*:: :->args' \&& ret=0case $state incmds)# 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] insubcommand)# 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;;esacreturn ret
shell
#compdef mycommand# -------------------------------## info about mycommand goes here# (e.g. author, license, etc)## -------------------------------local line statelocal -i retret=1_arguments -C \'1: :->cmds' \'*:: :->args' \&& ret=0case $state incmds)# 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] insubcommand)# 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;;esacreturn 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.
shell
#compdef mycommandlocal line statelocal -i retret=1_arguments -C \'1: :->cmds' \'*:: :->args' \&& ret=0case $state incmds)_values "mycommand command"ret=0;;args)case $line[1] inesac;;esacreturn ret
shell
#compdef mycommandlocal line statelocal -i retret=1_arguments -C \'1: :->cmds' \'*:: :->args' \&& ret=0case $state incmds)_values "mycommand command"ret=0;;args)case $line[1] inesac;;esacreturn 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:
shell
# snipcase $state incmds)# <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
# snipcase $state incmds)# <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:
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.
Mentions around the web
Reposts
Likes
Comments
-
Ölbaum
@olets Excellent. From this and the Bash tutorial I found, the Zsh completion mechanism seems orders of magnitude better. It makes me regret that our production servers are on Bash.
Articles You Might Enjoy
-
-
Numbered Code Block Lines in Eleventy with Shiki Twoslash
Bringing in a third-party library for easy, reliable line numbering
-
Accessible CSS-Only Light/Dark Toggles (with JS persistence as progressive enhancement)
CSS’s
:has()
can obviate JS