Let’s start by looking at the “Usage” and “Summary” comments.
Comments
# Summary: Set or show the shell-specific Ruby version
#
# Usage: rbenv shell <version>
# rbenv shell -
# rbenv shell --unset
#
# Sets a shell-specific Ruby version by setting the `RBENV_VERSION'
# environment variable in your shell. This version overrides local
# application-specific versions and the global version.
#
# <version> should be a string matching a Ruby version known to rbenv.
# The special version string `system' will use your default system Ruby.
# Run `rbenv versions' for a list of available Ruby versions.
#
# When `-` is passed instead of the version string, the previously set
# version will be restored. With `--unset`, the `RBENV_VERSION`
# environment variable gets unset, restoring the environment to the
# state before the first `rbenv shell` call.
From these comments, we learn that the purpose of rbenv shell
is to set a specific version of Ruby in your current terminal tab (as opposed to in your current project directory, or a global version for all directories in your machine). This means that even if you open the same project in a new terminal, it would have a different Ruby version, and switching between the two tabs could result in confusing behavior if the two Ruby versions are different enough.
We also learn that there are 3 ways to use rbenv shell
:
rbenv shell <version>
: sets the current terminal window’s Ruby version to the version you specify.rbenv shell -
: rolls back to the previously-set version number, if there was one.rbenv shell --unset
: unsets theRBENV_VERSION
environment variable, meaning the new value will be sourced from:- the local directory’s Ruby version (as determined by any
.ruby-version
file), - the global Ruby version (as determined by
~/.rbenv/version
), or - the machine’s default system version.
- the local directory’s Ruby version (as determined by any
Next, we’ll look at the tests.
Tests
Sad path- shell integration disabled
After the shebang and the loading of test_helper
, the first spec is:
@test "shell integration disabled" {
run rbenv shell
assert_failure "rbenv: shell integration not enabled. Run \`rbenv init' for instructions."
}
By definition, rbenv shell
is part of RBENV’s shell integrations. Therefore a user can only run rbenv shell
if they’ve already enabled shell integrations (i.e. they’ve included the eval "$(rbenv init -)"
line in their .zshrc
, or the equivalent in their .bashrc
). This test asserts that, when the user has not run this init
command, that rbenv shell
fails with a specific error message.
Getting the shell Ruby version - failure modes
The next few tests are for cases where shell integration is enabled but, for one reason or another, no Ruby version is retrievable.
First test:
@test "shell integration enabled" {
eval "$(rbenv init -)"
run rbenv shell
assert_success "rbenv: no shell-specific version configured"
}
In this test, we run the init
command as a setup step, and then we run rbenv shell
and assert that the command ran successfully.
However, since we haven’t yet set a Ruby version, we get the message “no shell-specific version configured”.
Next test:
@test "no shell version" {
mkdir -p "${RBENV_TEST_DIR}/myproject"
cd "${RBENV_TEST_DIR}/myproject"
echo "1.2.3" > .ruby-version
RBENV_VERSION="" run rbenv-sh-shell
assert_failure "rbenv: no shell-specific version configured"
}
Here we do a bit more test setup, but we’re still testing a failure mode:
- We create a test Ruby project directory and
cd
into it. - Within that directory, we create a
.ruby-version
file containing a Ruby version, however we manually setRBENV_VERSION
to the empty string before runningrbenv sh-shell
.
When there is no prior RBENV_VERSION
set and the user doesn’t provide an argument to rbenv shell
, the expectation is that an error message should be printed and the program should exit with a non-zero return code.
Getting the shell Ruby version - happy path
Next test:
@test "shell version" {
RBENV_SHELL=bash RBENV_VERSION="1.2.3" run rbenv-sh-shell
assert_success 'echo "$RBENV_VERSION"'
}
Here we provide the bare minimum information that rbenv shell
needs to do its job:
- a specific shell program to use, as stored in the
RBENV_SHELL
environment variable, and - a specific Ruby version, as stored in the
RBENV_VERSION
environment variable.
Once the program knows these things, it can print the requested Ruby version back to the user. We assert that it does this, and exits successfully.
Next test:
@test "shell version (fish)" {
RBENV_SHELL=fish RBENV_VERSION="1.2.3" run rbenv-sh-shell
assert_success 'echo "$RBENV_VERSION"'
}
Here we do the same test as the one before, except this time we set that RBENV_SHELL
is set to fish
instead of Bash.
run rbenv shell
vs. run rbenv-sh-shell
Notice that, in both of the above cases, the expected output is a snippet of code, including an echo
statement. That’s because the command we’re running is run rbenv-sh-shell
, not run rbenv shell
. Those are two subtly different commands.
run rbenv shell
assumes that we have shell integration enabled, whereas run rbenv-sh-shell
does not. The latter will output code, which will be executed by the call to eval
in our shell function. That’s why, when we write tests which run rbenv-sh-shell
, our assertions will all test that specific code is printed to the screen.
Reverting to an earlier Ruby version
Next test:
@test "shell revert" {
RBENV_SHELL=bash run rbenv-sh-shell -
assert_success
assert_line 0 'if [ -n "${RBENV_VERSION_OLD+x}" ]; then'
}
We set RBENV_SHELL
to Bash and run rbenv sh-shell -
. We assert that the command was successful, and that the first line of output was a snippet of Bash code, i.e.:
if [ -n "${RBENV_VERSION_OLD+x}" ]; then
Again, we’re testing rbenv-sh-shell
, not rbenv shell
, so our expected output will be code that the rbenv
shell function will eval
.
The code that is printed to the terminal depends on what shell you’re using, i.e. Bash, fish
, or another shell. We want to make sure the right code is eval
‘ed for the right shell program. This test covers that case, for the Bash shell.
Same with the next test:
@test "shell revert (fish)" {
RBENV_SHELL=fish run rbenv-sh-shell -
assert_success
assert_line 0 'if set -q RBENV_VERSION_OLD'
}
This covers the same case as the previous test, but for the fish
shell.
Unsetting the current Ruby version
Next spec:
@test "shell unset" {
RBENV_SHELL=bash run rbenv-sh-shell --unset
assert_success
assert_output <<OUT
RBENV_VERSION_OLD="\${RBENV_VERSION-}"
unset RBENV_VERSION
OUT
}
Here we pass the RBENV_SHELL=bash
env var and the --unset
flag to rbenv sh-shell
and assert that, in the case of Bash, the output is bash-specific code for setting RBENV_VERSION_OLD
and unsetting RBENV_VERSION
.
Next test:
@test "shell unset (fish)" {
RBENV_SHELL=fish run rbenv-sh-shell --unset
assert_success
assert_output <<OUT
set -gu RBENV_VERSION_OLD "\$RBENV_VERSION"
set -e RBENV_VERSION
OUT
}
This is again the fish-specific version of the previous spec. Nothing new to see here except RBENV_SHELL=fish
.
Changing the shell version
Next spec:
@test "shell change invalid version" {
run rbenv-sh-shell 1.2.3
assert_failure
assert_output <<SH
rbenv: version \`1.2.3' not installed
false
SH
}
Here we test the sad-path case where we attempt to set the local shell version to a version number which is not installed in the system. We do no setup work, we simply run the sh-shell
program and assert that an error message is echo
d to the eval
command.
Next test:
@test "shell change version" {
mkdir -p "${RBENV_ROOT}/versions/1.2.3"
RBENV_SHELL=bash run rbenv-sh-shell 1.2.3
assert_success
assert_output <<OUT
RBENV_VERSION_OLD="\${RBENV_VERSION-}"
export RBENV_VERSION="1.2.3"
OUT
}
This is a happy-path, Bash-specific test which asserts that, when a given Ruby version has been installed and the user tries to set their shell’s Ruby version to that version, the command succeeds and does in fact set the shell version to that number.
Last test:
@test "shell change version (fish)" {
mkdir -p "${RBENV_ROOT}/versions/1.2.3"
RBENV_SHELL=fish run rbenv-sh-shell 1.2.3
assert_success
assert_output <<OUT
set -gu RBENV_VERSION_OLD "\$RBENV_VERSION"
set -gx RBENV_VERSION "1.2.3"
OUT
}
This is the same spec as the previous one, but for the fish
shell. Instead of testing that certain Bash code is printed to the screen, we test that certain fish
code is printed.
Testing what code does vs. testing how it does it
One final note- when we assert that certain code is printed to the screen (as we do for many of the tests above), we’re testing implementation instead of behavior. This is considered by many programmers to be less-than-great practice:
Tests that are independent of implementation details are easier to maintain since they don’t need to be changed each time you make a change to the implementation. They’re also easier to understand since they basically act as code samples that show all the different ways your class’s methods can be used, so even someone who’s not familiar with the implementation should usually be able to read through the tests to understand how to use the class.
When I test for behavior, I’m saying:
“I don’t care how you come up with the answer, just make sure that the answer is correct under this set of circumstances.”
When I test for implementation, I’m saying:
“I don’t care what the answer is, just make sure you do this thing while figuring it out.”
If I “verify” that my car works by checking for the presence of various parts, then I haven’t really actually verified anything. I haven’t demonstrated that the system under test (the car) actually meets spec (can drive).
If I test the car by actually driving it, then the questions of whether the car has various components become moot. If for example the car can travel down the road, we don’t need to ask if the car has wheels. If it didn’t have wheels it wouldn’t be moving.
All of our “implementation” questions can be translated into more meaningful “behavior” questions.
- Does it have an ignition? -> Can it start up?
- Does it have an engine and wheels? -> Can it drive?
- Does it have brakes? -> Can it stop?
Lastly, behavior tests are better than implementation tests because behavior tests are more loosely coupled to the implementation. I ask “Can it start up?” instead of “Does it have an engine?” then I’m free to, for example, change my car factory from a gasoline-powered car factory to an electric car factory without having to change the set of tests that I perform. In other words, behavior tests enable refactoring.
Testing for the output of specific code to be evaluated by eval
makes the test more brittle. It’s testing the implementation instead of testing the result. If the code which is output gets refactored somehow, the tests would break.
What if we instead test that running rbenv shell
results in a change to our shell’s Ruby version? Then we could refactor the implementation of that command without breaking its test. For example, instead of:
@test "shell version" {
RBENV_SHELL=bash RBENV_VERSION="1.2.3" run rbenv-sh-shell
assert_success 'echo "$RBENV_VERSION"'
}
We could write:
@test "shell version foo" {
eval "$(rbenv init -)"
new_version="1.2.3"
RBENV_SHELL=bash RBENV_VERSION="$new_version" run rbenv shell
assert_success "$new_version"
}
When I add the above test into the spec file and run the full file, everything passes. So in theory, at least, this approach could be applied to RBENV. And it seems like the user needs shell integration enabled in order to use rbenv shell
anyway, so there’s no harm in refactoring the tests to all resemble the above.
I submitted a PR to check whether the core team would find this useful, and as of June 17, 2023 I’m waiting for a response.
Let’s now move on to the file itself.
Code
Skipping the shebang and “Usage” comments that we’ve already looked at, the first block of code is:
set -e
[ -n "$RBENV_DEBUG" ] && set -x
This hopefully looks familiar by now:
- Setting “exit on error mode” via
set -e
. - Setting verbose mode via
set -x
.
Printing completions
Next block of code:
# Provide rbenv completions
if [ "$1" = "--complete" ]; then
echo --unset
echo system
exec rbenv-versions --bare
fi
Looks like we have 2 hard-coded completions (--unset
and system
), as well as the dynamic Ruby versions which are output from rbenv-versions –bare
and which we’ve seen before.
Setting local variables for the version and shell
Next block of code:
version="$1"
shell="$(basename "${RBENV_SHELL:-$SHELL}")"
We just set two variables:
- one named
version
which is equal to the first argument given torbenv shell
, and - the other named
shell
which is set to the user’s shell program name.
$RBENV_SHELL
resolves to zsh
on my Macbook, and $SHELL
resolves to /bin/zsh
. The output of the basename
command when given (for example) /bin/zsh
is just zsh
.
If no argument is passed
Next block of code:
if [ -z "$version" ]; then
if [ -z "$RBENV_VERSION" ]; then
echo "rbenv: no shell-specific version configured" >&2
exit 1
else
echo 'echo "$RBENV_VERSION"'
exit
fi
fi
If our new version
variable is empty, then we do one of two things:
- If the value of the
RBENV_VERSION
env var is also empty, then we print a helpful error message before exiting. - Otherwise, we
echo
anecho
command.
We use >&2
to redirect the if
-clause’s message to stderr
. We do this because, without >&2
, we would be printing to stdout
instead. This would cause an error when the message reaches the eval
command which calls rbenv-sh-shell
.
Unsetting the Ruby shell version
Next block of code:
if [ "$version" = "--unset" ]; then
case "$shell" in
fish )
echo 'set -gu RBENV_VERSION_OLD "$RBENV_VERSION"'
echo "set -e RBENV_VERSION"
;;
* )
echo 'RBENV_VERSION_OLD="${RBENV_VERSION-}"'
echo "unset RBENV_VERSION"
;;
esac
exit
fi
If the argument $1
that we stored in our version
variable was not a version number at all, but rather the string "--unset"
, that means the user wants to unset the Ruby version for this shell. In this case, we set RBENV_VERSION_OLD
to our current value of RBENV_VERSION
, and then use the unset
command to remove any value from RBENV_VERSION
.
Rolling back to a previous Ruby version
Next block of code:
if [ "$version" = "-" ]; then
case "$shell" in
...
esac
exit
fi
This case
statement is quite long, so I’ll break it up into chunks.
We start with a similar situation as the last block of code, in which the value stored in the version
variable is not a version number at all. In this case, instead of testing whether the value is the string --unset
, we test whether it’s a single hyphen character -
.
If it is, then we execute a similar case statement to the --unset
block, which branches on the value of the shell
variable.
If the current shell is fish
First case statement is:
fish )
cat <<EOS
if set -q RBENV_VERSION_OLD
...
else
...
end
EOS
;;
We’re cat
‘ing a here-doc string containing a bunch of commands which will be evaluated by the rbenv
shell function:
The fish docs tell us that set -q
is how we test whether a variable has been defined. There’s no output, but the exit code is the number of variables passed to set -q
which were undefined. So since we only passed one variable to set -q
, i.e. RBENV_VERSION_OLD
, our exit code is 0 if RBENV_VERSION_OLD
is defined and 1 if it is undefined.
If RBENV_VERSION_OLD
is set
If the exit code of the set -q
command is 0, that means 0 variables in the list of variables were undefined. Since our “list of variables” consisted of just RBENV_VERSION_OLD
, that means RBENV_VERSION_OLD
was defined. According to fish’s if
docs, that means our if
check would be true, and we execute the following code:
if [ -n "\$RBENV_VERSION_OLD" ]
set RBENV_VERSION_OLD_ "\$RBENV_VERSION"
set -gx RBENV_VERSION "\$RBENV_VERSION_OLD"
set -gu RBENV_VERSION_OLD "\$RBENV_VERSION_OLD_"
set -e RBENV_VERSION_OLD_
else
set -gu RBENV_VERSION_OLD "\$RBENV_VERSION"
set -e RBENV_VERSION
end
Just because RBENV_VERSION_OLD
has been set, doesn’t mean it has a value. It could be set to null
.
Here we check whether it has a non-zero value. The [ -n ... ]
syntax does the same thing in fish
as it does in Bash.
If it does have a value:
- we set a temporary variable named
RBENV_VERSION_OLD_
equal to the current value ofRBENV_VERSION
. Note the trailing underscore character, which makes this a different variable fromRBENV_VERSION_OLD
. - We then set the
RBENV_VERSION
variable equal to the value ofRBENV_VERSION_OLD
(more specifically, a global environment variable, as denoted by the-gx
flags). - We then set
RBENV_VERSION_OLD
equal to the value of our temp variable (the one with the underscore at the end). - Lastly, we unset (aka delete) the
RBENV_VERSION_OLD_
temp variable.
All this has the effect of swapping the values of RBENV_VERSION_OLD
and RBENV_VERSION
.
If [ -n RBENV_VERSION_OLD]
is false, then we execute the logic in the else
block of code. This block sets a new value for RBENV_VERSION_OLD
which is equal to our current RBENV_VERSION
value, and then unsets the value of RBENV_VERSION
via the set -e
command (here, e
stands for erase
).
If RBENV_VERSION_OLD
is NOT set
If RBENV_VERSION_OLD
was undefined, we’ll execute the else
branch, which does the following:
echo "rbenv: RBENV_VERSION_OLD is not set" >&2
false
This just prints an error message to STDERR. The false
command at the end is how the fish
shell sets a non-zero exit status (see the docs here).
If the current shell is NOT fish
Next block of code is the “non-fish” version of the exact same process as above:
* )
cat <<EOS
if [ -n "\${RBENV_VERSION_OLD+x}" ]; then
if [ -n "\$RBENV_VERSION_OLD" ]; then
RBENV_VERSION_OLD_="\$RBENV_VERSION"
export RBENV_VERSION="\$RBENV_VERSION_OLD"
RBENV_VERSION_OLD="\$RBENV_VERSION_OLD_"
unset RBENV_VERSION_OLD_
else
RBENV_VERSION_OLD="\$RBENV_VERSION"
unset RBENV_VERSION
fi
else
echo "rbenv: RBENV_VERSION_OLD is not set" >&2
false
fi
EOS
;;
We can go faster this time around, since we can assume that this block of code does the same thing in Bash that the previous block of code does in fish
.
If RBENV_VERSION_OLD
is set
Let’s look at the first if
condition:
if [ -n "\${RBENV_VERSION_OLD+x}" ]; then
According to StackOverflow, the point of the +x
in the parameter expansion is to “deterimine(s) if a variable ARGUMENT is set to any value (empty or non-empty) or not.”
Similar to the fish script, we’re just querying if the variable has been set, even if it’s just to the empty string. We could just use the simpler if [ -n "${RBENV_VERSION_OLD}" ]
(and in fact we do exactly that on the next line), but that test would be falsy in the case where RBENV_VERSION_OLD
is set to “”, whereas [ -n "\${RBENV_VERSION_OLD+x}" ]
is truthy in that case.
We would want that case to be truthy, because we want the ability to have an else
clause where we tell the user that the variable is unset. That’s why we use the somewhat-confusing +x
syntax instead.
Summary of the -
argument
Taken together, this case statement appears to either set our current RBENV_VERSION
value equal to the value of RBENV_VERSION_OLD
, or to unset RBENV_VERSION
entirely if we don’t have an old value to roll back to.
Happy path- setting the shell’s Ruby version
Last block of code for this file:
# Make sure the specified version is installed.
if rbenv-prefix "$version" >/dev/null; then
...
else
...
fi
Checking whether the specified version is installed
We run rbenv prefix
on the version param passed to rbenv shell
. For example, if we type rbenv shell 2.7.5
, then we run rbenv prefix 2.7.5
.
On my machine, that returns the filepath to the directory for version 2.7.5 (/Users/myusername/.rbenv/versions/2.7.5
). If I pass rbenv prefix
and invalid version, I get an error:
$ rbenv prefix foo
rbenv: version `foo' not installed
So if our version
variable corresponds to a version that we have installed in our system, we execute the next block of code.
If the requested version is valid
if [ "$version" != "$RBENV_VERSION" ]; then
case "$shell" in
fish )
echo 'set -gu RBENV_VERSION_OLD "$RBENV_VERSION"'
echo "set -gx RBENV_VERSION \"$version\""
;;
* )
echo 'RBENV_VERSION_OLD="${RBENV_VERSION-}"'
echo "export RBENV_VERSION=\"$version\""
;;
esac
fi
If we’ve pass a valid version, i.e. one that does have a prefix, we check whether our new version of Ruby is different from our current version.
- If they are different:
- We set
RBENV_VERSION_OLD
equal to the current version of Ruby, or null if there is no current version. - We then set the Ruby version equal to the version that the user requested, and export
RBENV_VERSION
so that it may be used elsewhere.
- We set
- If the two version numbers are the same, we do nothing. Hence, no
else
condition for thisif
check.
If the requested version is NOT valid
What if we’ve passed a version of Ruby that doesn’t have a prefix, i.e. was not installed by RBENV?
echo "false"
exit 1
If we’ve passed an invalid version, we echo "false"
and exit with a non-zero status code.
That’s it for rbenv sh-shell
. On to the next file.