ferkakta.dev

I was wrong about shell completions for 15 years

I thought shell completions were tab-operated Jinja — fill in the blank, template-style. Type half a filename, press tab, get the rest. That’s all I thought they did.

And the AWS CLI completer kept confirming that impression in the worst way. When you’re typing aws s3 cp somefile.txt s3://bucket/, the completer hijacks tab on the local path argument and sits there forever trying to guess AWS-side completions that will never come. You’re done with the AWS part of the command. You just want to tab-complete a filename. It won’t let you. AWS hogs the empty space where your possibilities want to go, because it thinks it’s important to block your progress in case you wanted to speak to any of its 867 pet names. As John Roderick and Merlin Mann put it on Roderick on the Line: keep moving and get out of the way.

I killed it years ago with a one-liner in my zshrc:

# get rid of the insane lag autocompleting local paths in aws commands
unset '_comps[aws]'

On other people’s machines — a boss’s GPU box, an EC2 instance — where I won’t edit the shell config because it’s not mine, I prepend echo once the AWS portion of the command is ready. echo claims the talking stick — zsh looks up echo in the dispatch table instead of aws, finds no completer, and falls back to _files. I’ve been using this blindfold for years. I only just understood why it works: #compdef aws binds to the command name, and echo replaces it.

So I’d written off the whole completion system. Tab was either a dumb template fill or a hostile obstruction. I never explored what it could do when the completers were actually good.

The accidental discovery

I needed to tell someone in Italy my availability in their timezone. I was about to write a timezone lookup function because I refused to google it one more time. Then I typed TZ= and hit tab.

Continents appeared. I typed E, tabbed — Europe/. R, tabbed — Riga, Rome. o, tabbed — TZ=Europe/Rome. Seven keystrokes and four tabs. Every timezone I’ve ever struggled to remember was one keystroke away, and I was already typing the prefix.

I could have written that lookup tool and spent years feeling smug about it, never knowing that TZ=[tab] was there all along. For lack of knowing about this one affordance, my life was genuinely impoverished by being uncultured.

(The full discovery walkthrough with all the tab outputs is a separate TIL.)

The machinery

Everything I’m about to explain, I learned in the hour after TZ=[tab] worked. I had never read a #compdef directive, never looked inside a completion function, never understood why my unset or my echo trick worked. I just knew they worked. The _time_zone discovery sent me down a rabbit hole, and I came out understanding a system I’d been using — and abusing — for 15 years.

I had to know where the names were coming from. I grepped zsh’s function path for TZ-specific logic and found one hit: a function called _time_zone.

$ cat /usr/share/zsh/5.9/functions/_time_zone
#compdef -value-,TZ,-default-
local _zoneinfo_dirs=(/usr/{share,lib,share/lib}/zoneinfo(/N))
(( $#_zoneinfo_dirs )) &&
  _wanted time-zones expl 'time zone' _files -g '[A-Z]*' \
    -M 'm:{a-z}={A-Z}' -W _zoneinfo_dirs

Four lines, and every one is doing something clever.

The #compdef line is not executable code. The # makes it a comment as far as zsh’s function parser is concerned — but compinit, the completion system’s initializer, reads these files and parses the #compdef lines to build a dispatch table. It’s a routing declaration, hidden in a comment, read by a different reader than the one that runs the function. Think route matcher, not template tag.

The directive -value-,TZ,-default- is three tokens separated by commas. -value- means this completer fires in the value position of a variable assignment — after the =, not after a command name. TZ is the variable it cares about. -default- means it’s the fallback handler for that context. The whole thing reads: “when someone tabs after TZ=, anywhere in any command, call me.”

Compare the AWS completer:

$ head -1 /opt/homebrew/share/zsh/site-functions/_aws
#compdef aws

#compdef aws — binds to the command name. Every tab after aws goes through this function, every argument position, no matter what you’re trying to complete. The timezone completer scopes itself to one variable in one position. The AWS completer claims the entire command line.

Both directives register themselves in _comps, the associative array that maps patterns to functions. That’s the same array I surgically deleted aws from with unset '_comps[aws]'. The one-liner worked because the dispatch table is just a hash — remove the key, remove the route.

The -g '[A-Z]*' glob filters out the POSIX compatibility junk in zoneinfo so you only see real regions and cities. The -M 'm:{a-z}={A-Z}' flag means case-insensitive matching — TZ=e[tab] works just as well as TZ=E[tab]. And the real stroke: it uses _files, the filesystem completer. There is no lookup table. The IANA tz database is already a directory tree under /usr/share/zoneinfo/, and the completer just walks it. And because TZ= isn’t positional, it works anywhere on the command line — TZ=Europe/Rome date or date TZ=[tab], the completer doesn’t care.

No package to install. No plugin. No configuration. The vocabulary was already on the filesystem. The completion function has been in zsh for decades, wiring it to the tab key, waiting for someone to press it.

What I was wrong about

I thought completions were autocomplete — finish the word I started typing. Tab-operated Jinja. AWS kept ruining it, so I turned off the whole system and never looked back.

They’re not autocomplete. They’re a vocabulary construction kit of unparalleled power and elegance. Someone wrote a four-line program that turns the filesystem into a navigable menu, and that same machinery is available to anyone who writes a #compdef. The completion system isn’t finishing your words. It’s teaching you words you don’t know yet.

The irreconcilable tension (that isn’t)

Is the conflict between command completion and local path completion actually irreconcilable? It isn’t. The _time_zone completer proves the design pattern: it only fires in the value position after TZ=, not everywhere. It knows its scope. The AWS completer fires on every argument position and assumes every tab is asking about AWS — it doesn’t know when to shut up.

The AWS completer feels like an oversized car parked across three handicapped spots while blocking the exits. The fix isn’t hard. If the current argument looks like a local path — starts with ., /, ~, or contains a / — yield to _files. Better yet: if I type z[tab] and there’s a file called zones.csv in my working directory, show it alongside the AWS subcommands. Don’t make me fully qualify the path just to get local files into the menu. If you’re going to claim every completion opportunity in your zip code, you’re also responsible for contextual awareness of what the user might want. Or you can just get out of the way. The timezone completer does less, and that’s why it’s elegant. It walks a directory tree that’s already there and presents it as a self-revealing affordance. Each tab unfolds the next layer. You don’t have to memorize anything. You unwrap it.

The difference between a bad completer and a good one isn’t performance. It’s whether the completer respects what you’re actually trying to do next. The AWS completer assumes you need AWS. The timezone completer assumes you need a word. One is a product that hogs the stage. The other is a tool that keeps moving and gets out of the way.

Zsh completers are ancient protagonist support, made of ambient vocabulary imputation. The shell already knew the words. It was waiting for me to ask.

#zsh #cli #platformengineering