The completions/
directory stores scripts which are used to enable word completion in Bash and zsh
shells, respectively. You can activate RBENV’s completion logic by adding the proper shell integration command to your shell’s config file.
In Bash, that means adding the code eval "$(rbenv init - bash)"
to your .bashrc
file. In zsh
, you’d add eval "$(rbenv init - zsh)"
to your .zshrc
file. From there, rbenv-init
will source
the completion files via this block of code:
completion="${root}/completions/rbenv.${shell}"
if [ -r "$completion" ]; then
printf "source '%s'\n" "$completion"
fi
What about the fish
shell? Per the fish
shell docs:
Fish automatically searches through any directories in the list variable
$fish_complete_path
, and any completions defined are automatically loaded when needed.
In other words, fish
automatically supports certain word completions out-of-the-box, by searching for executables in certain pre-defined directories.
Why are completions useful?
Completions are pretty handy if you’re searching for a certain command. For example, in your command line, type rbenv
and hit the tab
key. When I do so, I get the following:
$ rbenv
--version global install root version version-name which
commands help local shell version-file version-origin
completions hooks prefix shims version-file-read versions
exec init rehash uninstall version-file-write whence
These are all the commands that rbenv
exposes. If I type rbenv r
and hit tab
, I see all the commands which start with r
:
$ rbenv r
rehash root
That’s completions in a nutshell. There are two files in the completions/
directory:
rbenv.zsh
, andrbenv.bash
.
Let’s inspect the files in that order.
rbenv.zsh
The code for rbenv.zsh
looks like so:
if [[ ! -o interactive ]]; then
return
fi
compctl -K _rbenv rbenv
_rbenv() {
local words completions
read -cA words
if [ "${#words}" -eq 2 ]; then
completions="$(rbenv commands)"
else
completions="$(rbenv completions ${words[2,-2]})"
fi
reply=("${(ps:\n:)completions}")
}
As usual, we’ll break it up into sections.
Checking whether an option is on
The first 3 lines of code are:
if [[ ! -o interactive ]]; then
return
fi
What does -o
do? The man test
page references an -o
flag, but it’s actually a different flag, i.e. expression1 -o expression2
is true if either expression1
or expression2
is true. We don’t have 2 expressions in our case, so we have to search elsewhere for an answer.
StackOverflow comes to the rescue:
-o : True if shell option "OPTIONNAME" is enabled.
Let’s test the -o
flag with an experiment.
Experiment- the test
command’s -o
flag
I run the following in my terminal:
$ set +o verbose
$ [[ -o verbose ]] && echo "TRUE"
In the above code, we use set +o verbose
to turn the verbose
option off. The result is that TRUE
was not printed to the screen.
Next, we’ll use set -o verbose
to turn the same option on:
$ set -o verbose
$ [[ -o verbose ]] && echo "TRUE"
[[ -o verbose ]] && echo "TRUE"
TRUE
When we turn on the verbose
option and run our test, we see the test itself (because verbose
mode means any executed commands will also be printed to the screen), followed by the string TRUE
that we expected to see.
The interactive
option
So we’re testing whether a certain option is on, namely the interactive
option. What is the ‘interactive’ option, you ask? The Linux Documentation Project give us an answer:
An interactive shell reads commands from user input on a
tty
. Among other things, such a shell reads startup files on activation, displays a prompt, and enables job control by default. The user can interact with the shell.A shell running a script is always a non-interactive shell.
And StackOverflow fills in some of the gaps in the above answer:
Interactive: As the term implies: Interactive means that the commands are run with user-interaction from keyboard. E.g. the shell can prompt the user to enter input.
Non-interactive: the shell is probably run from an automated process so it can’t assume it can request input or that someone will see the output. E.g., maybe it is best to write output to a log file.
So we’re checking whether we’re interacting with the user or with a shell script. If we’re not interacting with a user, we exit out of the rbenv.zsh
script via the return
keyword.
Adding completions to the rbenv
command with the compctl
keyword
Next line of code is:
compctl -K _rbenv rbenv
compctl
is a zsh
builtin, so we’ll need to use the help
command to view its docs:
ZSHCOMPCTL(1) General Commands Manual ZSHCOMPCTL(1)
NAME
zshcompctl - zsh programmable completion
DESCRIPTION
Control the editor's completion behavior according to the supplied set of options...
...
-K function
Call the given function to get the completions. Unless the name starts with an
underscore, the function is passed two arguments: the prefix and the suffix of
the word on which completion is to be attempted, in other words those characters
before the cursor position, and those from the cursor position onwards. The
whole command line can be accessed with the -c and -l flags of the read builtin.
The function should set the variable reply to an array containing the completions
(one completion per element); note that reply should not be made local to the
function.
In summary, compctl -K
tells the shell that, when trying to autocomplete rbenv
in the terminal, we first call the _rbenv
shell function. That function will set the value of a reply
shell variable equal to an array of… something (we’ll dig into that next). The instructions explicitly tell us not to use the local
keyword on the reply
variable. This way, it will be available outside the _rbenv
shell function, after it has been set.
Declaring the _rbenv
helper function
Next block of code is:
_rbenv() {
local words completions
read -cA words
We declare the _rbenv
function. The implementation starts with the declaration of two local variables- words
and completions
.
Next we use the read
command (which we learned about when discussing the rbenv versions
command) to populate the words
variable.
Checking the help
page for the -c
and -A
flags for read
, we see:
-c
-l These flags are allowed only if called inside a function
used for completion (specified with the -K flag to compctl).
If the -c flag is given, the words of the current command
are read.
-A The first name is taken as the name of an array and all
words are assigned to it.
We read all the words of the command, and store them in an array variable, which in this case is named words
.
Let’s try this for ourselves with an experiment.
Experiment- building our own word completion for a new command
I make a file called foobar
in my current directory, containing the following:
#!/usr/bin/env bash
echo "Hello"
echo "args:"
echo "$@"
It just prints out a few static strings, followed by the arguments it receives.
Then I make another file (also in my current directory) named _foobar
:
#!/usr/bin/env bash
function _foobar {
reply=(foo bar baz);
}
compctl -K _foobar foobar
Note that I didn’t try to use the read
command with the -cA
flags here. We’ll do that further down, in a separate experiment.
I load _foobar
into memory via source _foobar
:
$ source _foobar
Then, when I type ./foobar
in my terminal (with a space at the end) and hit the tab key, the3 words in my reply
array automatically appear:
$ ./foobar
bar baz foo
When I hit tab
again, the terminal auto-completes with the first option in the list:
$ ./foobar bar
bar baz foo
And when I hit Enter
, my ./foobar
command works as expected:
$ ./foobar bar
Hello
args:
bar
Let’s move on to the next line of code.
Checking the length of the commands in the terminal
if [ "${#words}" -eq 2 ]; then
Referring back to the docs for parameter expansion we see that a #
before a variable name inside the curly braces resolves to the length of the variable, like so:
$ foo="foo bar"
$ echo "${#foo}"
7
We see 7
because the string foo bar
has 7 characters in it.
And if we pass an array instead of a string, we get the number of items in the array:
$ foo=(bar baz)
$ echo "${#foo}"
2
Therefore, our line of code:
if [ "${#words}" -eq 2 ]; then
…says “If the length of words
is equal to 2, then execute the code inside the if
block”.
If the user tab-completes with just rbenv
in the terminal
Next line of code is:
completions="$(rbenv commands)"
We set the value of a variable named completions
equal to the return value of rbenv commands
, using command substitution.
Does it store the contents as a string, or as an array?
For what it’s worth, we don’t really need to know the answer to this question. After all, we’re not checking the length of the completions
variable like we do with the words
variable. I’m mostly just curious, and I find it helpful to know what data types I’m working with in my variables.
To answer this, I need to know how to print a variable’s type in the terminal. StackOverflow has the answer:
You can use
t
parameter expansion flag:$ print -rl -- ${(t)fpath} array-special $ a=1 $ print -rl -- ${(t)a} scalar $ a=(1 2) $ print -rl -- ${(t)a} array $ typeset -A a $ print -rl -- ${(t)a} association
Note that you can’t distinguish between array of integers or array of strings.
Looks like I need to use parameter expansion, coupled with (t)
before the variable name.
With that in mind, a quick experiment.
Experiment- checking the type of a variable with (t)
# sanity check to make sure we get 'array' as expected
$ foo=(1 2 3)
$ echo "${(t)foo}"
array
# now the actual experiment
$ foo="$(rbenv commands)"
$ echo "${(t)foo}"
scalar
Great, so we’re storing the output of rbenv commands
as a single string, not as an array of strings.
Why are we checking for a length of 2?
At this point, I’m wondering why 2
is the magic number that we’re checking against the length of words
. Why not 1, or 3?
I edit the rbenv.zsh
file to include the following 4 echo
statements after read -cA words
:
if [[ ! -o interactive ]]; then
return
fi
compctl -K _rbenv rbenv
_rbenv() {
local words completions
read -cA words
echo # this line is new
echo "inside _rbenv" # this line is new
echo "words: $words" # this line is new
echo "words.length: ${#words}" # this line is new
if [ "${#words}" -eq 2 ]; then
completions="$(rbenv commands)"
else
completions="$(rbenv completions ${words[2,-2]})"
fi
reply=("${(ps:\n:)completions}")
}
In order for the updated completion file to take effect in zsh
, I have to re-run rbenv init
:
$ eval "$(rbenv init - zsh)"
Then when I type rbenv
plus a space and hit tab
, I see:
$ rbenv
inside _rbenv
words: rbenv
words.length: 2
rbenv
--version global install root version version-name which
commands help local shell version-file version-origin
completions hooks prefix shims version-file-read versions
exec init rehash uninstall version-file-write whence
So the length of words
is 2 when the user just types rbenv
and a space afterward. This represents the string rbenv
plus an empty string after the space character. We can prove this to ourselves by printing each element in words
.
I update the completion file to add the following for
loop below my previous echo
statements:
for word in "$words[@]";
do
echo "word: $word"
echo "word type: ${(t)word}"
echo "word length: ${#word}"
echo "---"
done
We’ve seen the [@]
syntax before; its function is to tell the for
loop that the value of $words
is an array, so you can iterate over it.
When I type rbenv foo bar
and hit tab, I see:
$ rbenv foo bar baz
inside _rbenv
words: rbenv foo bar baz
words.length: 5
rbenv
foo
bar
baz
word: rbenv
word type: scalar
word length: 5
---
word: foo
word type: scalar
word length: 3
---
word: bar
word type: scalar
word length: 3
---
word: baz
word type: scalar
word length: 3
---
word:
word type: scalar
word length: 0
---
The last iteration of the for
loop shows up as:
word:
word type: scalar
word length: 0
It’s got a length of zero, and it’s a scalar type so it must be either a string or an integer. Integers can’t have a length of 0, so it must be a string.
Before moving on, I make sure to delete all the echo
statements from rbenv.zsh
, and re-run the eval "$(rbenv init - zsh)"
command to reset my word completions.
If the user asked for completions for a sub-command
Next line of code:
else
completions="$(rbenv completions ${words[2,-2]})"
fi
Again, we’re storing a value in the completions
local variable, but it’s a new value now, i.e. the result of the rbenv completions
command, plus the value of ${words[2, -2]}
. Let’s start by checking what that value is.
Recall from the earlier echo
attempts that words
was equal to an array of rbenv
, foo
, bar
, baz
, and the empty string ""
. If we make a new array named words
in our terminal, we can inspect it using the ${words[2, -2]}
syntax:
$ words=(rbenv foo bar baz "")
~/Workspace/OpenSource/impostorsguides.github.io (main) $ echo "$words[2, -2]"
foo bar baz
So ${words[2, -2]}
takes the values in the array starting with the 2nd item (skipping rbenv
), and ending at the 2nd-to-last item (skipping the empty string).
For example, if I type rbenv local
, we’d reach the else
block of our code, and the completions
variable would be set to the output of rbenv completions local
. On my machine, that is:
$ rbenv completions local
--help
--unset
system
2.7.5
3.0.0
In other words, the above 5 strings, joined with newlines into a single string.
1-based arrays in zsh
Observant readers will notice that the way to access the 2nd element in the words
array was with the syntax [2,...]
, not [1,...]
. As StackOverflow notes, array positioning in zsh
is 1-based:
$ echo "$words[0]"
$ echo "$words[1]"
rbenv
$ echo "$words[2]"
foo
This is different from the 0-based indexing you may have encountered in other languages, such as Ruby:
irb(main):001:0> foo = [1,2,3]
=> [1, 2, 3]
irb(main):002:0> foo[0]
=> 1
irb(main):003:0> foo[1]
=> 2
irb(main):004:0> foo[2]
=> 3
This is something to watch out for when working with arrays in zsh
.
Storing the completions in reply
The last line of code in the file is:
reply=("${(ps:\n:)completions}")
Clearly we’re setting a (non-local) variable named reply
equal to ("${(ps:\n:)completions}")
. What does this evaluate to?
The (ps:\n:)
syntax at the start of the parameter expansion looks similar to the (t)
syntax that we encountered earlier. If we search for ps:\n
on the docs page, we see the following:
f
Split the result of the expansion at newlines. This is a shorthand for ‘ps:\n:’.
So the syntax in question takes a string containing one or more newlines, and uses those newlines as a delimiter to split the string into an array.
As a test, I run the following code in my zsh
shell:
foo="foo\nbar\nbaz\nbuzz"
bar=("${(ps:\n:)foo}")
echo "$bar"
echo "${(t)bar}"
I see the following output:
$ foo="foo\nbar\nbaz\nbuzz"
$ bar=("${(ps:\n:)foo}")
$ echo "$bar"
foo
bar
baz
buzz
$ echo "${(t)bar}"
array
Looks good to me!
In summary, the line of code…
reply=("${(ps:\n:)completions}")
…splits the output generated from either the if
or else
block using the newline \n
character as a delimiter, and then sets the reply
variable equal to that array.
Summary
To summary rbenv.zsh
:
- If we’re not running in interactive mode (i.e. if the input is coming from the computer, as opposed to from the user), we exit the script.
- Otherwise, we create a completion function named
_rbenv
and use thecompctl -K
command to tell the computer to use this function to generate completion options for therbenv
command. - This function reads the input from standard input, which
zsh
feeds it when tab completion is attempted.- If the length of this input indicates that the user hit
tab
after entering onlyrbenv
, thenzsh
uses the output ofrbenv commands
as the possible tab completions. - If the length of this input indicates that the user hit tab after entering
rbenv
plus a sub-command (such asrbenv version
orrbenv local
), thenzsh
uses the output ofrbenv completions
plus the list of sub-commands the user entered (i.e. the output ofrbenv completions version
orrbenv completions local
).
- If the length of this input indicates that the user hit
Let’s move on to the rbenv.bash
file.
rbenv.bash
The entire file looks like this:
_rbenv() {
COMPREPLY=()
local word="${COMP_WORDS[COMP_CWORD]}"
if [ "$COMP_CWORD" -eq 1 ]; then
COMPREPLY=( $(compgen -W "$(rbenv commands)" -- "$word") )
else
local words=("${COMP_WORDS[@]}")
unset "words[0]"
unset "words[$COMP_CWORD]"
local completions=$(rbenv completions "${words[@]}")
COMPREPLY=( $(compgen -W "$completions" -- "$word") )
fi
}
complete -F _rbenv rbenv
Let’s break this down.
Declaring the function
_rbenv() {
...
}
We declare the _rbenv()
function. In this case, the syntax is exactly the same in Bash as it is in zsh
.
Initializing our list of completions
Next line:
COMPREPLY=()
Here we initialize a variable called COMPREPLY
, giving it the initial value of an empty array. According to the Bash docs, COMPREPLY
functions similarly to the reply
variable in zsh
:
COMPREPLY
An array variable from which Bash reads the possible completions generated by a shell function invoked by the programmable completion facility… Each array element contains one possible completion.
So we can assume that we’ll be adding more entries into the COMPREPLY
array, and that once our function is finished executing, Bash will use the final value of COMPREPLY
to populate the completions that we see in our terminal.
Checking the current word (for which the user wants completions)
Next line:
local word="${COMP_WORDS[COMP_CWORD]}"
Again according to the docs for COMP_CWORD
:
COMP_CWORD
An index into ${COMP_WORDS} of the word containing the current cursor position. This variable is available only in shell functions invoked by the programmable completion facilities…
Furthermore, the docs go on to describe COMP_WORDS
as well:
COMP_WORDS
An array variable consisting of the individual words in the current command line.
We can verify what the docs tell us with an experiment.
Experiment- verifying the value of COMP_CWORD
I make the following script, called foo
:
#!/usr/bin/env bash
_foobar() {
echo
echo "COMP_CWORD: ${COMP_CWORD}"
echo "COMP_WORDS: ${COMP_WORDS[@]}"
echo "0th word: ${COMP_WORDS[0]}"
echo "1st word: ${COMP_WORDS[1]}"
echo "2nd word: ${COMP_WORDS[2]}"
echo "3rd word: ${COMP_WORDS[3]}"
echo "4th word: ${COMP_WORDS[4]}"
}
complete -F _foobar foo
It prints the following:
- a newline (so that the completion output is not mixed-up with the command I typed)
- the value of
COMP_CWORD
- the value of
COMP_WORDS
(which I happen to know is an array, hence the[@]
syntax at the end) - the 0th through the 4th items stored in
COMP_WORDS
I then type source ./foo
in my terminal, followed by ./foo
plus 2 arguments and a space, and I hit tab
:
bash-3.2$ ./foo bar baz
COMP_CWORD: 3
COMP_WORDS: ./foo bar baz
0th word: ./foo
1st word: bar
2nd word: baz
3rd word:
4th word:
When I cancel out of this and re-type the command with 3 arguments and a space, I see the following:
bash-3.2$ ./foo bar baz buzz
COMP_CWORD: 4
COMP_WORDS: ./foo bar baz buzz
0th word: ./foo
1st word: bar
2nd word: baz
3rd word: buzz
4th word:
Lastly, I cancel out and re-type the command, but this time I leave off the space at the end:
bash-3.2$ ./foo bar baz buzz
COMP_CWORD: 3
COMP_WORDS: ./foo bar baz buzz
0th word: ./foo
1st word: bar
2nd word: baz
3rd word: buzz
4th word:
From the above experiment, we learn the following:
COMP_CWORD
prints the index of the word that the user is currently typing at the terminal prompt.- It uses the space character as a delimiter to determine this index.
COMP_WORDS
is the array of words that the user has typed so far.- The current word’s text can be derived by indexing into
COMP_WORDS
, usingCOMP_CWORD
as the index.
The final point above is what we’re doing on the current line of code- grabbing the value of COMP_WORDS
at index COMP_CWORD
, and storing it in the local variable word
.
If the user wants completions for the main rbenv
command
Next block of code:
if [ "$COMP_CWORD" -eq 1 ]; then
If the value of COMP_CWORD
is 1
, that means the user has typed the following in their terminal, and hit the tab
key:
bash-3.2$ rbenv
In this case, the user is asking for tab completions for the rbenv
command, not for one of its sub-commands. To generate those tab completions, we execute the code on the next line of code.
Storing the output of rbenv commands
as the completion result
Next line:
COMPREPLY=( $(compgen -W "$(rbenv commands)" -- "$word") )
Here we assign a new value to our (currently empty) COMPREPLY
array.
The compgen
command
The first thing we see is that, inside the COMPREPLY=( ... )
array assignment, we invoke command substitution with the compgen
command. This is a Bash builtin, and the help
docs for this command state:
compgen: compgen [-abcdefgjksuv] [-o option] [-A action] [-G globpat] [-W wordlist] [-P prefix] [-S suffix] [-X filterpat] [-F function] [-C command] [word]
Display the possible completions depending on the options. Intended
to be used from within a shell function generating possible completions.
If the optional WORD argument is supplied, matches against WORD are
generated.
From this, we see the following:
- The
compgen
command is used to display possible completions. - The
-W
flag that we’ve passed is followed by a “wordlist”, or a list of words to match against - The
word
argument is a comparator word which we’ll use to narrow down the words in our list of words
For example, if we run the following:
bash-3.2$ compgen -W "foo foobar bar baz" -- foo
Our wordlist
is "foo foobar bar baz"
, and our comparator word is foo
. From this, we would expect foo
and foobar
to match the comparator. And we’d be right- when we run this, we get:
bash-3.2$ compgen -W "foo foobar bar baz" -- foo
foo
foobar
What does this mean for RBENV? Instead of "foo foobar bar baz"
, our wordlist
is the output of the rbenv commands
command. On my machine, that resolves to:
bash-3.2$ rbenv commands
--version
commands
completions
exec
global
help
hooks
init
install
local
prefix
rehash
root
shell
shims
uninstall
version
version-file
version-file-read
version-file-write
version-name
version-origin
versions
whence
which
And if we’re inside the if
block, the value of word
will be whatever RBENV command we’ve supplied. For example, if we’ve attempted tab completion after typing rbenv version
, then word
will equal version
. When I echo
the word
variable, type rbenv version
with no spaces, and hit tab
once, I see:
bash-3.2$ rbenv version
word: version
If I hit tab
a 2nd time, I see:
bash-3.2$ rbenv version
word: version
word: version
version version-file version-file-read version-file-write version-name version-origin versions
We can see that all the possible commands which were output (i.e. version
, version-file
, version-file-read
, versions
, etc.) match the string version
. Why do we see the contents of COMPREPLY
printed with two tabs, but not with one?
TODO- what’s the difference between one tab and two tabs, in terms of bash completion?
If the user wants completions for a sub-command of rbenv
Next block of code:
else
local words=("${COMP_WORDS[@]}")
unset "words[0]"
unset "words[$COMP_CWORD]"
local completions=$(rbenv completions "${words[@]}")
COMPREPLY=( $(compgen -W "$completions" -- "$word") )
fi
If the index of the current word is greater than 1, that means the user is attempting tab completions for more than one word. In other words, they’ve typed rbenv
plus a sub-command. In that case, our earlier if
conditional (if [ "$COMP_CWORD" -eq 1 ]; then
) would return false, and we would drop into the above else
block.
The following table illustrates what happens at each line of code, assuming the user has typed rbenv versions
(including a space at the end), and hits the tab
key:
Step | Example |
---|---|
The user types rbenv plus the name of a command (with the empty string at the end), and hits the tab key. |
The user types rbenv versions plus tab . |
The variable word is initialized to the last item in the user’s input. |
word is initialized to the empty string. |
Then, inside the else
block:
Step | Example |
---|---|
Create a local variable named words , and store the entire contents of the COMP_WORDS array (i.e. all the words the user typed into the terminal). |
We store the array [rbenv, versions, ""] (with the empty string at the end) in the words variable. |
Remove the first and last words from the words array, via unset "words[0]" and unset "words[$COMP_CWORD]" respectively. |
rbenv is removed from the beginning, and the empty string is removed from the end. Now words is now equal to the array [versions] . |
Create a new local variable named completions , with contents equal to the output of rbenv completions plus all the remaining words in our words array. |
The value of completions is the output of rbenv completions versions , or [--help, --bare, --skip-aliases] . |
Call compgen -W "$completions" -- "$word" . |
We call compgen -W "--help --bare --skip-aliases" -- "" . |
Store the result as an array in COMPREPLY . |
Our result is: --help --bare --skip-aliases , with each result on its own line. |
Setting the completions for the rbenv
command via complete
Last line of code:
complete -F _rbenv rbenv
The complete
command is a builtin, so to look up the docs, we’ll need the help
command:
bash-3.2$ help complete
complete: complete [-abcdefgjksuv] [-pr] [-o option] [-A action] [-G globpat] [-W wordlist] [-P prefix] [-S suffix] [-X filterpat] [-F function] [-C command] [name ...]
For each NAME, specify how arguments are to be completed.
If the -p option is supplied, or if no options are supplied, existing
completion specifications are printed in a way that allows them to be
reused as input. The -r option removes a completion specification for
each NAME, or, if no NAMEs are supplied, all completion specifications.
The complete
command tells Bash how to determine completions for a given command. The -F
flag specifies that Bash should call a function to populate these completions. Here we’re mapping our new _rbenv
function to the rbenv
command, for the purposes of word completion. This is similar to the compctl -K _rbenv rbenv
command that we saw in rbenv.zsh
.
That’s it for the completions/
directory! Now on to the next section.