Describing Switches
One thing that official fish documentation for completions doesn’t highlight is how to efficiently add completions together with their descriptions. It focuses on code like that:
complete -c foo -a arg1 -d "help for arg1"
complete -c foo -a arg2 -d "help for arg2"
This is fine if command arguments are static. But consider a completion for
kill command. We would like to complete a process number and display a
helpful hint with a process name:
$ kill <TAB>
1 (systemd) 276 (watchdogd) 2468 (syncthing) 83533
2 (kthreadd) 281 (kworker/R-USBC0) 2470 (ydotoold) 85325
3 (pool_workqueue_) 283 (kworker/R-ttm) 2481 (gpg-agent) 85326
4 (kworker/R-rcu_g) 284 (card0-crtc0) 2489 (mpris-proxy) 85335
5 (kworker/R-sync_) 285 (card0-crtc1) 2492 (wireplumber) 85478
To do this, we can convert output of ps:
$ ps axo pid,comm --no-headers
ps axo pid,comm
1 systemd
2 kthreadd
3 pool_workqueue_release
4 kworker/R-rcu_gp
5 kworker/R-sync_wq
complete -a accepts a string which defines many completions at once, each on a
separate line. Each line may provide both completed argument and its
description, separated by a tab character (\t).
Command Substitutions
Converting the output of ps is just a matter of running it through a simple
sed, but we must put the whole logic in a string, which fish will lazily
evaluate at the correct time. On top of it, fish applies its tokenization rules
for command substitutions.
(sidenote: In short: it splits strings on
newline characters unless the command is piped through string collect, string
split or string split0 (source) )
Consider this illustration of what we get in fish for different forms of command substitutions:
function foo
echo 1
echo 2
end
$ echo (foo)
1 2
$ echo "(foo)"
(foo)
$ echo $(foo)
1 2
$ echo "$(foo)"
1
2
$ echo $(foo | string collect)
1
2
Only echo "(foo)" doesn’t evaluate immediately, so we’re interested in
something like this for our completion script. In other words, we will write a
function which fish evaluates only when completion occurs:
function _ps
ps axo pid,comm --no-headers | sed -E 's/ ([0-9]+) +(.*)/\1\t\2/'
end
complete -c kill -a "(_ps)"
Done.
Side Effects
A quirk with such functions is that they shouldn’t have side effects, because fish evaluates them in the calling shell: in the one which the user uses. For example, we can’t change directory in the script, because it would change the working directory of user who pressed TAB.
In other shells we would create a subshell to sandbox side effects, but fish
doesn’t implement the concept of subshells. The only way to create one is to
call fish -c ..., but it’s not possible to run a function like that, because
fish doesn’t have equivalent of export -f from Bash.
If we really must change a directory in _ps function, we must resolve to the
old pushd/popd trick.
Performance Issues
Using a function to produce a fish-compatible definition of completions is
especially useful if we use commands that operate on many files at once, like
cat or awk. There’s a noticable delay in generating completions when we
repeatedly run awk versus when we run it once.
Consider this situation: we have a set of mg-* scripts which define
subcommands for a main mg command. Each script has a
special comment which describes its purpose. The comment starts with #/
characters and it exists only for a purpose of completion scripts.
(sidenote: This isn’t imaginary situation. I’ve created this system for my
personal scripts and I’m very fond of it.)
We could create completions like this:
for f in "$subcdir/mg-"*
set --local help (awk '/#\/.+/ && !/[Uu]sage:/ {sub(/^#\/\s*/, ""); print; exit 0}' "$f")
set --local subc (path basename "$f" | string replace "mg-" "")
complete -c mg -n "not __fish_seen_subcommand_from $subcommands" -a "$subc" -d "$help"
end
but it introduces a noticeable delay, because we must run awk 15+ times.
Instead, we can refactor the completion script to this:
(sidenote: BEGINFILE is gawk extension.)
function _subc_descriptions
awk 'BEGINFILE { res[FILENAME] = "" }
/#\/.+/ && !/[Uu]sage:/ {
if (res[FILENAME] == "" && sub(/^#\/\s*/, "") > 0) {
res[FILENAME]=$0
}
}
END {
for (filename in res) {
printf("%s\t%s\n", filename, res[filename])
}
}' "$argv[1]/mg-"* | path basename | string replace mg- ''
end
complete -c mg -n "not __fish_seen_subcommand_from $subcommands" -a "(_subc_descriptions "$subcdir")"
which makes our fish snappy again.