9 minute read

Zsh’s completion system allows for you to display arbitrary display strings for items being completed.

a basic example:

_foo() {
  # you usually shouldn't use _describe with this literal
  # array syntax.
  _describe "abc's" '(a b c)' '(1 2 3)'
}
compdef _foo foo

In the example above when performing completion for the command foo, zsh will use the display strings a b c for the match strings 1 2 3; which are possible choices to insert into the command line.

If you’ve ever seen zsh’s completion descriptions, then you were looking at display strings.

% ls --color=
 --color  :: control use of color
 -,       :: print file sizes grouped and separated by thousands
 -1       :: single column output
 -A       :: list all except . and ..
 -a       :: list entries starting with .
 -b       :: as -B, but use C escape codes whenever possible
 -B       :: print octal escapes for control characters
 -C       :: list entries in columns sorted vertically
 -c       :: status change time
...

Those descriptions are templated display strings made up of the match string, possible padding, a seperator and a string of text describing the match string. The various utility functions for the completion system abstracts that away from the author of a completer.

Even _describe can do so:

_foo() {
  local -a values=('1:ah... one' '2:ah two...' '3:ah three.')
  _describe 'my tag description' values
}
compdef _foo foo
---
% foo 1
Completing: my tag description
 1  :: ah... one
 2  :: ah two...
 3  :: ah three

So on to _pids, when a completer completes process IDs using _pids you will see zsh do something pretty cool.

% kill 683090
Completing: process ID
683090 pts/7    00:00:00 zsh
683124 pts/7    00:00:00 zsh
683127 pts/7    00:00:00 ps

_pids uses the command ps to get available processes and uses the output as the display strings for the process id match strings. I never knew how it worked, was curious and poked around to figure it out.

A long time user may know that you can customize that command used since ps without any additional options usually only display processes running in the same TTY and EUID as the user.

zstyle ':completion:*:processes' command \
  'ps -o pid,user,lstart,pcpu,pmem,rss,args -A'
zstyle ':completion:*:processes' format \
  'Completing: %d (pid user lstart %%%cpu %%%mem rss args)'
---
% kill 100
 Completing:  process ID (pid user lstart %cpu %mem rss args)
    100 root     Fri Sep 10 16:14:41 2021  0.0  0.0     0 [mld]
    101 root     Fri Sep 10 16:14:41 2021  0.0  0.0     0 [ipv6_addrconf]
    102 root     Fri Sep 10 16:14:41 2021  0.0  0.0     0 [kworker/0:1H-kblockd]
     10 root     Fri Sep 10 16:14:41 2021  0.0  0.0     0 [rcu_tasks_kthre]
    114 root     Fri Sep 10 16:14:42 2021  0.0  0.0     0 [kstrp]
    119 root     Fri Sep 10 16:14:42 2021  0.0  0.0     0 [zswap1]
...

So how would we mimic what _pids did here? well lets look at how _pids works.

#compdef pflags pcred pldd psig pstack pfiles pwdx pstop prun pwait

# If given the `-m <pattern>' option, this tries to complete only pids
# of processes whose command line match the `<pattern>'.

local out pids list expl match desc listargs all nm ret=1

_tags processes || return 1

if [[ "$1" = -m ]]; then
  all=()
  match="(*[[:blank:]]|)${PREFIX}[0-9]#${SUFFIX}[[:blank:]]*(/|[[:blank:]]-(#c,1))${2}([[:blank:]]*|)"
  shift 2
elif [[ "$PREFIX$SUFFIX" = ([%-]*|[0-9]#) ]]; then
  all=()
  match="(*[[:blank:]]|)${PREFIX}[0-9]#${SUFFIX}[[:blank:]]*"
else
  all=(-P "$IPREFIX" -S "$ISUFFIX" -U)
  match="*[[:blank:]]*[[/[:blank:]]$PREFIX*$SUFFIX*"
  nm="$compstate[nmatches]"
fi

So the sake of staying on topic I am going to skip the #compdef magic comment and the comment about -m details what the first branch of the if statement is doing. _tags is a function that handles valid tags in a given completer; tags being a namespace for match strings. The match strings we add here will be added to the processes tag/namespace. Largely unimportant right now but i still wanted to explain it.

The second conditional is checking if the word that is currently attempted to be completed, if any, matches the various ways a pid is normally given on a command line. %jobnumber, -processgroupid, or processid. If so, gives a pattern that will eventually be used to parse the output of ps. This is largely the important part.

The last conditional does something pretty clever that I was unaware of but won’t explain here. But the TL;DR is, it helps implement the insert-ids style.

So the next chunk of code is:

while _tags; do
  if _requested processes; then
    while _next_label processes expl 'process ID'; do
      out=( "${(@f)$(_call_program $curtag ps 2>/dev/null)}" )
      desc="$out[1]"
      out=( "${(@M)out[2,-1]:#${~match}}" )

      if [[ "$desc" = (#i)(|*[[:blank:]])pid(|[[:blank:]]*) ]]; then
        pids=( "${(@)${(@M)out#${(l.${#desc[1,(r)(#i)[[:blank:]]pid]}..?.)~:-}[^[:blank:]]#}##*[[:blank:]]}" )
      else
        pids=( "${(@)${(@M)out##[^0-9]#[0-9]#}##*[[:blank:]]}" )
      fi
...

The first three lines are related to tag stuff but the fourth line is what allowed us to change the ps command used to get match strings via zstyle. When you have a command that a user may want to change, you use _call_program to do so.

desc=$out[1] saves the header that ps prints. So for _pids to work reliably, if you change the ps command used, try to ensure you do not ommit the headers.

out=( "${(@M)out[2,-1]:#${~match}}" ) parses the rest of the output, saving only the lines that matches the pattern saved in the first snippet.

if [[ "$desc" = (#i)(|*[[:blank:]])pid(|[[:blank:]]*) ]] this line is checking if $desc is indeed the header line and that it contains PID.

If so, it does this beastly of a parameter expansion.

pids=( "${(@)${(@M)out#${(l.${#desc[1,(r)(#i)[[:blank:]]pid]}..?.)~:-}[^[:blank:]]#}##*[[:blank:]]}" )

That parses out the pids based on the location of the PID column in the output.

Should $desc not contain PID though

pids=( "${(@)${(@M)out##[^0-9]#[0-9]#}##*[[:blank:]]}" )

fallback and just parse out any words in the output that are all numbers.

At this point $pids contain the match strings while $out contain our display strings (the latter will be saved in another array). The code below checks the verbose style, which is the idiomatic way of determining whether or not to use display strings (display descriptions) when using low level completion tools (e.g: not a helper function like _arguments, _describe, etc) and if so add the necessary arguments to pass to compadd.

compadd "$@" "$expl[@]" "$desc[@]" "$all[@]" -a pids && ret=0

So now that we understand how _pids work, how can we do it with something we care about?

For this post I will complete OCI image IDs and use podman to do so.

% podman images
REPOSITORY                                 TAG         IMAGE ID      CREATED        SIZE
docker.io/crystallang/crystal              latest      e7dac73e9072  7 weeks ago    522 MB
localhost/findimagedupes                   latest      fbfc841e5055  3 months ago   399 MB
...

podman presents them as a 12 character hexadecimal number so my function will do the same.

I will also use _pids as a base for my _podman_images.

#compdef foo

local out images list expl match desc listargs all nm fmt ret=1

_tags images || return 1

if [[ "$PREFIX$SUFFIX" = [[:xdigit:]]# ]]; then
  all=()
  match="(*[[:blank:]]|)${PREFIX}[[:xdigit:]]#${SUFFIX}[[:blank:]]*"
else
  all=(-P "$IPREFIX" -S "$ISUFFIX" -U)
  match="*/${PREFIX}[^[:space:]]$SUFFIX*"
  nm="$compstate[nmatches]"
fi

# allow an easier way to change the display string, versus specifying an entire command
# including this template.
if ! zstyle -s ":completion:${curcontext}:images" template fmt; then
  fmt='table {{.Repository}} {{.Tag}} {{.ID}} {{.Created}} {{.Size}}'
fi

while _tags; do
  if _requested images; then
    while _next_label images expl 'image ID'; do
      # programs and their arguments given to _call_program are eval'd, so ${(q-)...} preserves quoting
      out=( "${(@f)$(_call_program $curtag podman images --format ${(q-)fmt} 2>/dev/null)}" )
      if [[ $out[1] = *(#i)'image id'* ]]; then
        desc="$out[1]"; shift out
      fi
      out=( "${(@M)out:#${~match}}" )

      # if the first line of output is the header, which what the default _call_program has
      if [[ "$desc" = (#i)(|*[[:blank:]])'IMAGE ID'(|[[:blank:]]*) ]]; then
        # uses the length of the header, + 4 (since the fields are left adjusted) to trim anything after the IMAGE ID column
        # then trim anything before the IMAGE ID column
        images=( "${(@)${(@M)out#${(l.${#desc[1,(r)(#i)[[:blank:]]image id]}+4..?.)~:-}[^[:blank:]]#}##*[[:blank:]]}" )
      else
        images=( "${(@)${(@M)out#*[[:space:]]#[[:xdigit:]](#c12)}##*[[:blank:]]}" )
      fi

      if zstyle -T ":completion:${curcontext}:$curtag" verbose; then
        list=( "${(@Mr:COLUMNS-1:)out}" )
        desc=(-ld list)
      else
        desc=()
      fi
      compadd "$@" "$expl[@]" "$desc[@]" "$all[@]" -a images && ret=0
    done
  fi
  (( ret )) || break
done

if [[ -n "$all" ]]; then
  zstyle -s ":completion:${curcontext}:images" insert-ids out || out=menu

  case "$out" in
  menu)   compstate[insert]=menu ;;
  single) [[ $compstate[nmatches] -ne nm+1 && $compstate[insert] != menu ]] &&
              compstate[insert]= ;;
  *)      [[ ${#:-$PREFIX$SUFFIX} -gt ${#compstate[unambiguous]} ]] &&
              compstate[insert]=menu ;;
  esac
fi

return ret

The important changes are documented and the function will achieve what _pids does.

% foo 7f6f3d95821b
 Completing:  image ID
docker.io/crystallang/crystal              latest      e7dac73e9072  7 weeks ago    522 MB
docker.io/library/archlinux                base        3de742be9254  5 months ago   416 MB
docker.io/library/archlinux                base-devel  7f418864de94  5 months ago   730 MB
docker.io/library/php                      fpm         7f6f3d95821b  5 months ago   415 MB
docker.io/library/ubuntu                   latest      f643c72bc252  9 months ago   75.3 MB

This alone may not be the most desired thing to have complete say, podman image rm since in addition to image ids, it can also accept repository:tag as argument. So a user may be used to using podman image rm docker.io/library/archlinux not podman image rm 3de742be9254 to delete images.

This is where the else clause of the if condition in _pids comes into play.

...
else
  all=(-P "$IPREFIX" -S "$ISUFFIX" -U)
  match="*[[:blank:]]*[[/[:blank:]]$PREFIX*$SUFFIX*"
  nm="$compstate[nmatches]"
fi
...

If the user were to type in an alphanumeric string before attempting to complete an process id, _pids will use that string to match against the output of ps and for any line that the string matches, present the pid of the line as a choice in the match strings.

% kill firefox<tab>
---
% kill 447001
 Completing:  process ID (pid user lstart %cpu %mem rss args)
 447001 llua     Mon Sep 13 09:41:02 2021  0.0  1.1 180872 /usr/lib/firefox/firefox -cont
 447032 llua     Mon Sep 13 09:41:03 2021  0.1  1.4 230308 /usr/lib/firefox/firefox -cont
   4866 llua     Fri Sep 10 16:16:03 2021  3.9  4.2 680364 /usr/lib/firefox/firefox
   4934 llua     Fri Sep 10 16:16:04 2021  0.7  1.5 250652 /usr/lib/firefox/firefox -cont
   4970 llua     Fri Sep 10 16:16:05 2021  0.4  1.7 286452 /usr/lib/firefox/firefox -cont
   4974 llua     Fri Sep 10 16:16:05 2021  0.5  2.7 439960 /usr/lib/firefox/firefox -cont
   4978 llua     Fri Sep 10 16:16:05 2021  0.3  3.6 585772 /usr/lib/firefox/firefox -cont
   4982 llua     Fri Sep 10 16:16:05 2021  2.4  2.2 368488 /usr/lib/firefox/firefox -cont
   5049 llua     Fri Sep 10 16:16:05 2021  0.5  0.6 101100 /usr/lib/firefox/firefox -cont
   5113 llua     Fri Sep 10 16:16:05 2021  1.4  2.7 445104 /usr/lib/firefox/firefox -cont
   5384 llua     Fri Sep 10 16:16:11 2021  0.6  0.4 71556 /usr/lib/firefox/firefox -conte
   8701 llua     Fri Sep 10 16:32:48 2021  0.2  1.3 211284 /usr/lib/firefox/firefox -cont

With our copycat doing the same

% foo archl<tab>
---
% foo 3de742be9254
 Completing:  image ID
docker.io/library/archlinux                base        3de742be9254  5 months ago   416 MB
docker.io/library/archlinux                base-devel  7f418864de94  5 months ago   730 MB
localhost/archlinux-tools                  latest      56a21332be3d  5 months ago   848 MB

Ultimately, using the output of a command as a display string it is a creative way of using menu selection. Something that is as far as i am aware, uniquely possible in zsh. At the same time, now that i know how to do it, I am still not eager make as much of my personal completers do the same since i can easily see this become a pain to maintain for more complicated programs.

Comments