As with the rbenv
command, let’s start by looking at this command’s tests.
Tests
After the bats
shebang and the loading of the test_helper
file, the first test is:
@test "creates shims and versions directories" {
assert [ ! -d "${RBENV_ROOT}/shims" ]
assert [ ! -d "${RBENV_ROOT}/versions" ]
run rbenv-init -
assert_success
assert [ -d "${RBENV_ROOT}/shims" ]
assert [ -d "${RBENV_ROOT}/versions" ]
}
We first perform some sanity-check assertions stating that the ${RBENV_ROOT}/shims
and ${RBENV_ROOT}/versions
directories do not exist.
We then run the command with the -
flag. If this flag is missing, then rbenv init
prints out usage instructions and exits with a failure return code, which we’ll see in the next section.
Lastly, we assert that the command ran successfully and that the two formerly-missing directories have been created.
Next test:
@test "auto rehash" {
run rbenv-init -
assert_success
assert_line "command rbenv rehash 2>/dev/null"
}
We explicitly avoid passing the --no-rehash
argument, which means that we don’t set the value of the no_rehash
variable here. Because of that, this block of code prints the string command rbenv rehash 2>/dev/null
to STDOUT.
When we start analyzing the command file, we’ll see that these commands which are printed to STDOUT are actually captured by command substitution, and executed by Bash. So when we see an assertion that expects a certain string of code to be printed, that’s what’s happening.
This particular line of code, command rbenv rehash 2>/dev/null
, means that we’re trying to “rehash” (or regenerate) our shim files. As we discussed in our read-through of the rbenv
file, the use of the command
command means that we bypass our rbenv
shell function, and instead directly call our rbenv
script.
Next test:
@test "setup shell completions" {
root="$(cd $BATS_TEST_DIRNAME/.. && pwd)"
run rbenv-init - bash
assert_success
assert_line "source '${root}/test/../libexec/../completions/rbenv.bash'"
}
This test covers the 4-line block of code starting here:
- We store the value of our root directory, and run
rbenv-init
while specifying Bash as our shell. - We assert that:
- the command completed successfully and that
- the output contains a line of code which
source
s a certain file calledcompletions/rbenv.bash
.
The file that gets source
‘ed lives here. This action only takes place when the user passes Bash as the argument to rbenv init -
, as shown in the test. We haven’t read through this file yet, but we’ll do so in the future.
Next test:
@test "detect parent shell" {
SHELL=/bin/false run rbenv-init -
assert_success
assert_line "export RBENV_SHELL=bash"
}
This test covers this line of code and the lines after it. We purposely set the SHELL
env var to be a falsy value, and run the command with no argument after the “-“ character.
At this line of code, the $shell
variable is initialized to the first argument in our current list of args. In our case, we didn’t pass a specific shell name (like we did with Bash in the previous test), so this variable will be empty.
As the block of code executes, the value of shell
is populated and then progressively whittled down until we are left with just the shell’s name.
We can see this by adding a bunch of echo
statements to this block of code, like so:
When I run the test, and print out foo.txt
, I get:
You can see that, on my machine, the canonical shell name had been fully-resolved by the time we get to the 4th echo
statement. But that’s not necessarily true on every possible computer. Since the output of ps -p "$PPID" -o 'args='
will be different depending on the machine you run this command on, multiple lines of parameter expansion are necessary in order to accomodate all machine types.
Once we know which shell the user is using, we can construct our shell function to suit their shell type, which is the point of the rbenv-init
command.
Next test:
@test "detect parent shell from script" {
mkdir -p "$RBENV_TEST_DIR"
cd "$RBENV_TEST_DIR"
cat > myscript.sh <<OUT
#!/bin/sh
eval "\$(rbenv-init -)"
echo \$RBENV_SHELL
OUT
chmod +x myscript.sh
run ./myscript.sh
assert_success "sh"
}
In the first part of the test, we create and navigate into a test directory:
mkdir -p "$RBENV_TEST_DIR"
cd "$RBENV_TEST_DIR"
In that directory we create a test script which contains an sh
shebang and a call to eval
RBENV’s init
command with no explicit shell set. We also run chmod +x
on the file, to make it executable:
cat > myscript.sh <<OUT
#!/bin/sh
eval "\$(rbenv-init -)"
echo \$RBENV_SHELL
OUT
chmod +x myscript.sh
Lastly, we run the script and assert that a) it completed successfully, and b) it printed out the value of RBENV_SHELL
(which should be set when rbenv init -
is run):
run ./myscript.sh
assert_success "sh"
Heredocs
We’ve seen the cat
command before, but it wasn’t used in the way we’re seeing here. The cat > myscript.sh
bit means we’re printing to a new file that we’re creating, called myscript.sh
. The <<OUT
syntax, along with everything below it until the closing OUT
statement, is called a Here document, or heredoc for short. It’s used to pass multiple lines of text to a command (in this case, the cat
command).
Next test:
@test "skip shell completions (fish)" {
root="$(cd $BATS_TEST_DIRNAME/.. && pwd)"
run rbenv-init - fish
assert_success
local line="$(grep '^source' <<<"$output")"
[ -z "$line" ] || flunk "did not expect line: $line"
}
We store a directory path in a variable named root
, quite similar to a previous test. I actually think this is a copy-paste mistake, because root
is not subsequently used anywhere in the test. We’ll ignore it for now. Making a PR to remove it is left as an exercise for the reader. 😉
We then run rbenv init
, passing fish
as our shell.
We first assert that the command succeeded. We then search the lines of output (which bats
stores for us in a variable named $output
) for the string source
. We store any matches we find in a variable named line
. We then assert that this variable is empty (i.e. we found 0 matches), and we fail the test if any such lines were found.
The test implies that, if any of our output contains source
when we pass fish
as our preferred shell, an error has occurred somewhere. This makes sense, given that:
- the test description is
skip shell completions (fish)
, and - we saw in a previous test that shell completions are enabled by
source
ing a script in thecompletions/
folder.
This test ensures that there is no fish
shell equivalent for the block of code here, which handles shell completions for all other shell programs.
Here strings
In the declaration of the local variable line
, we see <<<"$output"
. This is similar to the <<OUT
syntax that we saw in the last test, except in the current test we’re passing a string variable instead of a multi-line block of text. Strings that use the <<<
syntax are called here strings. Unlike heredocs (which use the <<
syntax), they do not require a delimiter (such as OUT
, from the last test).
Next test:
@test "posix shell instructions" {
run rbenv-init bash
assert [ "$status" -eq 1 ]
assert_line 'eval "$(rbenv init - bash)"'
}
This test covers this line of code, as well as this one.
We specify Bash as the shell type for rbenv-init
and run it. Importantly, when running run rbenv-init bash
, we leave out the -
argument.
Doing so means that we end up inside the if
code of this line of code. The purpose of this if
block is to tell the user how to enable shell integration, i.e.:
- what code to paste into their config file, and
- where that config file lives.
We assert that one of the lines of text printed to STDOUT contains the command that we need to add to our shell profile, i.e. eval "$(rbenv init - bash)"
.
We also assert that the exit code returned by rbenv-init
was a non-zero code, indicating that the printing of these instructions is considered a non-happy-path use case.
Next test:
@test "fish instructions" {
run rbenv-init fish
assert [ "$status" -eq 1 ]
assert_line 'status --is-interactive; and rbenv init - fish | source'
}
This is a similar test to the previous one, except here we pass the argument fish
instead of Bash. We once again assert that the command exited with a status code of 1, and that the proper shell integration script is printed to STDOUT. This time, the script syntax is fish
-specific, rather than Bash-specific.
Next test:
@test "option to skip rehash" {
run rbenv-init - --no-rehash
assert_success
refute_line "rbenv rehash 2>/dev/null"
}
This test appears to cover this block of code, as well as this block. We run the init
command, passing the “-“ and “–no-rehash” flags. We assert that:
- the command exited successfully, and
- that the code to rehash RBENV’s shims (
"rbenv rehash 2>/dev/null"
) does not get printed to STDOUT.
Observant readers will notice that this test covers the opposite scenario covered by this earlier test.
Next test:
@test "adds shims to PATH" {
export PATH="${BATS_TEST_DIRNAME}/../libexec:/usr/bin:/bin:/usr/local/bin"
run rbenv-init - bash
assert_success
assert_line 0 'export PATH="'${RBENV_ROOT}'/shims:${PATH}"'
}
This test covers this line of code. We set $PATH
to a known value, then run init
with the arguments “-“ and “bash”. We assert that:
- the test was successful, and that
- the path was prepended with
{RBENV_ROOT}'/shims
.
We’ll talk about why shims are added to PATH
when we look at rbenv-init
.
Next test:
@test "adds shims to PATH (fish)" {
export PATH="${BATS_TEST_DIRNAME}/../libexec:/usr/bin:/bin:/usr/local/bin"
run rbenv-init - fish
assert_success
assert_line 0 "set -gx PATH '${RBENV_ROOT}/shims' \$PATH"
}
This is a similar test to the previous one, except that it passes fish
as an argument instead of Bash. The line of code that’s tested is this one. We perform the same setup, and we make the same assertions:
- the command completed successfully, and
- the
PATH
env var is re-exported to include{RBENV_ROOT}'/shims
before its original value.
Next test:
@test "can add shims to PATH more than once" {
export PATH="${RBENV_ROOT}/shims:$PATH"
run rbenv-init - bash
assert_success
assert_line 0 'export PATH="'${RBENV_ROOT}'/shims:${PATH}"'
}
This test asserts that, when the value of PATH
already includes RBENV’s shims/
directory, a subsequent call to rbenv init
results in PATH
being prepended with additional copies of the shims/
directory:
- We set
PATH
to includeshims/
. - We run
rbenv init
with the arguments “-“ and “bash”. - Finally, we assert that:
- the test is successful, and
- the new value of
PATH
to be exported includes the original value (withshims/
), plus a duplicate value of theshims/
path at the front of the env var.
I was curious why we’d explicitly want to ensure that duplication within PATH
is acceptable. I would have thought that we’d want to actively prevent such duplication, since it makes PATH
harder to read and debug.
As it turns out, this test appears to cover a problem presented by some of RBENV’s users here. When certain users attempted to run the tmux
command, their version of Ruby was changed unintentionally. According to RBENV’s core team, the reason for this was as follows:
Your problem is due to $PATH ordering… Your PATH in your regular terminal session is something like:
rbenv shims : rbenv bin : system paths
By entering tmux, you spawned a nested interactive subshell. That means that your .bashrc or .zshrc get sourced again, and the same paths get added to the already prepared PATH. You finish up with something like:
rbenv bin : system paths : rbenv shims : rbenv bin : system paths
The master branch of rbenv avoids adding shims to the PATH twice. So you finished with ruby from system paths (/usr/bin/ruby) having precedence over rbenv’s shims (~/.rbenv/shims/ruby).
So by running tmux
, the shell configuration files .bashrc
or .zshrc
get source
‘ed a 2nd time.
In the case of these users, this action caused $PATH
to be prepended with a duplicate copy of the path to “system” Ruby. Since the same config files presumably contained the rbenv init
command, this normally would have caused $PATH
to be prepended with a duplicate of the path to RBENV Ruby versions as well.
However, at the time of this bug, RBENV included a line of logic which prevented duplicate copies of the paths to its Ruby versions from being added to $PATH
. This was meant to avoid polluting $PATH
with duplicate directory paths, for the reasons I mentioned earlier.
By including this logic to prevent duplication, RBENV inadvertently contributed to the existence of this unexpected behavior for tmux
users. The core team appears to have decided that the best of several less-than-ideal solutions was to allow the $PATH
duplication after all, since the existing logic was causing problems for too many users. The PR to “revert back to simpler times” is located here.
Next test:
@test "can add shims to PATH more than once (fish)" {
export PATH="${RBENV_ROOT}/shims:$PATH"
run rbenv-init - fish
assert_success
assert_line 0 "set -gx PATH '${RBENV_ROOT}/shims' \$PATH"
}
This test covers the same edge case as the previous test, but for the fish
shell instead of the Bash shell.
Next test:
@test "outputs sh-compatible syntax" {
run rbenv-init - bash
assert_success
assert_line ' case "$command" in'
run rbenv-init - zsh
assert_success
assert_line ' case "$command" in'
}
This test covers this block of code. It asserts that, when we run rbenv init
for both the Bash and zsh
shells, the shell function that rbenv init
checks whether the user’s command is related to shell integration (i.e. it’s either rbenv shell
or rbenv rehash
).
These are the two commands that will be included in the commands[*]
array, the line directly below the line case "$command" in
which begins the case statement.
Last test:
@test "outputs fish-specific syntax (fish)" {
run rbenv-init - fish
assert_success
assert_line ' switch "$command"'
refute_line ' case "$command" in'
}
This test covers the blocks of code here and here. It covers the same behavior, but for fish
(as opposed to the Bash and zsh
shells, which the previous test covered). The fish
shell uses much different syntax from Bash or zsh
, so we need separate tests to cover that edge case.
Let’s move on to the code.