お魚大好き

大規模ソフトウェア実験でfish shellを手探るブログ。

fish shellに楽に改行できるキーバインドを追加した

概要

この記事は、東京大学電気電子・電子情報工学科で行われた「大規模ソフトウェアを手探る」実験においてfish shellのソースコードを読み、機能の追加を行った記録である。

fish shellについて

fish(Friendly interactive shell)UNIXにおけるシェルの一つ。自分で設定ファイルを書いていく必要のあるbashzshなどと違い、初期設定のままでも多くの便利な機能が使える点が特徴。

やったこと

実験2日目にテーマを決め、まず最初は軽めの機能を追加してみようということで、5日目までは『改行( \\n)を挿入するキーバインドを追加する』という目標に取り組んだ。

改行機能の詳細

ご存じの方も多いとは思うが、多くのシェルで"\"(バックスラッシュ)を打ってからエンターキーを押すと、新しい行にコマンドの続きを打てるようになる。 例えば、

$ echo \
  hoge

のように打つことができ、結果はもちろんhogeが出力される。

ただ、もっと快適に改行がしたいのである。改行をするためにスペース・バックスラッシュ・エンターの3つものキーを押したくないのである。 そこで、多くのGUIアプリケーションに倣ってShift+Enterで改行、Enterで実行、という形態をとればより直感的に、楽に改行ができるのではないかと考えた。

つまり、実装したい機能としてはこうである。例えば

$ ps aux

と打ったところでShift+Enterを押すと

$ ps aux \
 

というようにスペース・バックスラッシュ・改行が自動で補完される。

パイプで繋がれた長いコマンドを打ちたい時にも、この機能を使えば簡単に改行ができ、

$ ps aux \
  | grep bash \
  | awk '{print $2}' \
  | xargs kill -9

このように見やすい形でコマンドを打つことができる、というわけである。

手探った流れ

ビルドまで

まずはソースコードfishのGitHubから拾ってきて自分のPCでビルドするところから。README.mdを見ると、

autoreconf --no-recursive #if building from Git
./configure
make
sudo make install

このように書かれているのでこの通りやれば良いのだが、いくつか注意点がある。

今回はgdbでプログラムの流れを追いたいので、コンパイル時のオプションに-O0 -g-O0: 最適化なし、-g: デバッグシンボルの追加)を指定しておきたい。 その場合は./configureを行う前に環境変数CXXFLAGS="-O0 -g"を指定しておく。ここで注意が必要なのが、fishはC++で書かれているのでCFLAGSではなくCXXFLAGSを指定する、ということだ。これを間違えると自分たちのようにデバッグの時に正しく動作しなくなって困ることになる。 ちなみにCPPFLAGSという紛らわしい物のもあるので騙されないようにしたい。

また、デバッグだけしたいときはインストール先を変更したほうが良く、--prefixオプションでインストール先のディレクトリを変更できる。権限のあるホームディレクトリ以下を指定しておけばインストール時にsudoを求められることもなかろう。

以上をまとめると、./configureを実行するときは以下のようにすれば良い。

$ CXXFLAGS="-O0 -g" ./configure --prefix=/home/(ユーザ名)/fish-install

ちなみにbashではこの書き方で問題ないが、fishではコマンドの前に環境変数を指定する書き方ができないので、

$ set -x CXXFLAGS "-O0 -g"
$ ./configure --prefix=/home/(ユーザ名)/fish-install

のようにする必要がありそう。

これでビルドが完了し、fishを手探る準備が整った。

手探る

デバッグの前準備

gdbでプログラムの流れを追い、関連する処理を行っている関数を探す。ここでは、入力に応じてどんな処理が呼び出されるかをみたいので、別の端末から入力を行いながらデバッグする。
gdbを動かすターミナルと別にもう一つターミナルを立ち上げ、 reptyr を使って端末を確保する。

$ reptyr -l
Opened a new pty: /dev/pts/(適当な数が入る)

次に、gdbの側で

(gdb) set inferior-tty /dev/pts/(適当な数が入る)
(gdb) run

のようにすると、さっき立ち上げた別の端末でfishが動き始め、入出力はそちら側のターミナルでできるようになる。 あるいは、gdbattach機能を使って既に起動してあるfishのプロセスに対してデバッグを行っても良いだろう。

デバッグの流れ

デバッグの流れとしては、

  • とりあえずmain関数にブレークポイントをおく
  • nで処理を一行ずつ進めていき、怪しげな関数があったらsで関数の中に入る

といった感じで進めていくことが多いだろう。 今回の場合、キーバインドを押した時の処理がどのように行われているか知りたかったので

  • とりあえずブレークポイントは置かず、fishが入力待ちになるまで待つ
  • gdb側でCtrl-Cを押し、一時停止したらnで次に進む (この作業を行わないとキーを入力しても処理が流れてしまい、デバッグできない)
  • 再び入力待ちの状態になる
  • 入出力を行う方のターミナルで適当なキーを入力してみる (今回の目的は 『あるキーを押すとコマンドラインに特定の文字列が出力される』 というものなので、 Ctrl-V (ペースト) などを入力して処理を追いかけてみた)
  • 何らかのキーが入力された際に呼ばれる部分の処理を追う
  • さらに深く処理を追いかけ、怪しげな関数を探す。適宜ブレークポイントをおく

といった流れでデバッグを進めた。
また、並行して input とか key とか bind とかの いかにも な単語でgrepしてみると関係ありそうな関数が見つかり、精神衛生上優しい(ステップ実行は疲弊する作業なので)。

わかったこと・結果

Shift+Enterが取得できない・・・?

input.cppのinput_readch関数の中に

wchar_t c = input_common_readch(0);

という記述があり、ここで押されたキーを取得している。p cとして変数cの中身を出力してみると、Enterを押した時もShift+Enterを押した時もいずれも'\r'が入力されたことになっていた。 他のキーについても何が取得されていくかを追っていくうちに、どうやらCtrlShiftと同時にあるキーが押された、という情報は制御文字あるいはエスケープシーケンスとして、単一または複数の入力が渡されているようなのである。 しかし、残念なことにShift+Enterには特殊なエスケープシーケンスが割り当てられている様子もなく、調べてみると多くのターミナルの仕様でShift+EnterEnterは区別して送信されない、ということが判明した。

結論としては、fishをいくらいじってもShift+Enterの入力は受け取れない、ということであった。悲しい。 悲しいが、仕方ないので代わりにCtrl+Oに改行機能を割り当てることにした。

機能自体の実装

キーボードからの入力を解釈し、実際の処理を行っているのはreader.cppのreader_readline()関数のこの辺

case R_YANK: {
    yank_str = kill_yank();
    insert_string(data->active_edit_line(), yank_str);
    yank_len = wcslen(yank_str);
    break;
}
case R_YANK_POP: {
    if (yank_len) {
    for (size_t i = 0; i < yank_len; i++) remove_backward();

    yank_str = kill_yank_rotate();
    insert_string(data->active_edit_line(), yank_str);
    yank_len = wcslen(yank_str);
    }
    break;
}

yankもユーザの入力中の行に対する操作だし、case R_YANK:をこんな感じ

case R_YANK: {
    const wchar_t *str_to_insert = L" \\\n";
    insert_string(data->active_edit_line(), str_to_insert);
    break;
}

で書きかえれば、yankを所望の動作で置き換えられるのでは?
→できました
というわけで、他のinput functionの形式に倣ってちゃんと実装する。
input.cppの中で定義されるinput functionを示す定数

static const wchar_t *const name_arr[] = {L"beginning-of-line",
                                          L"end-of-line",
                                          L"forward-char",
...
static const wchar_t code_arr[] = {R_BEGINNING_OF_LINE,
                                   R_END_OF_LINE,
                                   R_FORWARD_CHAR,
...

L"new-line-with-backslash"R_NEW_LINE_WITH_BACKSLASH を加える。 ちなみに、name_arr[i]と合致するinput functionが指定されるとcode_arr[i]に対応する処理が行われる、といったような処理が行われるので、全く違う位置に加えると大変なことになる。 そしてreader.cppの先ほどの箇所に下のようなcase文を加えた。

case R_NEW_LINE_WITH_BACKSLASH: {
    const wchar_t *str_to_insert = L" \\\n";
    insert_string(data->active_edit_line(), str_to_insert);
    break;
}
デフォルトのキーバインドもconfigファイルに書かれていた...

キーバインドの指定はソースコードに書かれているものだと勝手に思い込み、gdbで追っていったが完全に泥沼だった。 input.cppのinput_mapping_execute_matching_or_generic関数で入力されたキーと登録されているキーバインドの照合が行われているのだが、そのキーバインドがどこで取得されているのかが全く分からず。

結局、気分転換に

$ grep -r yank

を行ったところ、

share/functions/__fish_shared_key_bindings.fish

でデフォルトのキーバインドがconfigとして記述されているのを発見し、yankなどに倣って

bind $argv \co new_line_with_backslash

の記述を足せばいいだけであった。grepすれば2秒で分かる事案であった。

プルリクを送ってみた

機能が実装できたので、Pull Requestを投げてみることにした。 正直、Shift+Enterで実装できなかったのでなんか微妙だし、機能自体にも賛否両論ありそうなので取り込まれたら良いな、くらいの気持ちで投げてみた。 減るもんじゃないし。

github.com

結果、

There's no need to touch the C++ code here: bind \co 'commandline -i \\\\\n'.

とのコメントをfishの開発メンバーの方からいただきました。 機能がどうこう、というよりconfigをいじるだけで解決する問題だったみたい。

commandlineというコマンドについて補足すると、これはfish独自のビルトインコマンドで、

$ commandline hoge

を実行すると、次のコマンドライン

$ hoge

と入力された状態になる、というものだ。 これはキーバインドと組み合わせることで大変便利になり、今回のようにキーバインドを押すことで自動でバックスラッシュと改行が挿入される、というような処理が簡単にconfigで書けるようになるのである。

少し悲しい気持ちもあるが、やっぱりfishは便利だなあという気持ちになった。

感想

  • 自分で実装して機能を追加してみるのは楽しい
  • デバッガで動作を追うのがつらかった
  • シェルの動きを知ることができて勉強になった
  • シェルはたいへん
  • もう二度とシェル作ってみたいなんて言いません
  • 6-7時間くらいは作業していた気がするが、うち実装をしていたのは1時間に届かないくらい。追加した行数も10行に満たない
  • 少しの変更でもかなりしっかり読み込まないといけないので大規模ソフトウェアは大変だなあという思いを得た
  • 実際に機能を追加する際にはまずはissueを立てて意見を仰ごう(あたりまえ)