fish shellに楽に改行できるキーバインドを追加した
概要
この記事は、東京大学電気電子・電子情報工学科で行われた「大規模ソフトウェアを手探る」実験においてfish shellのソースコードを読み、機能の追加を行った記録である。
fish shellについて
fish(Friendly interactive shell)はUNIXにおけるシェルの一つ。自分で設定ファイルを書いていく必要のあるbashやzshなどと違い、初期設定のままでも多くの便利な機能が使える点が特徴。
やったこと
実験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が動き始め、入出力はそちら側のターミナルでできるようになる。
あるいは、gdbのattach
機能を使って既に起動してある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'
が入力されたことになっていた。
他のキーについても何が取得されていくかを追っていくうちに、どうやらCtrl
やShift
と同時にあるキーが押された、という情報は制御文字あるいはエスケープシーケンスとして、単一または複数の入力が渡されているようなのである。
しかし、残念なことにShift+Enter
には特殊なエスケープシーケンスが割り当てられている様子もなく、調べてみると多くのターミナルの仕様でShift+Enter
とEnter
は区別して送信されない、ということが判明した。
結論としては、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
で実装できなかったのでなんか微妙だし、機能自体にも賛否両論ありそうなので取り込まれたら良いな、くらいの気持ちで投げてみた。
減るもんじゃないし。
結果、
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を立てて意見を仰ごう(あたりまえ)