Vim のソースのいじり方(:terminal を作るまで)

  • 88
    いいね
  • 2
    コメント

この記事は Vim Advent Calendar 2016、18日目の記事です。

本記事を執筆した2016年12月時点では vim に :terminal コマンドが実装されていませんでしたが、2017年7月に vim 本体に :terminal コマンドが実装されました。以下は参考程度に参照して下さい。

はじめに

ここ数年で、vim にコントリビュートする方が随分と増えた様に思います。リポジトリが GitHub に移ったのは vim-dev にとっても、そして vim-jp にとっても、やはり良い出来事だったと今更ながら感じています。
しかしながらその動きが遅かった事もあり neovim という派生が誕生する事になります。個人的には neovim はエンジニアリングとしては成功しており、悪い事だとは感じてはいません。できれば良い競争相手でい続けられればと思います。
さて neovim の機能の中でキャッチーな機能の一つとして terminal 機能があります。neovim の中でウィンドウの一つが端末として動作するアレです。

terminal feature

この :terminal 機能は現状 UNIX でしか動作しません。Windows の対応は今年1月に issue が作られたものの、現時点では winpty (msys/cygwin から cmd.exe の仮想端末を作るツール) をライブラリとして利用する事で実現できるのではないか、でもどう使ってどう改造すればいいのか分からない、という段階で開発が止まってしまっています。

今回この記事を書くにあたって僕も winpty のソースを眺めてみたのですが、そもそも vim-dev の開発スタイルとして、他のライブラリへの依存を増やす事は不可ではないにしろ推奨しづらい傾向にあります。可能ならば依存せずに実装したいという思いからの暗黙ルールだったりもします。

そこで今回の記事では、この neovim も未だ実装していない Windows 上での :terminal 機能を vim8 に実装していく過程を説明しながら、vim のソースの何をどう触ったら良いのかを紹介していきます。vim に機能を足したい!コントリビュートしたい!という方の参考資料になれば幸いです。

Vim のソースを触る

vim のソースは GitHub リポジトリから clone します。.h のファイルで 50 個、.c のファイルで 116 個あります。なんとなくファイル名からおおよその機能が理解できるかもしれませんが、なにせ数が多いのとマルチプラットフォームがゆえかなりのスパゲティコードになっています。

Vim のソースコードは Vim で編集した方がいい

Vim のソースコードは GNU スタイルのインデントになっています。スペースとタブが入り乱れます。通常のテキストエディタで編集すると大変です。ですが Vim のソースコードには Vim にインデントスタイル等を教えるモードラインという物が付いています。

/* vi:set ts=8 sts=4 sw=4 noet:
 *
 * VIM - Vi IMproved    by Bram Moolenaar
 *
 * Do ":help uganda"  in Vim to read copying and usage conditions.
 * Do ":help credits" in Vim to see a list of people who contributed.
 * See README.txt for an overview of the Vim source code.
 */

Vim でこのモードラインが付いているソースコードを開くと、割と自然にインデントが成される様になっています。

Vim script に関数を足す方法

実はこれそんなに難しくありません。Vim script の言語エンジンは eval.c に記述されています。そして Vim script の中で呼び出す事が出来る関数は evalfunc.c に集められています。新しい関数を足すにはまずインタフェースを決めます。例として先日 pull-request が投げられた trim 関数を例題にあげます。trim 関数であれば、第一引数が文字列で、オプショナルな第二引数がカットセルになるかと思います。まずお決まりの f_trim のプロトタイプを足します。引数は他の関数に倣って下さい。

static void f_trim(typval_T *argvars, typval_T *rettv);

次に関数名をテーブルに加えます。functions という構造体配列にエントリを足します。

    {"toupper",     1, 1, f_toupper},
    {"tr",      3, 3, f_tr},
    {"trim",        1, 2, f_trim},
#ifdef FEAT_FLOAT
    {"trunc",       1, 1, f_trunc},

以前はこの functions は二分探索されるのでソートされていないとダメというルールでしたが、最近は起動時にソートされる様になりました。

trim に関するこのエントリは、1が引数個数の最小値、2が最大値を意味します。実装コードは以下の形になります。

    static void
f_trim(typval_T *argvars, typval_T *rettv)
{

引数の取り回し

argvars は配列の先頭ポインタで、この場合だと2個目まで値が格納されており、省略して呼び出すと2個目は VAR_UNKNOWN という型種別になります。ただし trim の場合は文字列に限られるので以下の様なコードで引数を取得します。

    char_u  *str = get_tv_string_chk(&argvars[0]);
    if (argvars[1].v_type == VAR_STRING)
        mask = get_tv_string_buf_chk(&argvars[1], maskbuf);
    else
        mask = spaces;

Vim のマルチバイト対応は難しい

あとは trim のコードを書いて行くわけですが、Vim には色々な文字列操作関数が既に実装されています。trim 関数であれば、カットセル(デフォルト値はスペースとタブと改行文字)を文字列の先頭から見て行き、カットセルに含まれない文字があればそれが開始位置、最後まで文字を進めながら「最後の空白でない文字」を見つけそれを終了位置とします。その開始位置と終了位置で出来上がる文字列が trim の結果です。
勘のいい方ならなぜ文字列の後方から空白(もしくはカットセル文字)を探さないのか分かるかもしれません。Vim script はユニコードでは無いにしろマルチバイト文字に対応しています。例えば Shift_JIS には後続バイトに 0x5C (\) を含んだ文字が幾つもあるため、例えばカットセルに \ があった場合、後方からカットセル文字を探すと という文字の後続バイトに引っかかってしまうのです。ですので先頭から文字のバイト長を進めながら確認しなければなりません。
ただし一つ注意点があり、Vim はコンパイルオプションによってはマルチバイト文字をサポートしないバイナリもビルドできるのです。マルチバイトを扱うコードと、扱わないコードを #ifdef FEAT_MBYTE で分けつつ実装しなければなりません。

結果、一見いびつなコードが出来上がるのです。

ただ関数を追加する分に関しては、それほど難しくないはずです。Vim script の関数ドキュメントは runtime/doc/eval.txt の中にあるので追加した関数に関するドキュメントと、テストコードを追加すれば pull-request を行いましょう。

terminal を作ろう

さて、上記で Vim script 上に関数を追加する方法が分かりました。ただし :terminal はまた別のソースを触らなければなりません。コマンド系はソースファイル名が ex_ で始まります。今回のケースであれば ex_docmd.c がそれにあたります。ex_cmds.h に :terminal のエントリを cmdnames 構造体配列に追加し、以下の実装コードを足します。

    void
ex_terminal(exarg_T *eap)
{
#if defined(FEAT_GUI_W32)
    mch_open_terminal();
#else
    EMSG(_("E899: terminal feature not implemented for this platform"));
#endif
}

今回は Windows 版の gvim だけの実装になるのでそれ以外はエラーとします。ちなみに E で始まるエラーコード(E001~E899)ですが、そろそろ 900 を超えそうです。1000 を超えたらどうなるんだろうと、ちょっとドキドキしています。

gvim で cmd.exe を埋め込む方法を考える

cmd.exe はエスケープシーケンスを出力しない為、コンソール画面の無いパイプ入出力では色付きで起動しません。色付きで起動させる為には CreateProcess に CREATE_NEW_CONSOLE を指定する必要があります。ただし gvim から :terminal を実行してコンソール画面が表示されてしまっては本末転倒です。STARTUPINFO で非表示にしながら起動します。前述の通り、色付きで cmd.exe を起動する為にはパイプで起動する事が許されません。そこで AttachConsole という API で非表示になっているコンソール画面にアタッチし、その状態で GetStdHandle(STD_OUTPUT_HANDLE) を呼び出す事で回避しています。

winpty では、cmd.exe を直接起動させるのではなくエージェントと呼ばれるコマンドを起動し、必要なハンドルを親プロセスに引き渡した後に cmd.exe を起動させています。今回の実装もそうするべきでしたが、ひとまず実装を優先しました。

terminal を実装しよう

Vim のソースコードでは、各プラットフォーム依存の処理は gui_xxx.c もしくは os_xxx.c の中に実装するルールになっているので今回であれば os_win32.c に処理を書きます(gui_w32.c にしなかったのは今後、vim.exe でも :terminal をサポートする可能性があるかもしれない為です)。

端末機能を実装するためのポイントは2つあります。

  • キーが押されたらコンソール画面にキーイベントを伝搬させる
  • 定期的にコンソール画面をスキャンして Vim 上のバッファに再生する

前者は Vim の編集機能をまかなう edit.c に、後者は Vim8 で追加された job/channel が一定間隔でポーリングを行っている misc2.c に穴あけを行います。いろいろと試行錯誤した結果、各プラットフォームで以下の関数を実装すれば、端末機能が実現できる様にしました。

void mch_open_terminal();
void mch_close_terminal(win_T *wp);
void mch_handle_terminals();
int mch_input_terminal(int *c);
int mch_terminal_attr(win_T *wp, int col, int row);

Vim のソースコードではプラットフォーム依存のインタフェース関数には mch_ を付けるルールになっています。

上記で説明した cmd.exe を起動する処理を mch_open_terminal に実装し、mch_input_terminal では引数で貰った入力文字(へのポインタ)を使って cmd.exe へ流し込みます。

    memset(&ir, 0, sizeof(ir));
    ir.EventType = KEY_EVENT;
    ker = &ir.Event.KeyEvent;
    ker->bKeyDown = TRUE;
    ker->wRepeatCount = 1;
    ker->wVirtualKeyCode = VkKeyScanW(c);
    ker->wVirtualScanCode = MapVirtualKeyW(c, 0);
    ker->uChar.UnicodeChar = c;
    if (c == Ctrl_C)
    {
        GenerateConsoleCtrlEvent(CTRL_C_EVENT, curwin->dwTerminal);
        ker->wVirtualKeyCode = VkKeyScanW(c + 0x60);
        ker->dwControlKeyState |= LEFT_CTRL_PRESSED;
        ker->bKeyDown = FALSE;
        got_int = FALSE;
    }
    else if (c <= 31 && c != ESC)
    {
        ker->uChar.UnicodeChar = c;
        ker->wVirtualKeyCode = VkKeyScanW(c + 0x60);
        if (mod_mask & MOD_MASK_CTRL)
            ker->dwControlKeyState |= LEFT_CTRL_PRESSED;
        if (mod_mask & MOD_MASK_ALT)
            ker->dwControlKeyState |= LEFT_ALT_PRESSED;
        if (mod_mask & MOD_MASK_SHIFT)
            ker->dwControlKeyState |= SHIFT_PRESSED;
    }
    WriteConsoleInputW(curwin->hStdin, &ir, 1, &written);

画面のスキャンは上記で得たハンドルを ReadConsoleOutputCharacter を使って読み取ります。

if (!GetConsoleScreenBufferInfo(curwin->hStdout, &csbi))
    continue;
if (csbi.srWindow.Right != curwin->w_width - 1 ||
        csbi.srWindow.Bottom != curwin->w_height - 1)
{
    csbi.dwSize.X = curwin->w_width;
    csbi.dwSize.Y = curwin->w_height;
    csbi.srWindow.Right = csbi.dwSize.X - 1;
    csbi.srWindow.Bottom = csbi.dwSize.Y - 1;
    csbi.srWindow.Left = csbi.srWindow.Top = 0;
    SetConsoleWindowInfo(curwin->hStdout, TRUE, &csbi.srWindow);
    SetConsoleScreenBufferSize(curwin->hStdout, csbi.dwSize);
}
pbuf = malloc(csbi.dwSize.X + 1);
if (!pbuf)
    continue;
for (i = 0; i < csbi.dwSize.Y; i++)
{
    char_u *str = NULL;
    coord.X = 0;
    coord.Y = i;
    nread = 0;
    if (!ReadConsoleOutputCharacter(curwin->hStdout, (LPSTR)pbuf,
                csbi.dwSize.X, coord, &nread))
        continue;
    if (nread > 0)
        pbuf[nread-1] = '\0';
    else
        pbuf[0] = '\0';
    if (*pbuf && input_conv.vc_type != CONV_NONE)
    {
        int len = nread;
        str = string_convert(&input_conv, pbuf, &len);
    }
    if (i + 1 <= curbuf->b_ml.ml_line_count)
        ml_replace(i + 1, str ? str : pbuf, TRUE);
    else
        ml_append(i, str ? str : pbuf, (colnr_T)0, FALSE);
    if (str) vim_free(str);
}

この2つでおおよそ cmd.exe が起動できる様になりました。

色を付けるぞ

ここで困った問題が発生します。実は Vim は突然「赤で文字を書きたい」と思っても書けないのです。Vim で色を出すためには、ハイライト定義に対して色を割り当てる必要があります。
Vim script をご存じの方であれば、syntax と highlight の関係から「あっ、察し」となるはずです。今回は完全な実装よりもまずは動くものを優先して、自前でハイライトを流し込みました。

do_highlight((char_u *)"Term0 guifg=black guibg=NONE", FALSE, TRUE);
do_highlight((char_u *)"Term1 guifg=blue guibg=NONE", FALSE, TRUE);
do_highlight((char_u *)"Term2 guifg=green guibg=NONE", FALSE, TRUE);
do_highlight((char_u *)"Term3 guifg=cyan guibg=NONE", FALSE, TRUE);
do_highlight((char_u *)"Term4 guifg=red guibg=NONE", FALSE, TRUE);
do_highlight((char_u *)"Term5 guifg=magenta guibg=NONE", FALSE, TRUE);
do_highlight((char_u *)"Term6 guifg=yellow guibg=NONE", FALSE, TRUE);
do_highlight((char_u *)"Term7 guifg=white guibg=NONE", FALSE, TRUE);
do_highlight((char_u *)"Term8 guifg=black guibg=NONE", FALSE, TRUE);
do_highlight((char_u *)"Term9 guifg=blue guibg=NONE", FALSE, TRUE);
do_highlight((char_u *)"Term10 guifg=green guibg=NONE", FALSE, TRUE);
do_highlight((char_u *)"Term11 guifg=cyan guibg=NONE", FALSE, TRUE);
do_highlight((char_u *)"Term12 guifg=red guibg=NONE", FALSE, TRUE);
do_highlight((char_u *)"Term13 guifg=magenta guibg=NONE", FALSE, TRUE);
do_highlight((char_u *)"Term14 guifg=yellow guibg=NONE", FALSE, TRUE);
do_highlight((char_u *)"Term15 guifg=white guibg=NONE", FALSE, TRUE);

文字色しか対応できていませんが、これは今後の課題としておきます。このハイライトの番号は cmd.exe から ReadConsoleOutputAttribute で読み取った色属性の値のままなので、それを Vim のレンダリング処理に引き渡します。

    int
mch_terminal_attr(win_T *wp, int col, int row)
{
    WORD attr;
    COORD coord;
    DWORD        nread;
    char_u buf[16];

    coord.X = col;
    coord.Y = row - 1;
    if (!ReadConsoleOutputAttribute(curwin->hStdout, &attr, 1, coord, &nread))
        return 0;
    attr = attr & FOREGROUND_MASK;
    if (attr > 15) return 0;
    sprintf((char*)buf, "Term%d", attr);
    return HL_ALL + 1 + syn_name2id((char_u*)buf);
}

※) ただし現状、Windows7 でしか色が出せていません。

動かす

以上の実装を行う事で、Windows 版の gvim で以下のデモが行える程になりました。

terminal

まとめ

今回のパッチはまだ pull-request には至っていません。修正内容は僕の fork の terminal ブランチにあります。

https://github.com/mattn/vim/tree/terminal

今後もう少しまともに動くようになったら vim-jp で一度レビュー頂き pull-request を送りたいと思います。

今回は、:terminal を実装する過程を通じて Vim の開発流儀などをご紹介しました。実装を優先したため以下の課題が残りました。

  • 複数の端末を起動できない
  • Windows10 で色が表示できない
  • カーソルがチラつく

課題は残ってしまいましたが、今後 vim の :terminal 機能について何かしらの進捗になっていくのではないかと感じています。
また機能を実装するにあたって vim に穴あけする方法を説明できた事で、今後 vim にコントリビュートしたい人達へのアドバイスになれたなら幸いです。

この投稿は Vim Advent Calendar 201618日目の記事です。