Vim上でのシェルコマンド実行機能の改造について

Vim上でのシェルコマンド実行機能の改造について

by チーム9 a.k.a. Vaccinated Intelligent Members (2021.10)

はじめに

この文章は,東京大学工学部電気電子工学科・電子情報工学科(通称EEIC)の3年後期実験の選択科目の1つである,大規模ソフトウェアを手探るのレポートとして書かれたものです.

ざっくり言うと,この実験(演習)は10日間に渡って大規模なオープンソース・ソフトウェアを改造し,自分たちが欲しいと思った,もしくは役に立つような機能を追加することを目的としています.今回我々の班はVimの改造に着手しました.

Vimについて 

結構有名なテキストエディタなのでわざわざ説明するまでもないと思いますが,一応VimのHPからの抜粋を載せておきます.

Vim - the ubiquitous text editor Vim is a highly configurable text editor built to make creating and changing any kind of text very efficient. It is included as "vi" with most UNIX systems and with Apple OS X.

Vim is rock stable and is continuously being developed to become even better. Among its features are:

  • persistent, multi-level undo tree
  • extensive plugin system
  • support for hundreds of programming languages and file - formats
  • powerful search and replace
  • integrates with many tools

VimUNIX時代からのエディタとして活躍していたviの改良版(Vi IMproved)であり,Bram Moolenaar氏によって開発されました.最大の特徴はキーボードのコマンド入力によるテキスト編集が可能であり,高い作業効率が期待できる点でしょう.似たようなエディタにはEmacsが挙げられ,CUIテキストエディタとしてVimと双璧をなす存在です.

ネタですが,VimユーザーとEmacsユーザーは仲が悪いようで,~/.bashrcalias emacs="vim"あるいはalias vim="emacs"はあまりにも有名.最近発見したのですが,英語版のGoogleなどで"Vi"を検索すると,"Did you mean: Emacs"と表示され,逆に"Emacs"を検索すると"Did you mean: Vi"と候補が出る仕様になったりしています(最終確認日: 2021/10/30).Googleにもネタにされるという

2021年現在ではエディタならVSCode一択だろという風潮ですが,一部の熱狂的なファンの存在もありVimはまだ根強い人気があるようです.

ビルドからデバッグまで 

まずはVimの公式リポジトリからgit cloneします.

ディレクトリ構成

以下がVimディレクトリ構造(一部)です.

クリックすると展開されます

    .
    ├── CONTRIBUTING.md
    ├── Filelist
    ├── LICENSE
    ├── Makefile
    ├── README.md
    ├── README.txt
    ├── README_VIM9.md
    ├── READMEdir
    ├── ci
    ├── configure
    ├── nsis
    ├── pixmaps
    ├── runtime
    │    └── doc
    │        ├── helphelp.txt
    │        ├── help.txt
    │        ├── Makefile
    │        ├── tags
    │        └── etc ...
    ├── src
    │    ├── ex_cmds.c
    │    ├── ex_docmd.c
    │    ├── Makefile
    │    ├── os_unix.c
    │    └── etc ...
    └── etc ...


このうち,重要になったのはruntimeディレクトリとsrcディレクトリです. runtimeは名前の通り実行時に利用されるファイルが格納されており,OSによる差異にかかわらず利用されます.このディレクトリには(お馴染みの?)vimtutorやhelpコマンドを叩いたときに出てくるコマンドの仕様が記述されているテキストファイルなどがあります.ビルドするとこのディレクトリは複製され,実行時には複製されたディレクトリが参照されます.

srcは名前の通りVimの本体となるファイル群です.それぞれのファイルはオブジェクト指向を意識して記述されています.シェルコマンドを叩くと,最終的にはos_unix.cfork()が実行され,子プロセスにてexecvp()で実行されます.ex_cmds.c, ex_do_cmds.cにはVimのコマンドを叩いた時の処理が記述されています.

デバッグできない問題

デバッグシンボル埋め込み・最適化オプションオフの状態でビルドします.

$ CFLAGS="-g -O0" ./configure --prefix=/path/to/install_dir

gdbを起動すると,以下のメッセージが出ました.

(gdb) Reading symbols from …/…/vim_install/bin/vim…(no debugging symbols found)…done.

デバッグオプションが見つからないようです.Google検索したりTAの方に質問したりしたところ,src/Makefileの1行

#STRIP = /bin/true

コメントアウトを外してビルドすれば良いと判明しました. こうすると無事にgdbで読み込めるようになりました!
...しかし残念なことに,私達が頑張って探した情報はruntime/doc/debug.txtにすべて書いてあったのです.
開発者用のヘルプは事前によく読むべきでした.

GVimを使ってみようとする

先人の記録によると,コマンドラインgdbを走らせるとある時点で画面がVimに占有され,gdbを操作できなくなるということでした. 対応として我々はGVm(GUI版のVim)をデバッグすることにしました.これならターミナルとは別のウィンドウでVimが起動するからです. 「これならいける」と期待していたのですが,試行錯誤の末私達はGVimを使ったデバッグを断念しました.

試行錯誤たち(クリックすると展開されます)

  • ./vim -gのオプションでGVimを起動すると「GUI版は使えません」とのエラーメッセージがでたので,エラーメッセージで検索をして以下の作業を実行しました.
  • こちらに書いてあるライブラリをインストール
  • src/MakefileCONF_OPT_GUI = --enable-gui=gtk2コメントアウトを取る(つまり有効にする)
  • ./configureのオプションに--enable-gui=gtk2を付ける.

こうしてGVimが使えるようになったので,gdbで読み込みます.

(gdb) r -g -f

GVimの親プロセスは子プロセスを作ってすぐに終了してしまうので,子プロセスを追跡するオプション-fを付けています. 意気軒昂とnextを連打していると, GUIウィンドウが開かれた途端gdbが固まってしまいました.

結論構成

gdbは途中で固まるしGVimも使えないしで途方に暮れていましたが,まだ手段はありました.Termdebugです. TermdebugはVim内でgdbを起動できる拡張機能(詳細はこちら)で,この機能を使うとデバッグをうまく進めることができました. gdbとは別のウィンドウがVimのために用意されるので,Vimのウィンドウが開いてもgdbを操作できるのです.
他にもVimを立ち上げた後,gdbをアタッチすることで,デバッグができます. これを利用してlaunch.json

{
     "name": "(gdb) attach",
     "type": "cppdbg",
     "request": "attach",
     "processId":"${command:pickProcess}",
     "program": "path/to/vim"
}

を書き足すとVSCodeでもデバッグが進められるようになります.

長い長い道のりではありましたが,こうして無事デバッグを行うことができるようになりました.

やったこと(追加機能の概要)

まずは私たちが追加した機能の概要を説明します.

Vimには内部にビルトインコマンド(:<built-in-cmd>)が多数備わっていますが,同時に外部のシェルコマンドを実行できる(:!<shell-cmd>)という強力な機能が付いています.しかしながら,シェルコマンドを実行したあと,その結果表示にVimは一旦編集画面をコマンドラインに切り替える仕様になっています.

そこで私たちは今回の実験で,Vimのシェルコマンド実行時にコマンドラインを表示する代わりに,編集画面のウィンドウ分割機能を流用してシェルコマンドを下半分の画面に表示するという機能改造を行うことにしました.

この改造に必要な工程は,

  • シェルコマンド(下図①)の実行結果をファイル(例えばresult.txt)に一時的にリダイレクトする(下図②)
  • Vimコマンドライン画面への遷移を抑制する
  • 保存されたファイルをVimの内部コマンド(下図③)で実行できるウィンドウ分割機能で表示する(下図④)

の三段階になると考えられます.ここで,Vimの内部コマンド:helpは,ヘルプファイルを新しいウィンドウで開くのと同時にウィンドウがread-onlyになり便利そうなので,これを流用する方針で進めました.

分かりやすいように,実現したい機能の一連の流れを図示しました.

なお,Vim:help <keyword>コマンドは:h <keyword>と略せるので,以降は後者を用いて説明することにします.

タグとMakefile 

Vim:h <keyword>機能によって,編集途中で新しいウインドウを開いて,<keyword>に対応したヘルプ情報を表示することができます.これは我々が追加したい機能に是非使いたいと思いました.つまり,:h external_commandのようなコマンドを入力すると,Vimがシェルコマンド結果を格納しているファイルの中身をヘルプとして表示してくれるようにしたいということです.

まずVimがどうやってヘルプを表示しているかと言うと,

  • runtime/docディレクトリ内にはヘルプの情報を保有している.txtファイルが多数ある
  • その中から<keyword>に対応するファイル名およびそのファイル中の位置(特定の章・節)に関する情報をrumtime/doc/tagsというファイルから読み取る
  • これを基にファイルにアクセス・読み出しを行う
  • 最終的にVimの編集画面において新しいウィンドウを立ち上げて読み出した内容を表示する

という作業を裏で行っています.また,上記のファイルたちはVimのソース内に位置していますが,ビルド中においてruntime/doc/ディレクトリは丸ごとコピーされるので,インストールされたVimディレクトリ内にも情報が残るようになっています.

したがって,:hコマンドの動作を拡張するには,runtime/doc/内にて以下の3点を行う必要があります.

[1] 新たにexternal_command.txtなるファイルを作成し,ファイルの中には*external_command!*というヘルプ参照時に章・節を指定するタグを用意します.つまりファイルには以下の内容が入るようにします.

*external_command!*

--- <表示したい内容> ---

[2] tagsの任意の箇所において,以下の一行を付け足します.ここで1つ目が:hコマンドで指定される<keyword>,2つ目が参照先のヘルプファイル名,そして/の後ろにある3つ目がファイル中にある対応する章・節を表すタグ(これは1で追加しました),という構成になっています.

external_command    external_command.txt    /*external_command!*

[3] 1と2だけだと,ビルド時にVimがヘルプファイルが全て揃っているかを精査する工程に弾かれてしまい,上手くビルドができないので,runtime/doc/Makefileというヘルプファイルを一括管理しているMakefileの中身を編集する必要があります.このMakefileにはDOCSという変数にヘルプファイルのリストが格納されているので,以下のようにそのリストの末尾にexternal_command.txtを登録しておきます.

DOCS = \
    arabic.txt \
    autocmd.txt \
    change.txt \
    ...
    help.txt \
    helphelp.txt \
    ...
    workshop.txt \
    external_command.txt

以上を行った後に再度ビルドしてVimを起動すると,:h external_commandによってexternal_command.txtの中身がヘルプウィンドウに表示されることになります.

dup2でリダイレクト 

(方針1) リダイレクトをつけたす

helpコマンドを利用するにあたって,シェルコマンドの出力をファイルにリダイレクトする必要があります.そこでまず,コマンドに

> (filename) 2>&1

を付け足すことを考えました. strcat()で結合させて実行したところエラーが... どうも原因はバッファの大きさが不足していることらしいので,方針を切り替えることにしました.

(方針2)コマンドを付け替える

バッファの容量が不足しているなら,十分な長さのバッファにコマンドを書き,張り替えれば大丈夫そうと考えました.張り替えたら前のコマンドは必要ないはずなのでfree()します. すると,コマンドが実行されファイルにリダイレクトされました!!やったー!! しかし,喜びもつかの間,もう一度同じコマンドを叩くと...

Vim: Caught deadly signal

ヤバそうなエラーが出るとともにVimが強制終了されてしまいます. デバッグで原因を追ってみると,付け替える前のコマンドがどこかでfreeされてしまっていることがわかりました. ここで前のコマンドをfree()しないという選択もありそうですが,ことはそう簡単ではありません. 前のコマンドがfree()されたということは,新しいコマンドがfree()されることがないということがわかるからです.書き換える場所を再度探す必要がありますが,一苦労です.

また,実はこの方法では!!というコマンドに対応できないことが判明してしまいました.!!は直前のシェルコマンドを再度利用するというものですが,直前のコマンドは後ろにリダイレクトが付いているので,さらにその後ろにリダイレクトを付けられてしまいエラーになります.解決策が見つからないまま,時間ばかりが過ぎていきました.

(方針3) dup2()で出力先をファイルに変更

途中経過を発表する機会があり,ここで詰まっていることを伝えると,TAの方がdup2()という関数でコマンドを書き換えることなく,リダイレクトできることを教えてくださいました!! os_unix.cの子プロセスで

char_u help_bang_filename[100];
char_u *vim_version_name = VIM_VERSION_NODOT;
sprintf(help_bang_filename,"%s/%s/doc/external_command.txt",default_vim_dir,vim_version_name);

int fd_help_bang = open(help_bang_filename, O_RDWR | O_CREAT | O_TRUNC, 0600);

dup2(fd_help_bang, 1);
dup2(fd_help_bang, 2);
close(fd_help_bang);

//ファイルにtagを記述するために子プロセスを作る.writeで書くと変な文字が入る
pid_t pid2 = fork();
if (pid2==0) 
{
    char* child_argv[] = {"/bin/bash", "-c", "echo *external_command!*", 0};
    execvp(child_argv[0],child_argv);
    _exit(EXEC_FAILED);
}
else
{
    int ws_child;
    pid_t wait_child = waitpid(pid2,&ws_child,0);
}

というコードを入れることでシェルコマンドのリダイレクトに成功しました. この方法であれば!!コマンドの利用に影響は出ません. ここで利用されているdupは,ファイルディスクリプタを複製するシステムコールです.標準出力と標準エラー出力をファイルに変更しました.

:helpを内部で呼び出し(サイレントモード) 

:help内部呼び出しの実装

シェルコマンドのリダイレクトによって,コマンドの出力がexternal_command.txtに書き込まれるようになりました. 次にTagとMakefileの節で説明したように,:h external_commandを内部的に呼び出してコマンドの出力を新しいウィンドウに表示させます. まずは既存の:helpを実行し,gdbで内部の動作を追跡しました.すると

(cmdnames[ea.cmdidx].cmd_func)(&ea);

という行でex_help()という関数が呼ばれており,これが:helpの本体であることが判明しました.したがって,:helpが呼ばれる時のea.cmdidxおよびeaを解明すれば,プログラムの好きなところで:helpを実行できるという見通しが立ちました. この方針に従って,!から始まるコマンドを実行している関数ex_bang()の中に:helpを実行するコードを追加しました.引数の構造体のフィールドを設定する部分が長々と続くので,以下に折りたたみで表示します.

クリックすると展開されます

char_u * tmp_cmd = "h external_cmmand";
char_u * tmp_arg = tmp_cmd + 2;
exarg_T tmp_ea = { 
    .arg = tmp_arg,
    .nextcmd = NULL,
    .cmd = tmp_cmd,
    .cmdlinep = &tmp_cmd,
    .cmdline_tofree = NULL,
    .cmdidx = CMD_help,
    .argt = 2054,
    .skip = 0,
    .forceit = 0,
    .addr_count = 0,
    .line1 = 1, 
    .line2 = 1,
    .addr_type = ADDR_NONE,
    .flags = 0,
    .do_ecmd_cmd = NULL,
    .do_ecmd_lnum = 0,
    .append = 0,
    .usefilter = 0,
    .amount = 0,
    .regname = 0,
    .force_bin = 0,
    .read_edit = 0,
    .force_ff = 0,
    .force_ff = 0,
    .force_enc = 0,
    .bad_char = 0,
    .useridx = 0,
    .errmsg = NULL,
    .getline = NULL,
    .cookie = 0,
    .cstack = NULL,
};
(cmdnames[CMD_help].cmd_func)(&tmp_ea);

こうしてシェルコマンド実行の後に:h external_commandが内部的に実行されるようになりました.

 

ENTER入力待ちをスキップさせる

Vimでシェルコマンドを実行すると,画面がVimからターミナルに遷移してPress ENTER or type command to continueという文が表示され,VimはユーザのENTER入力を待ちます.例えば:!lsというコマンドを実行すると

Desktoop Downloads Documents Music 
...etc...

Press ENTER or type command to continue

というようになります. このENTER入力待ちを残しておくとVimにコマンドを渡してから結果が表示されるまでにENTER入力が求められることになり,これは大変鬱陶しいです.よってENTER入力を待たずにコマンドの結果が表示されるようにしようと考えました.

gdbで動作を追跡して名前がいかにも怪しい関数wait_return()を調べると,有用そうなコメントが見つかりました.

// If using ":silent cmd", don't wait for a return.  Also don't set
// need_wait_return to do it later.    

このコメントの周辺を見たところ,msg_silentという変数がTRUEのときサイレントモードに入っているという判定になり,ENTER入力を待たずにwait_return()が終了することが推測できました.この推測に基づいて行った実装を以下に示します.

    void
wait_return(int redraw)
{
    int        c;
    int        oldState;
    int        tmpState;
    int        had_got_int;
    int        save_reg_recording;
    FILE   *save_scriptout;
    int prev_msg_silent = msg_silent;

    if (redraw == TRUE)
    must_redraw = CLEAR;

    // If using ":silent cmd", don't wait for a return.  Also don't set
    // need_wait_return to do it later.    
#if MY_BANG
    msg_silent = TRUE;
#endif
    
        if (msg_silent != 0) {
#if MY_BANG
        msg_silent = prev_msg_silent;
#endif
        return;
        }
/* --omit --*/

msg_silentを一時的にTRUEにするという乱暴な実装になってしまいました.そのため副作用として,Vim独自lsのみ結果が表示されなくなるというバグが発生しました.シェルコマンドが実行されるときのみ立つフラグを設定し,そのフラグを使ってmsg_silentTRUEにするかどうかを判断するといった実装にすればうまく行ったかもしれませんが,試す時間がありませんでした.反省すべき点だと思います.

改良(自動更新&カーソル移動)

以上で実現したい機能は最低限実装されましたが,編集の観点からして使い勝手が悪いと感じた点が幾つかあります.

  • 一度シェルコマンドを実行し,その後異なるシェルコマンドを実行しても表示される実行結果が変わらず,ヘルプウィンドウを一旦閉じて再度開くことで更新する必要があります.
  • 外部シェルコマンドを入力すると,:hの仕様上カーソルがヘルプウィンドウに飛んでしまい,元々編集していたウィンドウに再度カーソルを移し直す必要があります.

どちらも編集効率を著しく低下させるイライラポイントなので,なんとかしてこれらを改善しなければなりません.

1つ目は,external_command.txtの中身は書き換えられているにも関わらず,:hコマンドが更新を反映してないことに起因します. :hコマンドは,一度ヘルプウィンドウが開かれればそれが消えない限り同じウィンドウを再利用します.普通のヘルプファイルであれば中身が書き換わることはないので,何度も同じヘルプファイルにアクセスしても,表示する内容は一度だけ読み込めば良いことになるという背景からこのような仕様になったのでしょう.

しかし私たちとってこの挙動は都合が悪いです.そこでVimの内部コマンドである:edit(または:e)コマンドを利用します.これはファイルを開き編集するためのコマンドですが,編集中にこれを行うことでファイルを開き直すことができるのです.そのため,:hコマンドでヘルプウィンドウにカーソルが移った後,内部で:eを呼び出すことでヘルプウィンドウを自動更新することが出来ます.
これで1つ目のイライラポイントは解決できました.

2つ目もまた:hの仕様に起因する問題です. 普通の場合,ヘルプファイルを開けばその内容を閲覧したいので,カーソルがヘルプウィンドウにあればVimの操作でウィンドウをスクロールすることが可能で,便利です.これはヘルプファイルの内容が長く,ウィンドウに収まらないことが多いからです.
しかし,シェルコマンドの実行結果は(多くの場合)一画面に収まるので,わざわざスクロールする必要がありません.それよりも実行結果を見て編集をすぐに再開したいはずです.そのため,1の:eによってヘルプウィンドウを自動更新した後は,Vimの内部コマンドである:wincmd wによって,ウィンドウ間でカーソル移動を行い,元の編集ウィンドウにカーソルを戻します.
これで2つ目も解決できました.

以上を踏まえて,ex_docmd.cex_bang()関数に以下の変更を行います.

    static void 
ex_bang(exarg_T *eap)
{
    do_bang(eap->addr_count, eap, eap->forceit, TRUE, TRUE);
#ifdef MY_BANG
    /* help */
    char_u * tmp_cmd_1 = "h external_command";
    char_u * tmp_arg_1 = tmp_cmd_1 + 2; 
    exarg_T tmp_ea_1 = {
        .arg = tmp_arg_1,
        .nextcmd = NULL,
        .cmd = tmp_cmd_1,
        .cmdlinep = &tmp_cmd_1,
        .cmdline_tofree = NULL,
        .cmdidx = CMD_help,
        .argt = 2054,
        
        /* omit */
    };
    (cmdnames[CMD_help].cmd_func)(&tmp_ea_1);

     /* refresh (edit) */
    char_u * tmp_cmd_2 = "e";
    char_u * tmp_arg_2 = tmp_cmd_2 + 1;
    exarg_T tmp_ea_2 = {
        .arg = tmp_arg_2,
        .nextcmd = NULL,
        .cmd = tmp_cmd_2,
        .cmdlinep = &tmp_cmd_2,
        .cmdline_tofree = NULL,
        .cmdidx = CMD_edit,
        .argt = 147742,
        
        /* same as help */
    };
    (cmdnames[CMD_edit].cmd_func)(&tmp_ea_2);

    /* move between windows */
    char_u * tmp_cmd_3 = "wincmd w";
    char_u * tmp_arg_3 = tmp_cmd_3 + 7;
    exarg_T tmp_ea_3 = {
        .arg = tmp_arg_3,
        .nextcmd = NULL,
        .cmd = tmp_cmd_3,
        .cmdlinep = &tmp_cmd_3,
        .cmdline_tofree = NULL,
        .cmdidx = ADDR_WINDOWS,
        .argt = 17301653,
        
        /* same as help */
    };
    (cmdnames[CMD_wincmd].cmd_func)(&tmp_ea_3);
#endif
}

これで実行結果の自動更新およびカーソルの自動移動を実現できます.些細な変更ですが編集効率がぐっと上がりました.

あとがき or おわりに or 感想

僕はVSCoderでした.これを機にVimmerに転身しようと考えていましたが,そんなに甘い話ではありませんでした.なぜかというと,ソースコードからVimの思想を読み取れるほどプログラミングの能力がなかったからです.しかし複雑怪奇なプログラムを読み解いていく作業には,未開の地を探索していくような高揚感もありました.(手塚)

Vimに習熟していなかったので,変更中に自分の知らない別のコマンドに影響しないか気を使いました.講義で扱ったfork()など,システムコールの知識が有効に利用できたのでいい経験でした.(柘植)

Vimはかれこれ1年以上使ってきましたが,基本的にはvimscriptなどを使っていて,ソースを触って改造したのはこれが初めてでした.C言語で書かれているのは知ってましたが,まさかソースコードがこうも複雑とは思えず,最初は打ちのめされました.しかし,10日間にわたってデバッグ・編集を行った末,実現したい機能を実装できたときは達成感がありました.より一層Vimに詳しくなった(と感じている)ので良い経験になりました.
あと,Vimを使ってVimを書くという,面白い体験でした.(任)

来年以降Vimを手探るかもしれない後輩たちへ

上述した通り,Vimの改造はかなりハードな作業になると思います(敢えてVimを扱う方には無用の心配1かもしれませんが...).
しかしながら,苦労した分だけ成功の喜びも大きいものです.確実にVimの仕様・コマンドやC言語について詳しくなれますし,何よりVimへの愛が深まることでしょう.ですからもし貴方がVimに興味があるのなら,ぜひハックにチャレンジしてほしいのです.

このレポートが貴方のVimを手探る旅をより良いものにすることを祈念しつつ,筆を擱きたいと思います.2

参考文献

参考資料:


  1. Vimユーザーはプログラミングが得意な人が多い傾向にあると筆者は感じます.

  2. この意味で「筆を『置く』」と書くのは誤り(参考