cdコマンドで-ディレクトリに入りたい！

# はじめに

みなさん普段シェルを扱う中で、`-` (ハイフン, ダッシュ) という文字はよく使うと思います。その主な用途は以下の2つではないでしょうか。

- オプション表現として
- 引数として (`% cd -`や`% git checkout -`など)

本記事では2点目の「引数としての`-`」について、「じゃあ`-`という名前のディレクトリがあったとき、`% cd -`で入れるのか？」という、しょうもない疑問の調査結果を書き留めたものになります。

# `% cd -`とは

`cd`コマンドの引数に`-`を渡すと、直前にいたディレクトリへ移動することができます。
```zsh:使用例 (mac OSのZshでの実行結果)
% pwd
~/hoge/fuga/hogehoge/fugafuga

% cd ~/tmp
% ...   # 何か作業をする

% cd -  # 直前にいた`~/hoge/fuga/hogehoge/fugafuga`に戻る
% pwd
~/hoge/fuga/hogehoge/fugafuga
```
直接繋がったディレクトリを行き来する分には、`% cd ..`やTAB補完を使えば事足ります。しかし上の例のように、
- 一時的に遠く離れたディレクトリで作業 -> 戻る

といった際、元いた場所の長いパスを書かなくてもハイフンですぐに戻ることができます。

`-`を引数として渡せるコマンドは、`cd`コマンドだけではありません。例えば`git`や`su`は、`-`を使う機会が多い部類なのではないかと思います。検索してみたら`-`に関するQiitaの記事もあり、面白かったです。

https://qiita.com/ryosukes/items/b9a3b2913f72e1127e58

# `-`ディレクトリに入りたい！

そんな便利な`% cd -`ですが、その存在は意外と知られていないのではないでしょうか。
そして、もしも`-`の意味を知らない人が`% cd -`を見たら、「`-`という名前のディレクトリに入ろうとしているのかな？」と思うに違いありません:triumph:

では、実際に`-`という名前のディレクトリに移動したいとき、`cd`コマンドにどんな引数を渡せばいいのでしょうか？

```zsh
% pwd
~/tmp

% mkdir hoge
% cd hoge
% pwd
~/tmp/hoge

% mkdir -  # エスケープしてもしなくても、`-`というディレクトリが作られる
% ls
-
```
上記のように、カレントディレクトリに`-`というディレクトリがあり、直前には`~/tmp`というパスにいた状態で、いくつかのコマンドを試してみます。

### `cd -`

まずは主役のこちら。
```zsh
% cd -
% pwd
~/tmp
```
`-`はディレクトリとしては認識してもらえず、直前にいた`~/tmp`へ移動してしまいました。

### `cd \-`

では、とりあえず`\`でエスケープしてみたらどうなるでしょうか？
```zsh
% cd \-
% pwd
~/tmp
```
変わらず、`-`はディレクトリとしては認識してもらえませんでした。

### `cd "-"`

`grep`コマンドとかでは、引数の文字列にメタ文字が含まれていても、ダブルクオーテーションで囲んであげればそのまま受け取ってもらえます。
```zsh
% cd "-"
% pwd
~/tmp
```
しかし`-`はディレクトリとしては認識してもらえず...
どうやら`-`はメタ文字という訳ではなさそうです。

### `cd "\-"`

やけくそです。
```zsh
% cd "\-"
cd: no such file or directory: \-
```
だめですよね...

### `cd ./-`

では、ちゃんとカレントディレクトリから相対パスで指定してあげましょう。
```zsh
% cd ./-
% pwd
~/tmp/hoge/-
```
お、**成功しました🎉**

# `-`って何者？

調べてみると、おそらく`-`は[Zshのメタ文字](https://zsh.sourceforge.io/Doc/Release/Expansion.html)でも、[正規表現のメタ文字](https://www.ibm.com/docs/en/informix-servers/12.10?topic=matching-metacharacters)[^1]でもないみたいです。なのでエスケープしても特に効果がないのは当たり前でした...
[^1]: 拡張正規表現においては、`-`はbetween的な意味を持つメタ文字です。ただ、本記事の内容は`unsetopt EXTENDED_GLOB`されたZshでの実行結果になります。

この`-`を処理するのは、各コマンドの引数parserになります。つまり、引数`-`に対する挙動は**各コマンドの実装次第で異なる**ということです。引数をパースする際、大抵の場合は１文字目から順番に見て処理を分岐していくので、`./-`という引数が正しくファイルパスとして認識されるのは直感的に納得です。

# `cd`コマンドにおける`-`の扱い

先ほどは、`％ cd ./-`であれば引数をファイルパスとして認識してもらえました。
ということは、「先頭の文字が`-`だとファイルパスとして認識してもらえない」ってことなのでしょうか？

試しに、`-`ディレクトリの中にもう一つディレクトリを作成して、`-`から始まるパス (`-/fuga`) を渡してみましょう。
```zsh
% ls
-

% mkdir ./-/fuga
% cd -/fuga
% pwd
~/tmp/hoge/-/fuga
```
**問題なく行けました。** 引数が`-`から始まる文字列だったとしても、ちゃんとファイルパスとして認識してもらえるようです。意外です:flushed:

`run-help`コマンドを見てみると、cdコマンドにも`-s`や`-q`などのオプションは存在します。しかし`cd`コマンドでは、引数が`-`から始まっていても無条件にオプションとして処理するわけではないようです。

### じゃあ`-s`というディレクトリは...?

引数が`-`から始まる文字列でも、オプションと誤認識せずファイルパスとして扱ってくれる...
それなら！！
オプションと同じ名前のディレクトリがあったら、引数はオプションかファイルパスのどちらとして認識されるのでしょうか！？
`-s`オプションを使って試してみましょう。(ちなみに`-s`オプションは、与えられたパス名にシンボリックリンクが含まれている場合はカレントディレクトリを変更しない、という効果です。)

```zsh
% mkdir ./-s
% ls
-s

% cd -s
% pwd
~  # `cd`コマンドは、引数にパスがなければホームディレクトリへ移動する機能を持つ
```
`-s`はオプションとして認識されて、ホームディレクトリに移動しました。オプション名とディレクトリ名が同じ場合、認識はオプションが優先されるということですね。

一方で、`cd`コマンドのオプションに存在しない`-z`という名前のディレクトリで試してみると、引数はファイルパスとして認識されます。
```zsh
% mkdir ./-z
% ls
-z

% cd -z
% pwd
~/tmp/-z
```
**つまり、`-`の次の文字が「存在するオプション文字」ならオプションとして、そうでないならファイルパスとして認識するみたいです。**

ならば、オプション文字から続くようなファイルパス (e.g. `-s/hoge`) もオプションとして認識されるのでしょうか？
```zsh
% ls
-s

% mkdir ./-s/hoge
% cd -s/hoge
% pwd
~/tmp/-s/hoge
```
なんと、ファイルパスとして認識されました...:hushed:

オプションとして認識される条件は一体なんなのでしょうか...
例えば、`-s`の後ろに文字が続いていると「オプションではない」と判断されるのかもしれません。
```zsh
% ls
-s

% cd -sa
cd: no such file or directory: -sa

% cd -s/
% pwd
~/tmp/-s
```
いけましたー
引数がオプションとして認識されるには、`-`の次の文字が「存在するオプション文字」であり、なおかつその文字で終わっている必要がありそうです。

## まとめ(予想)

芋づる式に疑問が生まれ、「`cd`コマンドにおける`-`の扱い」がなんとなく見えてきました。「`cd`コマンドでの`-`の扱い」は以下のようなフローになっているのかな？と考えました。
```python:if文 & 日本語
if 引数が一つ:
    if 一文字目が`-`である:
        if 二文字目がEOFである:  # つまり`-`単体
            "直前にいたパス"として認識
        else:
            if 二文字目がオプション文字である and 三文字目がEOFである:
                オプションとして認識
            else:
                ファイルパスとして認識
    else:
        ファイルパスとして認識
else:
    一つ目の引数をオプション, 二つ目をファイルパスとして認識
```

## 答え合わせ

Zshのソースコードは[github](https://github.com/zsh-users/zsh/tree/master)にて公開されています。せっかくなので、`cd`コマンドが書かれている[ファイル](https://github.com/zsh-users/zsh/blob/master/Src/builtin.c)をみてみましょう。
`cd`コマンドの実体は839行目にある`bin_cd`関数です。
```c:839行目
int
bin_cd(char *nam, char **argv, Options ops, int func)
{
    LinkNode dir;

    ...  // 省略

    // cd_get_dest関数が、引数を解析して移動を実行する関数
    if ( !(dir = cd_get_dest(nam, argv, OPT_ISSET(ops,'s'), func)) ) {
        // cd_get_dest関数の返り値がNULLならば異常終了
    	zsfree(getlinknode(dirstack));
    	unqueue_signals();
    	return 1;
    }

    cd_new_pwd(func, dir, OPT_ISSET(ops, 'q'));  // chpwd関数実行などadditionalな処理

    unqueue_signals();
    return 0;
}
```
引数を解析している`cd_get_dest`関数。
```c
static LinkNode
cd_get_dest(char *nam, char **argv, int hard, int func)
{
    LinkNode dir = NULL;
    LinkNode target;
    char *dest;

    // 引数の数によって処理分岐
    if (!argv[0]) {
        // 引数が一つもないとき
        ...
        
    	else if (func != BIN_POPD) {
    	    if (!home) {
        		zwarnnam(nam, "HOME not set");
        		return NULL;
    	    }
    	    zpushnode(dirstack, ztrdup(home));  // 目的地をhomeディレクトリに設定
    	}
    } else if (!argv[1]) {
        // 引数が一つのみのとき
    	int dd;
    	char *end;
    
    	doprintdir++;
        // POSIXではない && 引数が2文字以上 && 1文字目が'+'か'-'で2文字目以降に数字が含まれない
    	if (!isset(POSIXCD) && argv[0][1] && (argv[0][0] == '+' || argv[0][0] == '-')
    	    && strspn(argv[0]+1, "0123456789") == strlen(argv[0]+1)) {
    	    dd = zstrtol(argv[0] + 1, &end, 10);
            // 2文字目以降に数字が含まれないなら(3.5みたいな小数も検知対象)
            if (*end == '\0') {
                // (引数の1文字目が'+', PUSHDMINUSフラグが有効)のどちらかが真ならば
                if ((argv[0][0] == '+') ^ isset(PUSHDMINUS))
                    // ディレクトリスタックを古い方から数える
                    for (dir = firstnode(dirstack); dir && dd; dd--, incnode(dir));
                else
                    // ディレクトリスタックを新しい方から数える
                    for (dir = lastnode(dirstack); dir != (LinkNode) dirstack && dd;
                        dd--, dir = prevnode(dir));
                // dirがNULL or 現在地がディレクトリスタックのroot
                if (!dir || dir == (LinkNode) dirstack) {
                    zwarnnam(nam, "no such entry in dir stack");
                    return NULL;
                }
            }
    	}
    	if (!dir)
            // 引数が'-'ならoldpwdを、違うならその引数をdirstackにpush
            // `% cd -`の該当部分
    	    zpushnode(dirstack, ztrdup(strcmp(argv[0], "-")
    				       ? (doprintdir--, argv[0]) : oldpwd));
    } else {
        // 引数が2つ以上のとき
        ...
    }

    target = dir;
    ...
    // ディレクトリ移動の実行
    if (!(dest = cd_do_chdir(nam, getdata(dir), hard))) {
    	if (!target)
    	    zsfree(getlinknode(dirstack));
    	if (func == BIN_POPD)
    	    zsfree(remnode(dirstack, dir));
    	return NULL;
    }
    ...
    return target ? target : dir;
}
```
正しく読めている自信は全くないのですが、少なくとも
- 大元としては引数の数によって分岐
- `% cd -`の処理に該当していそうな部分が存在している

ということは確認できました。

## 他のコマンドでも扱いは同じなのか？
ここまでは`cd`コマンドでのハイフンの扱いについて見てきましたが、コマンドが変わるとその扱いも変わってきます。

例えば`mkdir`コマンドについて、カレントディレクトリに存在する`-`の中に`fuga`ディレクトリを作成しようとするケースを見てみます。
```zsh
% ls
-
% mkdir -/fuga
mkdir: illegal option -- /
usage: mkdir [-pv] [-m mode] directory_name ...
```
`-`から始まるパスを指定しても、オプションとして誤認識されてしまうようです。
一方で、冒頭に書いたように`-`というディレクトリを作るべく`-`単体を渡すと、意図通りに動きます。
`cd`は内部コマンド(shell依存)、`mkdir`は外部コマンド(os依存)なので、ハイフンの扱いを一例として、実装方針は一概に同じとは言えないのかもしれません。

# 結論

- `cd`コマンドの場合、`-`というディレクトリには`cd ./-`で入ることができる
- コマンドライン引数における`-`の扱いは、それぞれのコマンドの実装次第で異なる

もはや存在しているのが当たり前に感じてしまうシェルですが、もちろん誰かが考えて実装した産物です。普段使う全てのツールの仕組みを理解するのは現実的に難しいですが、その一端でも気持ちを知れると楽しいなと思いました😁