Understand how ZSH's completion system works - why git <tab> knows subcommands, how to build custom completions, and the architecture behind context-aware suggestions.
You type git <tab> and see subcommands. Type git commit -<tab> and see flags. Type git checkout <tab> and see branches.
How does ZSH know what to suggest? Why does cd <tab> only show directories, but ls <tab> shows files?
This is the completion system. It’s invisible infrastructure that makes your shell feel intelligent.
Most developers use completions daily but never understand how they work. This changes that.
Table of Contents
What Completions Actually Are
Completions are functions that generate suggestions based on:
- What command you’re typing
- What position in the command line you’re at
- What you’ve typed so far
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # Basic completion: just list files
cd <tab>
# Calls _cd function, which suggests directories only
# Context-aware completion: different per subcommand
git <tab>
# Calls _git function, which checks position
# Position 1 (after "git"): suggest subcommands
# Position 2+ (after "git commit"): suggest flags
# State-aware completion: knows your environment
git checkout <tab>
# Calls _git, which runs: git branch --list
# Dynamically generates suggestions from your repo
|
Key insight: Completions are code that runs when you press tab. They can do anything - read files, call commands, parse state.
The Completion Architecture
ZSH’s completion system has three layers:
Layer 1: compinit (System Initialization)
1
2
3
| # In your .zshrc
autoload -Uz compinit
compinit
|
This loads the completion system. Without this, you only get basic filename completion.
What compinit does:
- Loads completion functions from
fpath directories - Creates the
_main_complete dispatcher - Sets up keybindings (tab → complete-word)
- Initializes completion cache
Where completions live:
1
2
3
4
| echo $fpath
# /usr/share/zsh/functions/Completion/...
# /usr/local/share/zsh/site-functions
# ~/.zsh/completions
|
Layer 2: compdef (Register Completions)
1
2
3
4
5
6
7
8
| # Register _git function for git command
compdef _git git
# Register same completion for multiple commands
compdef _cargo cargo cargo-clippy cargo-fmt
# Register pattern-based completion
compdef '_files -g "*.pdf"' evince okular
|
compdef maps commands to completion functions. When you type git <tab>, ZSH looks up which function to call.
Check what’s registered:
1
2
3
4
5
| # See completion for git
which _git
# List all registered completions
compdef -p
|
Layer 3: _arguments (Parse and Complete)
The workhorse function that handles flags, options, and arguments.
1
2
3
4
| _arguments \
'-h[Show help]' \
'-v[Verbose output]' \
'*:filename:_files'
|
This declares:
-h flag with description “Show help”-v flag with description “Verbose output”* any number of arguments, type “filename”, completed by _files function
How Git Completion Works
Let’s trace what happens when you type git commit -<tab>:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| # 1. You press tab
git commit -<tab>
# 2. ZSH calls _main_complete
# 3. _main_complete looks up: which function handles "git"?
# Answer: _git (registered via compdef)
# 4. _git function executes
_git() {
# Figure out which subcommand (commit, checkout, etc.)
local subcommand=$words[2]
# Call subcommand-specific handler
case $subcommand in
commit) _git-commit ;;
checkout) _git-checkout ;;
...
esac
}
# 5. _git-commit runs
_git-commit() {
_arguments \
'-m[Commit message]:message' \
'--amend[Amend previous commit]' \
'--no-verify[Skip pre-commit hooks]' \
'*:file:__git_changed_files'
}
# 6. _arguments sees you typed '-' and offers matching flags:
# -m, --amend, --no-verify, etc.
|
Key points:
_git delegates to subcommand handlers (_git-commit, _git-checkout)- Each subcommand has its own
_arguments spec - Dynamic completions call helper functions (
__git_changed_files)
Building Your First Completion
Let’s build completion for a simple script: deploy [staging|production] [service]
Step 1: Create the Completion Function
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| # ~/.zsh/completions/_deploy
#compdef deploy
_deploy() {
local -a environments services
environments=(
'staging:Deploy to staging environment'
'production:Deploy to production (requires approval)'
)
services=(
'api:Backend API service'
'web:Frontend web application'
'worker:Background job worker'
)
_arguments \
'1:environment:->environment' \
'2:service:->service' \
&& return 0
case $state in
environment)
_describe 'environment' environments
;;
service)
_describe 'service' services
;;
esac
}
_deploy "$@"
|
Explanation:
#compdef deploy - Registers this function for the deploy command'1:environment:->environment' - First arg, named “environment”, set state_describe - Display options with descriptions- Format:
'value:description'
Step 2: Add to fpath and Load
1
2
3
4
| # In .zshrc
fpath=(~/.zsh/completions $fpath)
autoload -Uz compinit
compinit
|
Step 3: Test
1
2
3
4
5
| deploy <tab>
# Shows: staging, production (with descriptions)
deploy staging <tab>
# Shows: api, web, worker (with descriptions)
|
Context-Aware Completions
Different suggestions based on what you’ve already typed:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| # Example: docker run completion
_docker-run() {
_arguments \
'(-d --detach)'{-d,--detach}'[Run in background]' \
'(-it)'{-i,--interactive,-t,--tty}'[Interactive TTY]' \
'--name[Container name]:name' \
'1:image:__docker_images' \
'*:command:_command_names'
}
# Key patterns:
# '(-d --detach)'{-d,--detach} - Mutually exclusive flags
# ':name' - Requires user input, no completion
# ':name:__docker_images' - Complete with custom function
# '*:command' - Multiple arguments allowed
|
Position-Based Completion
1
2
3
4
5
6
7
8
9
10
11
12
| _mycommand() {
case $CURRENT in
2) # First argument
_describe 'action' '(start stop restart status)'
;;
3) # Second argument (only if first was 'start')
if [[ $words[2] == 'start' ]]; then
_files -g '*.conf'
fi
;;
esac
}
|
Variables available:
$CURRENT - Current word position (1-indexed)$words - Array of all words on command line$words[2] - Second word (first arg after command)
Dynamic Completions
1
2
3
4
5
6
7
8
9
10
11
12
13
| # Complete with live data
__project_names() {
local -a projects
projects=( ${(f)"$(ls ~/projects)"} )
_describe 'project' projects
}
# Complete with command output
__git_branches() {
local -a branches
branches=( ${(f)"$(git branch --format='%(refname:short)')"} )
_describe 'branch' branches
}
|
Pattern: ${(f)"$(command)"} - Split command output by lines into array
Completions run on every tab press. Slow completions = frustrating shell.
Problem: Expensive Operations
1
2
3
4
5
| # BAD: Slow API call on every tab
__projects() {
local projects=$(curl -s api.example.com/projects)
# Takes 500ms every time you press tab
}
|
Solution 1: Cache Results
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # GOOD: Cache for 5 minutes
__projects() {
local cache=~/.cache/zsh/projects
local cache_timeout=300 # 5 minutes
if [[ ! -f $cache ]] || \
[[ $(($(date +%s) - $(stat -f %m $cache))) -gt $cache_timeout ]]; then
curl -s api.example.com/projects > $cache
fi
local -a projects
projects=( ${(f)"$(cat $cache)"} )
_describe 'project' projects
}
|
Solution 2: Lazy Evaluation
1
2
3
4
5
6
7
8
9
10
| # Only fetch if user actually tabs
_arguments \
'1:project:->project'
case $state in
project)
# Only runs if user tabs at this position
__fetch_projects
;;
esac
|
Solution 3: compinit Caching
1
2
3
4
5
6
7
8
9
| # In .zshrc - cache completion dump
autoload -Uz compinit
# Only regenerate once per day
if [[ -n ~/.zcompdump(#qN.mh+24) ]]; then
compinit
else
compinit -C # Skip security check, use cached
fi
|
Impact: Reduces shell startup time from 500ms to 50ms.
Common Patterns
Pattern 1: File Type Filtering
1
2
3
4
5
6
7
8
| # Only PDF files
_arguments '*:pdf:_files -g "*.pdf"'
# Only directories
_arguments '1:directory:_directories'
# Images only
_arguments '*:image:_files -g "*.{jpg,png,gif}"'
|
Pattern 2: Multiple Commands, Same Completion
1
2
| # Use same completion for cargo variants
compdef _cargo cargo cargo-clippy cargo-fmt cargo-build
|
Pattern 3: Subcommand Dispatch
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| _myapp() {
local -a subcommands
subcommands=(
'init:Initialize new project'
'build:Build project'
'test:Run tests'
)
if (( CURRENT == 2 )); then
_describe 'subcommand' subcommands
else
local subcommand=$words[2]
case $subcommand in
init) _myapp_init ;;
build) _myapp_build ;;
test) _myapp_test ;;
esac
fi
}
|
Pattern 4: Flag + Value Completion
1
2
3
4
| _arguments \
'--env[Environment]:environment:(dev staging prod)' \
'--port[Port number]:port' \
'--config[Config file]:file:_files -g "*.yml"'
|
Syntax:
'--flag[Description]:label' - Flag requires value, no completion'--flag[Description]:label:(a b c)' - Complete from list'--flag[Description]:label:_function' - Complete with function
Debugging Completions
Completion Not Working?
1
2
3
4
5
6
7
8
9
10
11
12
13
| # 1. Check if completion function exists
which _git
# 2. Check if it's registered
compdef -p | grep git
# 3. Test completion manually
_git
# Should show: "usage: git [--version] ..."
# 4. Enable debug output
zstyle ':completion:*' verbose yes
# Now tab shows where completions come from
|
See What’s Being Completed
1
2
3
4
5
6
7
| # Show completion context
^Xh # Ctrl+X then h
# Shows: completing for git subcommand
# Show completion matches
^Xc # Ctrl+X then c
# Shows: what would be completed
|
Completion Function Not Found
1
2
3
4
5
6
7
8
9
| # Check fpath
echo $fpath
# Reload completions
rm ~/.zcompdump
compinit
# Manually load function
autoload -Uz _git
|
Real-World Example: Custom Script Completion
Let’s build completion for a deployment script that:
- Reads available environments from a file
- Dynamically lists services from a config
- Only allows valid flag combinations
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
| # ~/.zsh/completions/_deploy
#compdef deploy
_deploy() {
local curcontext="$curcontext" state line
typeset -A opt_args
_arguments -C \
'(-h --help)'{-h,--help}'[Show help]' \
'(-n --dry-run)'{-n,--dry-run}'[Show what would be deployed]' \
'--rollback[Rollback to previous version]' \
'1:environment:->environment' \
'2:service:->service' \
&& return 0
case $state in
environment)
local -a envs
# Read from config file
if [[ -f ~/.deploy/environments ]]; then
envs=( ${(f)"$(cat ~/.deploy/environments)"} )
else
envs=('staging' 'production')
fi
_describe 'environment' envs
;;
service)
local env=$words[2]
local -a services
# Different services per environment
case $env in
staging)
services=('api' 'web' 'worker' 'all')
;;
production)
# Only show production services if approved
if [[ -f ~/.deploy/.approved ]]; then
services=('api' 'web')
else
_message 'production deployment requires approval'
return 1
fi
;;
esac
_describe 'service' services
;;
esac
}
_deploy "$@"
|
Features:
- Reads environment list from file
- Different service completions per environment
- Blocks production unless approved
- Supports flags with descriptions
- Handles
--help and --dry-run
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| _arguments \
# Flags
'-v[Verbose]' \
'(-q --quiet)'{-q,--quiet}'[Quiet mode]' \
# Flags with values
'--port[Port]:port:(8000 8080 3000)' \
'--config[Config]:file:_files -g "*.yml"' \
# Optional arguments
'::optional arg:_files' \
# Multiple arguments
'*:files:_files' \
# Exclusive flags (can't use together)
'(--json --yaml)--json[JSON output]' \
'(--json --yaml)--yaml[YAML output]' \
# Position-specific
'1:command:(start stop restart)' \
'2:target:_directories'
|
Symbols:
'1:' - First position argument'::' - Optional argument'*:' - Multiple arguments allowed'(-a -b)' - Mutually exclusive with -a and -b
What’s Next
Use completions immediately:
- Build completion for your deployment scripts
- Add completion for project-specific commands
- Cache expensive API calls for fast tab completion
The completion system is ZSH’s killer feature. Now you know how to use it.