TODO: write a post on the pros and cons of fish vs zsh vs Bash.

Next line of code:

commands=(`rbenv-commands --sh`)

Storing the list of shell-specific commands

This line stores the output of rbenv-commands --sh in a variable called commands. Since this appears to be executing the libexec folder’s rbenv-commands script directly, I add libexec/ to my $PATH and run the same command, with the following results:

$ PATH=~/Workspace/OpenSource/rbenv/libexec/:$PATH
$ rbenv-commands --sh

rehash
shell

When I run rbenv commands --help, I see the following:

$ rbenv commands --help
Usage: rbenv commands [--sh|--no-sh]

List all available rbenv commands

I see --sh and --no-sh listed as valid flags in the Usage section, but no explanation as to what these flags do.

Looking at the rbenv-commands file itself, I see that the --sh flag narrows down the output to just the commands whose files contain sh- in their names (i.e. shell and rehash).

I’m not yet sure what makes these commands special or requires them to be treated differently, so I write down that question and decide to revisit it later.

Kicking off the rbenv shell function

Next line of code:

case "$shell" in
  fish )
    cat <<EOS
  function rbenv
...
esac

Here we use a case statement to execute one of several branches of code, based on the different values of our $shell var. Each branch handles a different shell program. We’ll look at what happens when the user’s shell is fish.

If that’s the case, we call the cat function, which takes as its input a heredoc string (a pattern that we previously saw in the init.bats test file).

Inside the heredoc is where we begin creating a function named rbenv. According to the docs, in fish we begin a function declaration with the function keyword.

We’re creating a function inside a string because that string will be sent to stdout, and later used as input for a call to eval in our shell configuration file. We do this because eval can execute the code we give it (in this case, our rbenv function definition). Furthermore, it execute that code in such a way that the rbenv shell function will actually be available for us to use.

Installing fish

The fish shell uses much different syntax than other shells use. We can refer back to the Fish shell docs, however I can already tell that I’ll need to install fish on my laptop in order to experiment with its syntax. For example, in the two lines above, why do we use a “$” sign on line 1, but not on line 2?

To install fish, I’ll use the Homebrew package manager:

brew install fish

Now when I write a fish shebang as the first line of my script, my computer will know how to handle that.

Storing the command name

Next few lines:

  set command \$argv[1]
  set -e argv[1]

The Escape Character

The \ before the dollar sign is called the escape character. It’s necessary because, without it, the interpreter will try to resolve $argv[1] into a Bash variable (since rbenv-init is being evaluated as a Bash script).

We don’t want that, at least not here. Instead, we want set command $argv[1] to be part of what gets echo‘ed to eval, so that it gets included in the definition of the rbenv shell function. So whenever you see \ in this part of rbenv-init, it’s because we want the character which comes after it to be treated as a string, not as code to be evaluated.

You’ll need to leave it out when trying to evaluate fish code inside a fish shell. Let’s strip it out now, to make the code easier to analyze:

  set command $argv[1]
  set -e argv[1]

This is the code as our fish shell function will see it.

NOTE- occasionally, we will want Bash to treat code with a dollar sign as code. If we see a $ without the escape character, we’ll know that we’re looking at a variable which will be resolved to a specific value by the time it reaches fish. This might not make sense in the abstract, but I’ll call it out again when we reach a specific example.

Setting the command variable

To understand the above code, let’s open up a fish shell, and type man set:

SET(1)                                                                     fish-shell                                                                     SET(1)

NAME
       set - display and change shell variables

SYNOPSIS
       set
       set (-f | --function) (-l | local) (-g | --global) (-U | --universal)
       set [-Uflg] NAME [VALUE ...]
       set [-Uflg] NAME[[INDEX ...]] [VALUE ...]
       set (-a | --append) [-flgU] NAME VALUE ...
       set (-q | --query) (-e | --erase) [-flgU] [NAME][[INDEX]] ...]
       set (-S | --show) [NAME ...]
...

The SYNOPSIS section of the man entry shows us several ways to invoke set. The one we’re using here is set [-Uflg] NAME [VALUE ...]. The way we set a shell variable in fish is not with the syntax foo='bar', but rather set foo 'bar':

> set foo 'bar'

> echo "$foo"

bar

In the case of our rbenv shell function, instead of creating a variable named foo and setting it equal to "bar", we’re creating a variable named command and setting it equal to $argv[1]. What does $argv[1] do?

argv in fish

According to the docs, argv is:

argv

a list of arguments to the shell or function. argv is only defined when inside
a function call, or if fish was invoked with a list of arguments, like fish
myscript.fish foo bar. This variable can be changed.

So argv is the list of arguments passed to (in our case) the rbenv shell function that we’re defining. And it looks like we can access them using array indexing syntax. Let’s confirm that with an experiment.

Experiment- accessing argv in fish

I make a simple fish script, containing the following:

#!/usr/bin/env fish

function foo
  echo "argv: $argv"
  echo "argv[0]: $argv[0]"
  echo "argv[1]: $argv[1]"
  echo "argv[2]: $argv[2]"
  echo "argv[3]: $argv[3]"
end

When I run it, I get:

> ./foo.fish

argv: bar baz buzz

./foo.fish (line 5): array indices start at 1, not 0.
  echo "argv[0]: $argv[0]"
                       ^
in function 'foo' with arguments 'bar baz buzz'
	called on line 11 of file ./foo.fish

argv[1]: bar

argv[2]: baz

argv[3]: buzz

A few things to call out here:

  • We got an error, but the code continued executing. Presumably that’s because we didn’t include the fish equivalent of the Bash set -e command, so the code just continues executing when it hits an error.
  • As the error states, fish array indices start at 1, not 0.
  • We were correct in our hypothesis that we can access the individual args in argv using array indexing syntax.

Removing an argument from the list

So we store the first argument inside the new variable named command. Then we call set -e argv[1]. But remember, we’re in fish-land now, so set -e here does not mean the same thing it does in Bash.

According to the SYNOPSIS section of the above man entry for set, the -e flag is short for --erase. The longer name of the flag --erase suggests to me that we’re deleting the value inside argv[1].

Let’s see if that’s true with another experiment.

Experiment- modifying argv in fish using set

I write a simple fish script, which declares a function that takes in any args which are passed from the command line:

#!/usr/bin/env fish

function foo
  echo "old argv: $argv"
  set -e argv[1]
  echo "new argv: $argv"
  exit
end

foo $argv

I call it from the command line, passing in a few arguments:

$ ./foo bar baz buzz

old argv: bar baz buzz
new argv: baz buzz

Initially, the args passed to the foo function are bar, baz, and buzz. After I call set -e argv[1], the new args are baz and buzz. This means that set -e argv[1] has the effect of removing the first arg from the arg list. This is the same thing that shift does in zsh.

So to summarize the following lines:

set command $argv[1]
set -e argv[1]

Taken together, these two lines mean that we’re creating a variable named command and setting its value equal to the value of argv[1], and then we’re deleting argv[1] itself.

Executing the correct rbenv command

Next few lines of code:

switch "\$command"
  case ${commands[*]}
    rbenv "sh-\$command" \$argv|source
  case '*'
    command rbenv "\$command" \$argv
  end
end

To make this easier to read, let’s first remove the escape characters from the code:

switch "$command"
  case ${commands[*]}
    rbenv "sh-$command" $argv|source
  case '*'
    command rbenv "$command" $argv
  end
end

This is almost correct, but it’s not what we would see in fish. Notice that the code ${commands[*]} has a dollar sign, but it did not have an escape character. That means Bash will treat this as code, and will resolve it to a value before it gets echo‘ed.

So what would it resolve to? The easiest way to determine this is to simply print out the function definition. But we haven’t yet installed the rbenv shell integrations in our fish shell yet. Let’s do that first.

Installing shell integrations in fish

In my fish shell, I type rbenv init to get the installation instructions that are printed out due to this block of code:

> rbenv init

# Load rbenv automatically by appending
# the following to ~/.config/fish/config.fish:

status --is-interactive; and rbenv init - fish | source

It tells me to copy the code status --is-interactive; and rbenv init - fish | source into the file ~/.config/fish/config.fish. So that’s what I do. I then open a new terminal tab and enter the fish shell again.

If I were in Bash, I could print out the shell function by typing which rbenv. I don’t know what the equivalent of that is in fish, so I Google “print a shell function definition fish”. The first link I find is this one:

In the fish shell, I type functions rbenv:

> functions rbenv

# Defined via `source`
function rbenv
  set command $argv[1]
  set -e argv[1]

  switch "$command"
  case rehash shell
    rbenv "sh-$command" $argv|source
  case '*'
    command rbenv "$command" $argv
  end
end

So by the time we get to defining the body of the rbenv shell function, the line case ${commands[*]} resolves to case rehash shell. This maps to what we see if we type rbenv commands --sh directly in our terminal:

> rbenv commands --sh

rehash
shell

So the code we’re really trying to read is:

  switch "$command"
  case rehash shell
    rbenv "sh-$command" $argv|source
  case '*'
    command rbenv "$command" $argv
  end

Much easier to read.

Executing the user’s command

So if the command that the user typed in was either rehash or shell, then we run:

rbenv "sh-$command" $argv|source

And if the user’s command wasn’t one of those two commands, we run:

command rbenv "$command" $argv

The first thing to notice is that we execute shell or rehash by calling rbenv directly, whereas we execute any other commands by calling command rbenv. Let’s recall our earlier coverage of the command command. This command skips any shell functions, aliases, etc. and goes straight to PATH when searching for how to execute the program that it’s given.

A quick proof of that is follows. I define a function named ls in my terminall, which just prints Hello world. Then I run the function as I would the ls utility:

$ ls() {
function> echo "Hello world"
function> }

$ ls ./
Hello world

When I preface ls with the command command, I get my normal ls behavior back:

$ command ls ./

404.html				_config.yml				cp_img					sales_letter.md
404.md					_data					feed.xml				script
CNAME					_includes				foo					start-here.md
Gemfile					_layouts				index.md				timestamped-archived-pages
Gemfile.lock				_sass					rbenv
Github commits to consider analyzing	_site					resources
README.md				assets					robots.txt

But back to our case statement.

The fact that we’re using command rbenv in the '*' case tells us that we’re explicitly trying to avoid the rbenv shell function. And by extension, the fact that we’re not using command in the first branch of the case must mean we’re not trying to avoid that shell function.

In other words, in the first case, we want to call the rbenv shell function, not the version of rbenv which is in our $PATH (i.e. the libexec/rbenv file that we previously examined). In the 2nd case, we want to call the rbenv file directly, bypassing the shell function.

I was wondering why this is the case, so I looked up the git history for this block of code. I discovered this PR from 2011:

The goal seems to be to allow shell-specific commands to set and/or modify environment variables in the current environment. In the context of the fish shell, this is done by:

  • calling rbenv "sh-$command" $argv (which is just the code from here, minus the escape characters), and then
  • piping the output from that command to the source command.

If we look at the fish docs for the source command, we can see:

the commands will be evaluated by the current shell, which means that changes in shell variables will affect the current shell.

This sounds a lot like what the stated goal of the PR was.

With that, we come to the end of the shell function definition for fish. Next, we’ll look at the shell function definitions for the remaining shells that RBENV supports. These function definitions are largely identical, so we can cover them all in one post.