いかに素早く目的のディレクトリにcdするか、そのためのTips


我々がシェルを操作している時間のうち、たぶん8割くらいは`cd`と`ls`を打っているんじゃないかとすら、私は思っています。

世の中には様々なシェルハックが溢れている昨今ですが、`ls`は置いといて、`cd`のコストを減らせれば、そこそこの効率化が図れるのではないでしょうか。そんなことを考えて色々な改善を考えてみたことをまとめた記事です。

私はBashユーザなのでBashの話ですが、Zshでも多少の改変で応用が効くのではないかと思います。

ここでの試行錯誤は2014年3月現在のものです。興味深い改造があれば都度書き足していきます。

##cdのオプション

まず `man bash` から `cd` の説明を観てみましょう。

       cd [-L|-P] [dir]
              Change the current directory to dir.  The variable HOME  is  the
              default  dir.   The  variable CDPATH defines the search path for
              the directory containing dir.  Alternative  directory  names  in
              CDPATH  are  separated by a colon (:).  A null directory name in
              CDPATH is the same as the current directory,  i.e.,  ``.''.   If
              dir  begins  with  a  slash (/), then CDPATH is not used. The -P
              option says to use the physical directory structure  instead  of
              following  symbolic  links  (see  also  the -P option to the set
              builtin command); the -L option forces symbolic links to be fol-
              lowed.   An  argument  of - is equivalent to $OLDPWD.  If a non-
              empty directory name from CDPATH is used, or if - is  the  first
              argument,  and  the directory change is successful, the absolute
              pathname of the new working directory is written to the standard
              output.   The return value is true if the directory was success-
              fully changed; false otherwise.

`cd`コマンド、世界中のユーザに日々相当使われているけど、オプションは相当貧弱だなと思います。`-L` も `-P` も使いどころがそれほどない。これらのオプション、日々使っている人いるんでしょうか。

せめてウェブブラウザでいうような「進む」「戻る」「ブックマーク」「検索」といったインターフェースがあればいいのにと思って、色々作ってみました。

ちなみに、`cd -` とすれば直前にいたディレクトリに戻ることができますが、履歴をたどって戻り続けるといったようなことはできません。`cd -`を連続で打っても、2つのディレクトリ間を行き来するだけです。

そこで私なりの`cd`ハックを以下でご紹介してみようと思います。面白そうな機能があれば取り入れていただければ幸いです。

##pushdとpopd、そしてdirs

まずは`pushd`と`popd`について解説しておかなければなりません。

Bashには`pushd`と`popd`という組み込みコマンドがあるのですが、あまり使われている場面に出くわしたことがありません。`pushd`は、カレントディレクトリをスタックに保存して与えれたディレクトリに移動する、もう一つの`cd`コマンドといったものです。`pushd 移動したいディレクトリ` といった感じで、`cd` コマンドと同様に使えます。

スタックに積まれたディレクトリで一番新しいディレクトリに移動したい場合には`popd`コマンドを使います。これは引数無し。スタックに積まれた一番新しいディレクトリに移動して、スタックからその情報を削除します。いわゆるLIFOですね。

スタック一覧を見たい場合には`dirs`コマンドを使います。スタックを削除したり表示方法を替えたりといったオプションもありますが、詳しくは `man bash` の中から `dirs` の項目を参照下さい。

    dirs [-clpv] [+n] [-n]

##cdをpushdで再定義する

`pushd` が `cd` の代わりとして使えるとはいえ、`cd` の方が打つのに慣れているし、`pushd` って5文字もあるしで、恒常的に `pushd` を使うのは面倒です。であれば `cd` の定義を `pushd` を使ったもので置き換えてしまおうと考えました。

普段から `cd` と `pushd` を使い分けている人は、この設定はスタックの履歴が混ざってしまうので問題となりますが、「普段のシェル作業で `pushd` なんて使わないよ」という人は、この設定にしてしまって問題無いと思います。私は数年間この設定で作業していますが、特段パフォーマンス的だったりといった問題が出たことはありません。

```Bash
# 履歴を記録する cd の再定義
function cd {
    if [ -z "$1" ] ; then
        # cd 連打で余計な $DIRSTACK を増やさない
        test "$PWD" != "$HOME" && pushd $HOME > /dev/null
    elif ( echo "$1" | egrep "^\.\.\.+$" > /dev/null ) ; then
        cd $( echo "$1" | perl -ne 'print "../" x ( tr/\./\./ - 1 )' )
    else
        pushd "$1" > /dev/null
    fi
}

```

これらの関数は `~/.bashrc` に書くか、`~/.bashrc` から `source` (`.`) コマンドで読まれるファイルに書く必要があります。

定義の説明ですが、まず `cd` コマンドを引数無しで打ったときにホームディレクトリに戻ることができる機能があって、ここでも`pushd`でこれを再現していますが、これをホームディレクトリで打ったときに `pushd` が余計なスタックを増やさないような配慮をしています。

またオマケ機能として、`cd ..` で1つ上のディレクトリに戻れる機能の拡張として、`cd ...` と打つと2つ上のディレクトリに戻れ、$n$ 個のドットで $n-1$ つ上のディレクトリに戻れる機能を書いてあります。シェル力が足りなかったので、`egrep` で検査した上で必要があればPerlを起動するように書いていますが、ここのパフォーマンスが特段気になる方は削ってもよいと思います。ここの `then` 節で表れる `cd` はこの関数の再起呼び出しとして解釈されます(無限ループには注意して書いたつもりです)。

そうでなければシンプルに `pushd` に引数を渡してカレントディレクトリを変更しています。`pushd` はいちいちスタックに入れたディレクトリについての報告を標準出力にしてくるので、`>/dev/null` しておきます。

この定義は、一部の人が使い慣れた `cd -` を潰したりはしません。

事情があって組み込みの`cd`コマンドを使いたい場合は `builtin cd 移動したいディレクトリ` というコマンドを使います。

##事前指定したディレクトリへ素早く移動する

ブラウザでいう「ブックマーク」のような機能が欲しいと思って、色々考えました。

`cd` コマンドには `CDPATH` という環境変数があって、`PATH` のように、ここにコロン区切りでパス群を入れておくと、カレントディレクトリがどこであろうと、このパス群からディレクトリを探すという機能があるのですが、私には使い勝手が悪く感じました。込み入った設定や状況にしておくと、意図しないディレクトリに飛んでしまったりとか。

`pushd` は `CDPATH` を見るのかとか調べる以前に、意図しないことが起こらないように、これは別コマンドにしてしまったほうがいいなと感じました。最初は `cdjump` という名前だったのですが、長いので `cdj` という名前にしました。

```Bash
# ショートカットキーで移動するcd
function cdj {
    ### cdjはCDJ_DIR_MAPという環境変数の配列の定義が必要です
    # CDJ_DIR_MAP配列の例は以下です。ディレクトリのエイリアスと実ディレクトリのパスを空白区切りでペアで書いていきます
#     export CDJ_DIR_MAP=(
#         dbox ~/Dropbox
#         cvs  ~/cvs
#         etc  /etc
#         );
    test -n "$DEBUG" && echo "DEBUG: dir arg=$arg #CDJ_DIR_MAP=${#CDJ_DIR_MAP[*]}" 
    declare arg=$1 \
            subarg=$2 \
            dir i key value warn
    if [ -z "$arg" -o "$arg" = "-h" ] || [ "$arg" = "-v" -a -z "$subarg" ] ; then
        ### help and usage mode
        echo "Usage: $FUNCNAME <directory_alias>"
        echo "       $FUNCNAME [-h|-v|-l <directory_alias>]"
        echo "-h: help"
        echo "-l: list defined lists"
        echo "-v <directory_alias>: view path specify alias."
        return
    elif [ "$arg" = "-v" -o "$arg" = "-l" ] ; then 
        ### view detail mode
        for (( i=0; $i<${#CDJ_DIR_MAP[*]}; i=$((i+2)) )) ; do
            key="${CDJ_DIR_MAP[$i]}"
            value="${CDJ_DIR_MAP[$((i+1))]}"
            if [ "$arg" = "-l" ] ; then
                if [ ! -d "$value" ] ; then
                    warn=" ***NOT_FOUND***"
                else
                    warn=""
                fi
                printf "%8s => %s%s\n" "$key" "$value" "$warn"
            elif [ "$arg" = "-v" ] ; then
                if [ "$key" = "$subarg" ] ; then
                    echo $value
                    return
                fi
            fi
        done
        return
    fi
    ### change directory mode
    for (( i=0; $i<${#CDJ_DIR_MAP[*]}; i=$((i+2)) )) ; do
        key="${CDJ_DIR_MAP[$i]}"
        value="${CDJ_DIR_MAP[$((i+1))]}"
        test -n "$DEBUG" && echo "$key => $value"
        if [ "$key" = "$arg" ] ; then
            if [ -n "$subarg" ] ; then
                dir="$value/$subarg"
            else
                dir="$value"
            fi
            cd "$dir"
            return
        fi
    done
    echo "directory alias \"$arg\" is not found"
    return 1
}
```

大昔に書いた自分も忘れてしまった部分もあるのですが、以下のような仕掛けです。

まず `CDJ_DIR_MAP` という配列を定義します。これがいわゆる `cdj` コマンドでの「ブックマーク」に相当します。上のコメントで書いたような例で書きます。いくらでも書けます。

最近のBashでは連想配列がサポートされたという話も聞いたり聞かなかったりするのですが、世間の新しくないBashに合わせて配列にキーバリューのペアで記録する形になっています。

前述の例であれば、`cdj dbox` と打てば `cd ~/Dropbox` と同じ意味になります。長く深いディレクトリであればあるほど、この恩恵は大きいです。

ヘルプを表示する `-h`、現在の設定を確認する `-l`、与えられたキーに対する実ディレクトリを表示する `-v` といったオプションも付けました。

`cd` コマンドは標準でディレクトリのタブ補完が効きます。`cdj` コマンドもタブ補完ができるようにしましょう。`function cdj` で `cdj` コマンドを定義した後で以下のような記述を書きます。

```Bash
type cdj >/dev/null 2>&1 &&
_cdj()
{
    local cur prev opts i
    COMPREPLY=()
    cur="${COMP_WORDS[COMP_CWORD]}"
    prev="${COMP_WORDS[COMP_CWORD-1]}"
    opts=$(for i in $(seq 0 2 $((${#CDJ_DIR_MAP[*]}-2)) ) ; do echo ${CDJ_DIR_MAP[$i]} ; done ; echo "-h -v -l")
    #echo ""
    #echo "> cur=$cur prev=$prev COMP_CWORD=$COMP_CWORD"
    #echo "> opts=$opts"
    #echo "> COMPWORDS=${COMP_WORDS[*]}"

    case ${prev} in
        -l)
            opts=$(for i in $(seq 0 2 $((${#CDJ_DIR_MAP[*]}-2)) ) ; do echo ${CDJ_DIR_MAP[$i]} ; done)
            COMPREPLY=( $(compgen -W "$opts" -- ${cur} ))
            return 0
            ;;
        *)
            ;;
    esac

    COMPREPLY=( $(compgen -W "$opts" -- ${cur} ))
    return 0
} &&
complete -F _cdj cdj
```

bash_completion の色々な書き方を真似て `CDJ_DIR_MAP` を見るようにしてみただけです。

私はbash_completionを導入していますが、もしかしたら事前にbash_completionを導入しておかないといけないかもしれません。それぞれのOS/ディストリビューション事に導入方法が違いますので、検索してみてください。

##取得したディレクトリ移動履歴を利用する

前述のように `cd` コマンドを再定義したことで、`pushd` のおかげでディレクトリ履歴が取られるようになりました。

であれば、その履歴を番号選択して移動ができる `cdhist`、`cd -` とは違い、打ち続けると履歴をたどっていける `popd` を利用した `cdback` コマンドを定義してみましょう。

```Bash
# 最近の cd によって移動したディレクトリを選択
function cdhist {
    local dirnum
    #dirs -v | head -n $(( LINES - 3 ))
    dirs -v | sort -k 2 | uniq -f 1 | sort -n -k 1 | head -n $(( LINES - 3 ))
    read -p "select number: " dirnum
    if [ -z "$dirnum" ] ; then
        echo "$FUNCNAME: Abort." 1>&2
    elif ( echo $dirnum | egrep '^[[:digit:]]+$' > /dev/null ) ; then
        cd "$( echo ${DIRSTACK[$dirnum]} | sed -e "s;^~;$HOME;" )"
    else
        echo "$FUNCNAME: Wrong." 1>&2
    fi
}

function cdback {
    #popd $1 >/dev/null
    local num=$1 i
    if [ -z "$num" -o "$num" = 1 ] ; then
        popd >/dev/null
        return
    elif [[ "$num" =~ ^[0-9]+$ ]] ; then
        for (( i=0 ; i<num ; i++ )) ; do
            popd >/dev/null
        done
        return
    else
        echo "cdback: argument is invalid." >&2
    fi
}
```

##カレントディレクトリにある、打つのが面倒なディレクトリへ移動

特に最近では日本語ディレクトリが多くなってきている印象です。日本語のファイル名やディレクトリ名は利用すれば便利なのですが、それはGUIでの話であって、CUIでは`cd`のときに打つのが面倒だったりといった問題があります。

そこで日本語変換をオンにせずとも、カレントディレクトリの一覧を番号付きで出して、番号を打つだけで移動ができる `cdlist` コマンドを定義してみましょう。

```Bash
# 現在のディレクトリの中にあるディレクトリを番号指定で移動
function cdlist {
    local -a dirlist opt_f=false
    local i d num=0 dirnum opt opt_f
    while getopts ":f" opt ; do
        case $opt in
            f ) opt_f=true ;;
        esac
    done
    shift $(( OPTIND -1 ))
    dirlist[0]=..
    # external pipe scope. array is established.
    for d in * ; do test -d "$d" && dirlist[$((++num))]="$d" ; done
    # TODO: Is seq installed?
    for i in $( seq 0 $num ) ; do printf "%3d %s%b\n" $i "$( $opt_f && echo -n "$PWD/" )${dirlist[$i]}" ; done
    read -p "select number: " dirnum
    if [ -z "$dirnum" ] ; then
        echo "$FUNCNAME: Abort." 1>&2
    elif ( echo $dirnum | egrep '^[[:digit:]]+$' > /dev/null ) ; then
        cd "${dirlist[$dirnum]}"
    else
        echo "$FUNCNAME: Something wrong." 1>&2
    fi
}
```

##名前でディレクトリ名を検索して移動する

例えば "qiita" という文字列が入ったディレクトリを大文字小文字区別せず検索して、その検索結果から移動したい場合。よくありそうですね。

`find / -iname \*qiita\* -type d`

これで出てきた結果を目視して、コピーアンドペーストをして移動…。面倒な感じがします。そもそも`find`コマンド自体が検索に時間がかかります。

Linuxには`locate`、Macには`mdfind`という、事前に作成したインデックスファイルを利用して高速検索ができるコマンドがあるので、`find /` するくらいなら、そちらを利用したほうがよさそうです。

大体の場合、移動したいディレクトリが、カレントディレクトリ以下にあるのか、ホームディレクトリ以下にあるのか、それとも `/` 以下にあるのか、いちいち区別するのは不便です。

そこで書いてみたのが `mdfind` を利用した `cdmdfind` です。

```Bash
# cd shortcut by mdfind (Mac OS X Spotlight CLI)
type mdfind >/dev/null 2>&1 && \
function cdmdfind {
    local arg="$1" path i=0 j selnum selpath OUTPUT
    declare -a pathes
    if [ -z "$arg" ] || [ "$arg" = "-h" ] ; then
        echo "Usage:"
        echo "  $FUNCNAME STRING"
        return
    fi
    # mdfind search is case insensitive
    for path in $(mdfind -name "$arg" | sed -e 's/ /+/g') ; do
        path=$(echo "$path" | sed -e 's/\+/ /g')
        test -d "$path" || continue
        i=$((i+1))
        pathes[$i]="$path"
    done
    if [ -z "${pathes[1]}" ] ; then
        # Nothing search result.
        return
    fi
    if [ $i -ge $LINES ] ; then
        OUTPUT=$PAGER
        test -z "$OUTPUT" && OUTPUT=cat
    else
        OUTPUT=cat
    fi
    for j in $(seq 1 $i) ; do
        printf "%2d: %s\n" $j "${pathes[$j]}"
    done | $OUTPUT
    read -p "select number: " selnum
    selpath="${pathes[$selnum]}"
    if [ -z "$selpath" ] ; then
        echo "$FUNCNAME: select is wrong." 1>&2
        return 1
    fi
    cd "$selpath"
}
```

検索を実行すると、メニューが出て…といったところは前述の `cdlist` をそのまま真似ました。

さらに `cdmdfind` を真似て `cdlocate` を書きました。

```Bash
# cd shortcut by locate
type locate >/dev/null 2>&1 && \
function cdlocate {
    local arg="$1" path i=0 j selnum selpath OUTPUT
    declare -a pathes
    if [ -z "$arg" ] || [ "$arg" = "-h" ] ; then
        echo "Usage:"
        echo "  $FUNCNAME STRING"
        return
    fi
    # mdfind search is case insensitive
    for path in $(locate "$arg" | grep -i -E "/[^/]*$arg[^/]*$" | sed -e 's/ /+/g') ; do
        path=$(echo "$path" | sed -e 's/\+/ /g')
        test -d "$path" || continue
        i=$((i+1))
        pathes[$i]="$path"
    done
    if [ -z "${pathes[1]}" ] ; then
        # Nothing search result.
        return
    fi
    if [ $i -ge $LINES ] ; then
        OUTPUT=$PAGER
        test -z "$OUTPUT" && OUTPUT=cat
    else
        OUTPUT=cat
    fi
    for j in $(seq 1 $i) ; do
        printf "%2d: %s\n" $j "${pathes[$j]}"
    done | $OUTPUT
    read -p "select number: " selnum
    selpath="${pathes[$selnum]}"
    if [ -z "$selpath" ] ; then
        echo "$FUNCNAME: select is wrong." 1>&2
        return 1
    fi
    cd "$selpath"
}
```

ほぼ `cdmdfind` そのままです。ただ、`locate` の出力が、`mdfind -name` とは違い、パス全体の中からの一致で非常に多くの検索が出てくるので `grep -i -E "/[^/]*$arg[^/]*$"` で検索結果を適切なように絞っています。

##他にもアイデアが…

この記事を書くにあたって、他にも`cd`を効率化することができるか考えたのですが、いくつか考えついたものがあります。指定ディレクトリ以下に `.git/` があるディレクトリを一覧してGitワークディレクトリを選択させるコマンドとか…。完成したら随時この記事に追記していこうとおもいます。

もしあなたにもアイデアがあれば、ぜひこの記事へのコメントや Twitter @[xtetsuji](https://twitter.com/xtetsuji) へ教えていただければ幸いです。

##参考

私の [GitHub リポジトリの dotfiles](https://github.com/xtetsuji/dotfiles/) にはこれらの設定例が書かれています。ただし、`~/.bash_secret` だけは秘密のファイルとして公開はしていません。