Hacking up your own shell completion
In this post I’ll be walking through a simple hack I added to my shell that I think makes me more productive (if nothing else it makes me happy).
Note that all this applies to most common shells, but my examples will be using
fish
since that’s what I’ve been using as of late.
I’ll also be talking about using fzf as a part of this hack.
You can use fzf
or not with this idea, but I like the feel of it :).
Introducing: My Problems
As a full-stack (frontend, backend, and firmware) software engineer, I use quite a few tools to build, test, run, version control, and style check the code I write.
At my company we built this pretty simplistic go program that is really a collection of scripts for building and testing out parts of our application.
We’ll call the tool doer
for fun.
Those things can be listed using the command doer -list
.
No one really bothered making autocompletion scripts for 3+ shells used across the company for an internal tool, but I was having trouble remembering the exact abbreviations involved in the command I wanted to run.
Script It!
Some people at my company use fzf
. For the uninitiated, fzf
is a program that lets you select
from multiple options using a fuzzy find search.
The cool thing about fzf
is that you can send it a newline separated list of items,
and it’ll handle all the user interaction to select one and return the selected
item.
Here’s an example of using fzf
to choose a line from a file and echo it.
I’ve often used fzf
as a file finder, you can simply run something like git ls-files | fzf
, and pass
the output to your editor, and you’ve already made a git aware fuzzy file finder, running this inside
your editor like fzf-vim makes this even more powerful.
One quick solution to our doer
problem is to write a bash script like this:
#!/bin/bash
doer $(doer -list | fzf)
Here we just use command substitution to run doer
’s list command, pass it to fzf
, then run doer
with the result.
Here’s it in action:
This works really well, but unfortunately you lose shell history when you run this command.
For example, lets say I finally figure out through the fuzzy completion version of doer
that I really
want to run doer gen/other_thing
, because it generates a file based on one I’m currently modifying.
So I run the command once, fuzzy selecting my option, modify some code, then return to my shell to quickly repeat the command I had just tried.
After a quick CTRL-P
(or up arrow), or CTRL-R
, I find that the only thing in my
history is doer-fuzzy
.
That means I have to fuzzy find my option again and rerun, and it means I don’t have a great history
of what I’ve run in the past. What a drag!
Maintaining History
My first thought here was to somehow append the underlying command being run by doer-fuzzy
to my shell
history, but I quickly found out that this is hard, hacky, and no one really does this.
So really what we want is to trigger argument completion in the shell, so that by the time I hit return to trigger the command, the full command I’m interested in is on the shell already.
Oh wait… that sounds like just normal shell completion, you the know, the tab-tab-tab-tab-tab approach.
Okay fine, I guess I can add proper shell completion to the command using fish
’s built in complete
command,
add it to the repo, and set up an install script. But now that I think about it, I have several commands
I use that don’t support completion (not just doer
), or complete too much stuff for my 90% use case,
or just support so many options its a bit painful to autocomplete through tab key wear out.
What if there was a way to strap an fzf
completion thing into my shell, that I fully control through
just a couple lines of fish
script, that doesn’t interfere with built-in completion, and allows me to
quickly add bindings to any command I frequently run?
Introducing Personalized FZF Completion!
fzf
already sort of does this type of thing when you install the fzf
bindings to your shell.
Regardless of your currently typed command, you can hit CTRL-T
to search for a list of files in
your current directory. For example, lets say we’re trying to open a file in nvim
.
What if we could copy how CTRL-T
works, but replace the hardcoded fzf
command with a contextual one
that we control?
Here’s a basic fish
script to accomplish the core part of this. Basically we just match
on any commands that are prefixed with doer
, and return an appropriate autocompletion function
for that command.
function get-completion-command
set -l cmd (commandline)
switch $cmd
case 'doer *'
echo 'doer -list'
case '*'
return 1
end
end
This function gets called in a skeleton version of fzf
’s CTRL-T
command.
function fzf-smart-completion -d "List files and folders"
set -l commandline (__fzf_parse_commandline)
set -l dir $commandline[1]
set -l fzf_query $commandline[2]
# use our cool new completion checker
set -l FZF_CMD (get-completion-command); or return
# fzf edge case and formatting (prevents fzf from taking up the whole screen)
set FZF_HEIGHT 40%
begin
set -lx FZF_DEFAULT_OPTS "--height $FZF_HEIGHT --reverse $FZF_DEFAULT_OPTS $FZF_CMD[2]"
eval "$FZF_CMD[1] | "(__fzfcmd)' -m --query "'$fzf_query'"' | while read -l r; set result $result $r; end
end
if [ -z "$result" ]
commandline -f repaint
return
else
# Remove last token from commandline.
commandline -t ""
end
for i in $result
commandline -it -- (string escape $i)
commandline -it -- ' '
end
commandline -f repaint
end
bind -M insert \et fzf-smart-completion
bind \et fzf-smart-completion
With this little bit of fish
scripting, we now have a pretty cool ALT-T
command that runs
our own fuzzy autocomplete like so.
Extensions
This got me thinking, what other commands could benefit from this type of completion?
One example is go test
, which for my use cases, should only run on source controlled files ending
in _test.go
. There’s no reason to autocomplete every file in my repo, just the test ones is perfect!
The same idea applies to our frontend test runner, if I’m typing yarn test
, I probably
only want to see typescript files ending with our .test
convention.
function get-completion-command
set -l cmd (commandline)
switch $cmd
case 'doer *'
echo 'doer -list'
case 'go test *'
echo 'git ls-files | grep _test.go'
case 'yarn test *'
echo 'git ls-files | grep .test.ts'
case '*'
return 1
end
end
You can see the go tester in action here:
Another useful thing I’ve found is automating some routine git
commands.
Obviously there exist quite a few git
UIs that try to allow you to use git
more quickly than the
CLI interface, but I have always come back to the CLI because frankly (1) there’s more documentation, and
(2) it doesn’t have performance issues on massive monorepos that I’ve seen in every git
UI (cough cough
magit).
Good git
autocomplete is awesome and likely used by everyone, but we can extend it using this same
interface.
Simply adding the following gives you a git add
command that completes only changed files and
allows showing a toggleable preview of the file changes.
case 'git add *'
echo 'git diff --name-only'
echo '--bind='ctrl-space:toggle-preview' --preview \'git diff --color=always {}\' -m'
Finish
If you look hard enough at these scripts, you’ll find there are flaws or gaps in this completion system.
A common example for me is my git checkout
completion. I simply list local branches as targets. This obviously
ignores a lot of different targets to the git checkout
command including commits, specific files, remote branches,
and tags, but for me, > 90% of the time I’m using it to just switch branches.
On top of that, using this new framework doesn’t break or invalidate any other tool you have, I frequently
fall back to fish
’s git
completion when I’m doing something more specific, but I’m happy that
my most common access patterns are now a bit faster.
This pattern also doesn’t add a layer of abstraction over the CLI tools you use, your shell history still is useful, you use the same CLI tools as everyone else, you just hopefully save yourself a bit of typing.
I’m quite sure this sort of thing would be pretty trivial to get working
in both bash
and zsh
since they support fzf
’s CTRL-T
command as well. If I get a lot of long term
use out of this maybe I’ll try getting a similar idea running in a few shells and share those scripts.