久しぶりに Rails を使う機会があり rbenv を久しぶりに使ったが, 仕組みがいまいちわかってないのでコードをざっくり読んでみた. 以下その際のメモ書き.

まず .bash_profile 等に記載する以下のコードについて,

eval "$(rbenv init -)"

これにより, 以下のようなコードが展開される.

export PATH="/Users/ユーザ名/.rbenv/shims:${PATH}"
export RBENV_SHELL=bash
source '/usr/local/Cellar/rbenv/1.1.0/libexec/../completions/rbenv.bash'
command rbenv rehash 2>/dev/null
rbenv() {
  local command
  command="$1"
  if [ "$#" -gt 0 ]; then
    shift
  fi

  case "$command" in
  rehash|shell)
    eval "$(rbenv "sh-$command" "$@")";;
  *)
    command rbenv "$command" "$@";;
  esac
}

rbenv(){…} の動作を見ていくにあたり, “rbenv install 2.3.1” を実行した場合を考える. まず変数の command(以下 $command と表記する)には $1, つまり “install” が入る. そして次の if で引数が shift して $@ は “install 2.3.1” から “2.3.1” となる.

続いて, case での分岐で $command が “install” であり “rehash” または “shell” でないので, command rbenv “$command” “$@” が実行される. command rbenv の部分は /usr/local/bin/rbenv になり, 全体としては /usr/local/bin/rbenv “install” “2.3.1” になる.

なお, この command は shell の alias や function 以外のコマンドを実行する shell の組み込みコマンドで, これがないと上に書いてある function の rbenv が呼ばれてしまう. 以下, コマンドの rbenv は単に rbenv と表示し, function の rbenvは rbenv() と表記する.

そして, $command が “rehash” のとき, つまり rbenv rehash では eval “$(rbenv “sh-$command” “$@”)” が実行され, 変数が展開されると eval $(rbenv “sh-rehash”) となり, まず rbenv() がもう一度呼ばれ, この際の command は “sh-rehash” となっているため, command rbenv “$command” “$@” が実行される. そしてこの返り値が eval で評価される. “shell” の場合も同様である.

command が “rehash” や “shell” の場合は command rbenv “$command” “$@” を実行した返り値がコマンド(※)であり, それをシェルで実行させるため, このような case を用いた書き方になっている.
※ rbenv sh-rehashやrbenv sh-shell 2.3.1などと実行すると確認できる

次に, rbenv コマンドの方を見ていく. /usr/local/bin/rbenv では /usr/local/Cellar/rbenv/1.1.0/libexec/ をPATHに追加して, ここにあるシェルスクリプト群を呼び出すようになっている. つまり, rbenv command で実行されるシェルスクリプトは /usr/local/Cellar/rbenv/1.1.0/libexec/ に rbenv-command としておいてある. ただし, rbenv rehash と rbenv shell は上述したように rbenv sh-rehash, rbenv sh-shell となるので rbenv-sh-rehash, rbenv-sh-shell となる.

最後に, /Users/ユーザ名/.rbenv/shims以下にあるファイルについて見ていく. これらは rbenv rehash つまりrbenv-sh-rehash(の中で呼ばれる rbenv-rehash)によって生成されるシェルスクリプトである. つまり ruby, irb, rails 等を使用する際に実際に実行しているラッパーコマンド群である. これらを見てみると, すべて以下のようなシェルスクリプトであることが分かる.

#!/usr/bin/env bash
set -e
[ -n "$RBENV_DEBUG" ] && set -x

program="${0##*/}"
if [ "$program" = "ruby" ]; then
  for arg; do
    echo "$arg"
    case "$arg" in
    -e* | -- ) break ;;
    */* )
      if [ -f "$arg" ]; then
        export RBENV_DIR="${arg%/*}"
        break
      fi
      ;;
    esac
  done
fi

export RBENV_ROOT="/Users/ユーザ名/.rbenv"
exec "/usr/local/Cellar/rbenv/1.1.0/libexec/rbenv" exec "$program" "$@"

まず $program には ${0##*/}, つまり $0 の basename が入る. 例えば, /usr/local/bin/rbenv install 2.3.1 では $0 は /usr/local/bin/rbenv だが, ${0##*/} によって rbenv となる.

下記のQiita記事がシェルでの basename, dirname の取得について簡潔にまとまってる.
bashの変数展開によるファイル名や拡張子の取得

続いて, コマンドが ruby だった場合で後続の引数にファイルが含まれる場合, つまり ruby ~/Desktop/hoge.rb などとする場合は RBENV_DIRに/User/ユーザ名/Desktop が入る(${arg%/*} は dirname を取得する変数展開). なお, for arg; do ~ done は for arg in “$@”; do ~ doneのショートハンドである.

この変数(RBENV_DIR)をどこで使ってるか探したところ /usr/local/Cellar/rbenv/1.1.0/libexec/rbenv-version-file (rbenv の version 指定ファイルから version を読み取るスクリプト)にて下記の記述があった.

  find_local_version_file "$RBENV_DIR" || {
    [ "$RBENV_DIR" != "$PWD" ] && find_local_version_file "$PWD"
  } || echo "${RBENV_ROOT}/version"

つまり, ruby コマンドで実行する .rb ファイルのおいてあるディレクトリ($RBENV_DIR)において rbenv local で ruby のバージョン指定が行われている場合, そのバージョンを利用するための変数と思われる. これを見ると, ruby ~/Desktop/hoge.rb を実行する場合, まず hoge.rb のある ~/Desktop 以下の local のバージョン指定を見て, そのあとカレントディレクトリ下の local のバージョンを見てるが, local の指定の優先度は 実行するrbのディレクトリ下 > カレントディレクトリ下であることがわかる.

最後に, exec “/usr/local/Cellar/rbenv/1.1.0/libexec/rbenv” exec “$program” “$@” としている. これはつまり rbenv(function ではない方) を引数 exec, “$program” “$@”で実行する(rbenv での exec コマンド)ということ.

そして, rbenv-exec では rbenv-version-name で指定した ruby のバージョンを取得(ここで上述した rbenv-version-file が使われる), rbenv-which で指定したバージョンの実行するコマンドのフルパス(RBENV_COMMAND_PATH)を作成し, 更にそのコマンドが存在するディレクトリ(RBENV_BIN_PATH)を PATH に追加し, execしている.

ちなみによく疑問で出て来る「rbenv rehash は何してるの」は /Users/ユーザ名/.rbenv/shims 以下にラッパーを(gem などで環境に変更があった場合, 例えば rails を入れたら rails コマンドのラッパーを作る)用意し, シェルのコマンド検索のハッシュテーブルを空にしている.