Next few lines of code:
command="$1"
case "$command" in
...
esac
Here’s where we get to the meat of this file. We grab the first argument sent to the rbenv
command, and decide what to do with it via a case
statement. The internals of that case statement will dictate how RBENV responds to the command the user has entered.
Let’s take each branch of the case
statement in turn:
Typing rbenv
with no arguments
"" )
{ rbenv---version
rbenv-help
} | abort
;;
The "" )
syntax represents our first case in the case statement. This branch of the case statement will execute if $command
matches the empty string (i.e. if the user just types rbenv
by itself, with no args).
In that event, we call the rbenv—version
and rbenv-help
scripts. We wrap those commands inside curly braces, so their output is piped together into the abort
function, which will send the output to stderr
.
Since abort
also returns a non-zero exit code, this implies that passing ""
to rbenv
is a non-happy path. In fact, we can assume this is also true any time we see the abort
function.
If we go into our terminal and type rbenv
with no args, we see this happen:
$ rbenv
rbenv 1.2.0-16-gc4395e5
Usage: rbenv <command> [<args>]
Some useful rbenv commands are:
commands List all available rbenv commands
local Set or show the local application-specific Ruby version
global Set or show the global Ruby version
shell Set or show the shell-specific Ruby version
install Install a Ruby version using ruby-build
uninstall Uninstall a specific Ruby version
rehash Rehash rbenv shims (run this after installing executables)
version Show the current Ruby version and its origin
versions List installed Ruby versions
which Display the full path to an executable
whence List all Ruby versions that contain the given executable
See `rbenv help <command>' for information on a specific command.
For full documentation, see: https://github.com/rbenv/rbenv#readme
$ echo "$?"
1
We see the following:
- The output of
rbenv --version
(aka the version number) prints out:
rbenv 1.2.0-16-gc4395e5
- The output of
rbenv help
(aka info on how therbenv
command is used, along with its syntax and its possible arguments):
Usage: rbenv <command> [<args>]
Some useful rbenv commands are:
commands List all available rbenv commands
local Set or show the local application-specific Ruby version
...
If we then type echo "$?"
immediately after that to print the last exit status, we see 1
print out.
Pretty straight-forward.
Typing rbenv -v
or rbenv --version
Next case branch is:
-v | --version )
exec rbenv---version
;;
This time we’re comparing $command
against two values instead of just one: -v
or --version
. If it matches either pattern, we exec
the rbenv---version
script, which is just a one-line output of (in my case) rbenv 1.2.0-16-gc4395e5
:
$ rbenv -v
rbenv 1.2.0-16-gc4395e5
$ rbenv --version
rbenv 1.2.0-16-gc4395e5
Version Numbers
In the output 1.2.0-16-gc4395e5
, the 1.2.0
represent the major, minor, and patch versions, in accordance with semantic versioning (or SemVer, for short).
You might also be asking what 16-gc4395e5
represents. I wasn’t sure either, and I didn’t know what to Google, so I asked ChatGPT:
According to ChatGPT, 16
is the number of commits since the last release of RBENV, and gc4395e5
is a reference to the SHA that we’re currently pointing to (c4395e58201966d9f90c12bd6b7342e389e7a4cb
) plus a g
at the end to indicate that there are changes in our version which are not yet released.
We can double-check this by running a git log
to find the previous commits and their SHAs, checking one of them out, and re-running rbenv --version
:
commit c4395e58201966d9f90c12bd6b7342e389e7a4cb (HEAD -> impostorsguides)
Merge: c6cc0a1 a54b47e
Author: Hiroshi SHIBATA <hsbt@ruby-lang.org>
Date: Sat Jul 16 08:14:44 2022 +0900
Merge pull request #1418 from uraitakahito/patch-0
Fix link to Pow because the server is down
commit a54b47e7835a652b48d142fbe041017895c5aabe
Author: Takahito Urai <uraitakahito@gmail.com>
Date: Fri Jul 15 21:46:32 2022 +0900
Fix link to Pow because the server is down
commit c6cc0a1959da3403f524fcbb0fdfb6e08a4d8ae6
Merge: e4f61e6 b39d429
Author: Mislav Marohnić <git@mislav.net>
Date: Wed Mar 9 13:03:36 2022 +0100
Merge pull request #1393 from scop/refactor/simplify-version-file-read
Simplify version file read
commit b39d4291bed8439330f8a399dcdf6f11cf03eabe
Author: Ville Skyttä <ville.skytta@iki.fi>
Date: Tue Mar 8 20:58:58 2022 +0200
Simplify version file read
Avoid a subshell and external `cut` invocation, as well as a throwaway
intermediate array.
...
I’ll take a54b47e7835a652b48d142fbe041017895c5aabe
, the one just prior to my current SHA:
$ git co a54b47e7835a652b48d142fbe041017895c5aabe
Note: switching to 'a54b47e7835a652b48d142fbe041017895c5aabe'.
...
$ rbenv --version
rbenv 1.2.0-15-ga54b47e
Now we see 15
instead of 16
, and a54b47e
instead of c4395e5
after the -g
.
What if we start at c4395e5
and count back 16 commits, and check out that SHA? What would we see?
$ git log
...
commit 6cc7bff383a603fb47325be80e3cac8a7f55f501
Author: Audree Steinberg <audreee@github.com>
Date: Thu Oct 21 06:23:46 2021 -0700
Update code block in readme for rbenv-doctor script (#1353)
Co-authored-by: Mislav Marohnić <git@mislav.net>
commit 38e1fbb08e9d75d708a1ffb75fb9bbe179832ac8 (tag: v1.2.0)
Author: Mislav Marohnić <git@mislav.net>
Date: Wed Sep 29 20:47:10 2021 +0200
rbenv 1.2.0
commit 69323e77cc080d35387762eeb7dc26062ac159ea
Author: Mislav Marohnić <git@mislav.net>
Date: Wed Sep 29 20:23:42 2021 +0200
Clarify bash config for Ubuntu Desktop vs. other platforms
Fixes #1130
...
$ git co 38e1fbb08e9d75d708a1ffb75fb9bbe179832ac8
Note: switching to '38e1fbb08e9d75d708a1ffb75fb9bbe179832ac8'.
...
$ rbenv -v
rbenv 1.2.0
This time, there’s no -16
or g...
. Just a version number. That’s because RBENV SHA # 38e1fbb08e9d75d708a1ffb75fb9bbe179832ac8
has a git tag attached to it.
We’ll get into why this is later, when we look at the actual code for the rbenv---version
file. For now, the goal was just to learn how to read the output of the --version
flag.
Typing rbenv -h
or rbenv --help
Next case branch is:
-h | --help )
exec rbenv-help
;;
Again, two patterns to match against. If the user types rbenv -h
or rbenv –help
, we just run the rbenv-help
script:
$ rbenv -h
Usage: rbenv <command> [<args>]
Some useful rbenv commands are:
commands List all available rbenv commands
local Set or show the local application-specific Ruby version
global Set or show the global Ruby version
shell Set or show the shell-specific Ruby version
install Install a Ruby version using ruby-build
uninstall Uninstall a specific Ruby version
rehash Rehash rbenv shims (run this after installing executables)
version Show the current Ruby version and its origin
versions List installed Ruby versions
which Display the full path to an executable
whence List all Ruby versions that contain the given executable
See `rbenv help <command>' for information on a specific command.
For full documentation, see: https://github.com/rbenv/rbenv#readme
This is actually the same output we saw from typing rbenv
with no arguments, except we don’t see the version number here.
Again, no real surprises.
The Default Case
Next up is:
* )
...;;
The * )
line is the catch-all / default case branch. Any rbenv
command that wasn’t captured by the previous branches, including both known commands (rbenv version
, rbenv local
, etc.) and unknown commands (i.e. rbenv foobar
), will be captured by this branch.
How we handle the user’s input is determined by what’s inside the branch, starting with the next line.
Getting the filepath for the user’s command
command_path="$(command -v "rbenv-$command" || true)"
Here we’re declaring a variable called command_path
, and setting its value equal to the result of a response from a command substitution. That command substitution is either:
- the result of
command -v "rbenv-$command"
, or (if there is no result) - the simple boolean value
true
.
We saw the same || true
syntax earlier in this file. It means that we don’t want a failure of command -v "rbenv-$command"
to trigger an exit of this script.
The value of the command substitution depends on what command -v "rbenv-$command"
evaluates to. This might be a bit confusing to read, because command
is the name of a shell builtin, but $command
is the name of a shell variable that we declared earlier.
If we run help command
to see what this shell builtin does, we get:
bash-3.2$ help command
command: command [-pVv] command [arg ...]
Runs COMMAND with ARGS ignoring shell functions. If you have a shell
function called `ls', and you wish to call the command `ls', you can
say "command ls". If the -p option is given, a default value is used
for PATH that is guaranteed to find all of the standard utilities. If
the -V or -v option is given, a string is printed describing COMMAND.
The -V option produces a more verbose description.
So according to the above, calling command ls
is not the same as calling ls
.
Let’s say you have a shell function named ls
, in addition to the regular ls
command, and you call ls
in your shell. In this case, Bash will try to execute the shell function first, before executing the ls
command. The way to bypass this behavior, and go straight to the shell command, is with the command
command. This applies to aliases as well as shell functions.
As the help
output mentions, adding the -v
flag results in a printed description of the command you’re running. When I pass -v
to command ls
, my terminal displays the path to the executable (/bin/ls
) instead of actually executing the command.
So in our case, if we type rbenv version
in our terminal, then this line of code will evaluate to:
command_path="$(command -v "rbenv-version" || true)"
Since we loaded libexec
into $PATH
earlier, rbenv-version
is considered a valid executable, which means command -v rbenv-version
will return /Users/myusername/.rbenv/libexec/rbenv-version
on my machine.
It is this value that gets stored in the command_path
variable. We can prove that in our terminal by adding libexec/
to our $PATH
and running command -v rbenv-version
:
$ PATH="$(pwd)/libexec:$PATH"
$ command -v rbenv-version
/Users/myusername/.rbenv/libexec/rbenv-version
If we had typed rbenv foobar
or another known-invalid command, then command -v rbenv-foobar
would have returned nothing, in which case we would store the boolean value true
in command_path
:
$ command -v rbenv-foobar
$
That knowledge is useful when interpreting our next line of code:
if [ -z "$command_path" ]; then
...
fi
In other words, if the user’s input did not result in a valid command path, then we execute the code inside this if
block.
That code is:
if [ "$command" == "shell" ]; then
abort "shell integration not enabled. Run \`rbenv init' for instructions."
else
abort "no such command \`$command'"
fi
So if the user’s input was the string “shell”, then we abort with one error message (shell integration not enabled. Run 'rbenv init' for instructions.
).
We would reach this branch if we tried to run rbenv shell
either:
- before adding
eval "$(rbenv init - bash)"
to our~/.bashrc
config file (if we’re using Bash as a shell), - before adding
eval "$(rbenv init - zsh)"
to our~/.zshrc
file (if we’re usingzsh
as a shell), - etc.
In this case, $command_path
would be empty.
On my machine, I have the above rbenv init
command added to my ~/.zshrc
file, so I can’t reproduce this error in zsh
. However, I don’t have the equivalent line added to my ~/.bashrc
file. So if I open up a Bash shell and type rbenv shell
, I get the following:
bash-3.2$ rbenv shell
rbenv: shell integration not enabled. Run `rbenv init' for instructions.
We’ll get to why $command_path
has a value in my zsh
and no value in my Bash later, when we examine the rbenv-init
file in detail.
In our else
clause (i.e. if $command
does not equal "shell"
), we abort with a no such command
error. I’m able to reproduce the else
case by simply running rbenv foobar
in my terminal.
bash-3.2$ rbenv foobar
rbenv: no such command `foobar'
So to sum up this entire if
block- its purpose appears to be to handle any sad-path cases, specifically:
- if the user enters a command that isn’t recognized by RBENV, or
- if the user tries to run
rbenv shell
without having enabled shell integration by adding the right code to their shell’s configuration file.
Moving on to the next line of code:
shift 1
This just shifts the first argument off the list of rbenv
’s arguments. The argument we’re removing was previously stored in the command
variable, so we no longer need to reference it with "$1"
. The call to shift
makes it easier to access the next argument, if any.
Printing help
Instructions For The User’s Command
Next line of code:
if [ "$1" = --help ]; then
...
else
...
fi
;;
Now that we’ve shifted off the command
argument in the previous line, we have a new value for $1
. Here we check whether that new first arg is equal to the string --help
. An example of this would be if the user runs rbenv init --help
.
If the user did pass the --help
flag, we execute the next block of code:
if [[ "$command" == "sh-"* ]]; then
echo "rbenv help \"$command\""
else
exec rbenv-help "$command"
fi
In the first half of this nested conditional, we check whether the user entered a command which starts with “sh-“. If they did, and if they followed that command with --help
, then we print rbenv help "$command"
to STDOUT.
I try this in my terminal by typing “rbenv sh-shell –help”, and I see the following:
$ rbenv sh-shell --help
rbenv help "sh-shell"
So the output matches what we’d expect from reading the code, but I’m still confused on why the code is written this way in the first place. At this point we know enough to tell the user which command they should have run, because that’s what was just printed. Why don’t we just run that same command, and save the user the work of doing so themselves?
I find the PR which added this block of code and read through it. Turns out the code used to look like this:
if [ "$1" = --help ]; then
exec rbenv-help "$command"
else
exec "$command_path" "$@"
fi
This is much closer to what I’d expect, because in both cases we’re calling exec
, as opposed to echo
in the if
branch and exec
in the else
branch.
But according to the PR description, this caused the rbenv shell --help
command to trigger an error. I run git checkout
on the commit just before this PR was merged (SHA is 9fdce5d069946417d481fd878c5c005db5b4539c
), and try to reproduce the error:
$ rbenv shell --help
(eval):2: parse error near `\n'
OK, so what caused this error?
Making use of RBENV_DEBUG
I remember we have the ability to run in verbose mode by passing in the RBENV_DEBUG
environment variable, so I try running RBENV_DEBUG=1 rbenv shell --help
. It results in a ton of output of course, and the last few lines of that output are:
...
+ [rbenv-help:124] echo
+ [rbenv-help:125] echo '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.'
+ [rbenv-help:126] echo
(eval):2: parse error near `\n'
Here we can see the lines of code that are reached (rbenv-help:124
, rbenv-help:125
, rbenv-help:126
, and finally, (eval):2
).
For comparison, I run this same command but with rbenv version
instead of rbenv shell
, and the last few lines of the verbose output are:
...
+ [rbenv-help:124] echo
+ [rbenv-help:125] echo 'Shows the currently selected Ruby version and how it was
selected. To obtain only the version string, use `rbenv
version-name'\''.'
Shows the currently selected Ruby version and how it was
selected. To obtain only the version string, use `rbenv
version-name'.
+ [rbenv-help:126] echo
The same lines of code are logged, but no (eval):2
error at the end. So I think we’re in the right neighborhood here. But where is this call to eval
happening, and why does it result in an error for shell
, but not for version
?
OK, I’ll stop here and admit that I cheated a little. I initially was stumped by this question, so I decided to write down the question so I didn’t forget it, and continue reading the code. Then, months later when I was re-reading and editing this post, I leveraged the knowledge I had gained during those months to deduce what is happening here.
TL;DR- one of the things that RBENV does when you add shell integration is create a shell function (also called rbenv
). At the time this PR was introduced, that shell function looked like this:
$ which rbenv
rbenv () {
local command
command="$1"
if [ "$#" -gt 0 ]
then
shift
fi
case "$command" in
(rehash | shell) eval "$(rbenv "sh-$command" "$@")" ;;
(*) command rbenv "$command" "$@" ;;
esac
}
You can verify this by:
- checking out the commit
9fdce5d069946417d481fd878c5c005db5b4539c
, i.e. the commit just before the above PR was merged. - running
rm -r ~/.rbenv/shims/*
followed byrbenv rehash
to re-generate all your shims based on this new version of RBENV. - running
which rbenv
from your terminal, and verifying that it prints out a complete shell function instead of printingpath/to/file/rbenv.bash
or something similar. - NOTE- when you’re done exploring this older version of RBENV, don’t forget to check out the latest version again, and re-generate your shims so that they’re up-to-date.
When you run rbenv
commands from inside your terminal with shell integration enabled, you’re not running the rbenv
bash script, at least not directly. Instead, you’re actually running this shell function, which in turn calls the rbenv
script.
This shell function gets defined by the eval "$(rbenv init -)"
call in your shell config, each time you open a new terminal tab. When responding to a command, UNIX checks for shell functions before it checks your PATH
for any commands. Because of this, the shell function is what gets run when you type rbenv
into your terminal.
One of the things the shell function does is execute this block of code, which checks if the command that the user is running begins with sh-
. If it does, it runs eval
plus the name of the command with its sh-
prefix. Otherwise, it runs the command via the command
command (which we discussed earlier). This was also the case at the the time the error was reported.
It’s this call to eval
that is erroring out. We can prove that to ourselves by changing that block of code in rbenv-init
to look like the following:
set -e # I added this line
case "\$command" in
${commands[*]})
echo "just before rbenv sh-command" >&2 # I added this line
echo "result: "\$(rbenv "sh-\$command" "\$@")"" >&2 # I added this line
eval "\$(rbenv "sh-\$command" "\$@")"
echo "just after rbenv sh-command" >&2 # I added this line
;;
*)
I added the call to set -e
before the case
statement (so that the code will exit immediately if the eval
code throws an error), as well as the three echo
statements:
- one just before
eval
and one after. - the resolved values of any arguments that we pass to
eval
I then source
my ~/.zshrc
file so that these changes take effect, and I run which rbenv
to confirm that they appear in the updated shell function:
$ which rbenv
rbenv () {
local command
command="$1"
if [ "$#" -gt 0 ]
then
shift
fi
set -e
case "$command" in
(rehash | shell) echo "just before rbenv sh-command" >&2
echo "result: "$(rbenv "sh-$command" "$@")"" >&2
eval "$(rbenv "sh-$command" "$@")"
echo "just after rbenv sh-command" >&2 ;;
(*) command rbenv "$command" "$@" ;;
esac
}
Then, when I run rbenv shell --help
, I see the following:
$ rbenv shell --help
just before rbenv sh-command
result: Usage: rbenv shell <version> 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.
(eval):2: parse error near `\n'
[Process completed]
I see just before rbenv sh-command
, but not just after rbenv sh-command
. Since we added the set -e
option to our shell function, the code exited after the first error that it encountered. And since we only saw the first of the two echo
statements immediately before and after our call to eval
, it must have been this call to eval
which threw an error.
Furthermore, the “code” that we’re passing to eval
is:
Usage: rbenv shell <version> rbenv .....
This is a human-readable set of usage instructions. But eval
is meant to take a command which the terminal can run. We’re trying to execute a “command” which isn’t really a command. There’s our problem.
But I’m still wondering why commands prefixed with sh-
use eval
, while those without the prefix use command
. I decide to look up the command which introduced this if/else
block. After some digging, I find it here. It says:
Regular commands pass though as you’d expect. But special shell commands prefixed with sh- are called, but its result is evaled in the current shell allowing them to modify environment variables.
I check both commands which have sh-
in their names, i.e. rbenv-sh-shell
and rbenv-sh-rehash
. It looks like both of these files use echo
to print machine-readable commands as output, rather than executing those same commands themselves. This fits with the pattern described in the above PR link.
It sounds like the idea is to give the sh-
commands the ability to modify environment variables. I suspect that it wouldn’t have been possible to set env vars if we had used the same command
strategy that non-sh
commands are run with. To test this, I run an experiment.
Experiment- can command
set environment variables?
I write a script named sh-foo
, containing the following code:
#!/usr/bin/env bash
echo "export FOO='foo bar baz'"
This script is designed to mimic the sh-
scripts that are called using eval
.
I write a 2nd script named bar
, containing the following code:
#!/usr/bin/env bash
export BAR="bar bazz buzz"
This script is designed to mimic the non-sh
scripts, which are called using command
.
In my terminal, I run the following:
$ echo "$FOO"
$ PATH="$(pwd):$PATH"
$ which sh-foo
/Users/myusername/Workspace/OpenSource/impostorsguides.github.io/sh-foo
$ eval `sh-foo`
$ echo "$FOO"
foo bar baz
We can see that running sh-foo
via the eval
command had the effect of setting a value for the "$FOO"
env var, where there was no value beforehand.
Next, I try running the bar
script:
$ echo "$BAR"
$ which bar
/Users/myusername/Workspace/OpenSource/impostorsguides.github.io/bar
$ command bar
$ echo "$BAR"
$
This time, the environment variable was not set. This proves that using command
does not result in the setting of the environment variable.
We mentioned earlier that scripts which are prefaced with sh-
have the convention of echo
ing lines of code to stdout
. Now we understand why this is the case- that output is executed by the caller using eval
, because this is the only way for child scripts to affect the environment variables of a parent script.
More generally, this is also a useful design pattern if you need to run a child script which modifies the values of environment variables, and rely on those new values in the parent script. We can write the child script such that it echo
s the code for the modifications of the env vars, and have our parent script eval
the code that is echo
‘ed. This is a work-around we can use if we’re ever stymied by the fact that env var changes in a child script aren’t available to the parent.
Aside- backticks vs. command substitution
FYI, according to StackOverflow, the following syntax…
`sh-foo`
…is basically interchangeable with the syntax:
"$(sh-foo)"
In other words, surrounding a command with backticks is the same as using the "$(...)"
command substitution syntax. I used backticks to keep my experiment code as similar as possible to the eval
line of the rbenv
shell function.
Happy path- executing a regular command
Next (and final!) line of code in this file:
else
exec "$command_path" "$@"
This is the line of code which actually executes the command that the user typed. The $@
syntax expands into the flags or arguments we pass to that command.
To prove this, let’s update the code of this else
block to the following:
else
echo "what gets run: $command_path $@" >&2
exec "$command_path" "$@"
fi
When we then run rbenv local 3.0.0
, we see the following output:
$ rbenv local 3.0.0
what gets run: /Users/myusername/.rbenv/libexec/rbenv-local 3.0.0
So we exec
the filepath /Users/myusername/.rbenv/libexec/rbenv-local
, and pass 3.0.0
as the one and only arg to this command.
And with that, we’ve examined how rbenv
executes the commands in its API!
Let’s move on.