🍺

Vimのソースの歩き方

2022/12/12に公開

https://github.com/vim/vim

8月ごろ、Vim9 scriptにあった二つの不具合を修正しました。
この記事では、どのような過程を経てバグを発見・特定・修正したのかを紹介します。数ヶ月前の記憶を引っ張り出して書いているため、時系列など事実関係に誤りがあるかもしれません。
これからVimを弄る予定の方にとってちょっとでも情報が増えるといいなーとおもいます。

バグの発見

vim-jetpackというプラグインマネージャーがあります。
vim-plugによく似た簡潔なインターフェースながら、細かく設定をいじらなくても非常に早く動作するとても便利なプラグインです。
(詳細については作者のtaniさんがアドベントカレンダーで解説された記事を参照ください)

これはVim scriptで実装されているのですが、ふとVim9 scriptで実装したらどのくらい動作が速くなるのかと気になったため、移植してみることにしました。

移植自体はうまくいき基本機能がきちんと動作するようになったのですが、移植の過程でVim9 scriptのバグらしきものを発見しました。

バグの内容

見つけたバグは「Vim9 script内でVim scriptのコマンドを実行するlegacyコマンドでexecute(src)関数を呼び出すとsrcがVim9 scriptとして解釈される」というものでした。
このバグは以下のコードで再現できます。

# Vim9 scriptの中で...

lagacy call execute("let g:hoge = 'hugo'")
# => E1126: Cannot use :let in Vim9 script
# => Vim scriptのコードとして解釈されるべきだが、Vim9 scriptのコードとして解釈されるためエラーが出る。

execute(src)関数は渡されたsrcを現在のインタプリタで実行する、Rubyなどでいうところのeval()関数です。
letコマンドはVim scriptでのみ利用できるコマンドのため、execute()関数がVim scriptコマンドとして呼び出されている上記コードでは実行可能なはずですがエラーが出ていることがわかります。

Vim9 scriptの構造

Vim9 scriptの実行器はバイトコードを実行するVM型のスクリプト言語です。
Vim scriptの実装はsrc/eval.c🔗src/evalfunc.c🔗、Vim9 scriptの実装はsrc/vim9*.cにまとまっています。ただし、Vim scriptと共有する組み込み関数はsrc/evalfunc.cに定義されています。

execute()関数の実装を確認してみます。あらかじめリポジトリのトップで$ ctags -Rを実行しておきタグファイルを作っておきましょう。
src/evalfunc.cを開き、executeで検索をかけると1855行目付近に次のような配列が見つかります。
ここには全ての組み込み関数の定義が書き込まれており、関数の呼び出し時はここから定義が二分探索されています。

\\ ...
			ret_number,	    f_executable},
    {"execute",		1, 2, FEARG_1,	    arg12_execute,
			ret_string,	    f_execute},
    {"exepath",		1, 1, FEARG_1,	    arg1_string,
\\ ...

f_execute シンボルの上にカーソルを合わせ、<C-]>で関数の定義までジャンプできます(できない場合はtagsファイルが生成されているか確認してください)。
次のコードがf_executeの定義です。

/*
 * "execute()" function
 */
    static void
f_execute(typval_T *argvars, typval_T *rettv)
{
    if (in_vim9script()
	    && (check_for_string_or_list_arg(argvars, 0) == FAIL
		|| check_for_opt_string_arg(argvars, 1) == FAIL))
	return;

    execute_common(argvars, rettv, 0);
}

大半の処理はexecute_common関数が担っているのはいいとして、先頭にvim9scriptと書いてあることがわかります。
これはVim scriptとVim9 scriptが組み込み関数の実装を共有しているためで、Vim9 scriptから関数が呼ばれた場合、引数の型が正しいかを確かめています。

また、Vim scriptとVim9 scriptは相互に実行できるように変数や関数定義などの環境も共有しています。

原因特定

構造を把握すると自ずと原因がつかめてきます。
関数はどちらの実行器から自分が呼び出されたかを把握していません。この処理のどこかで今動作している実行器の判定がうまくいっていないのでしょう。

実際の処理を追うためにGDBデバッガを利用します。
Macではうまく動かないかもしれないので(一敗)、Linuxで動かしてください。VSCodeのデバッガや、Vimのtermdebugを使うとスムーズに確認ができると思います。
デバッガの利用方法については下記URLの記事を参考にすると良いと思います。(スタディサプリは高校で使ってますが、まさかVimのデバッグ講座まであるとは… 驚きですね)

https://blog.studysapuri.jp/entry/vim-gdb-ujihisa

termdebugは、「右クリックでブレークポイントを打てる」こと・「マウスホバーで変数の中身が読み込める」こと・「変な操作をするとすぐカーソルがどっかに飛んでいく」ことなどを把握しておくとスムーズに利用できると思います。

また、Vim9 script関数は:disassembleコマンドで、内部VM用のバイトコードを確認できます。不具合の原因を調査するのに便利なので活用してみてください。

GDBで動作をチェックしていくと、src/evalfunc.cの4000行付近f_execute()の中にdo_cmdline()関数が見つかります。
do_cmdline()関数はsrc/ex_docmd.cで定義されています。この関数がコマンドを実行する上で重要になっているように思われますが、他に手掛かりがないので一旦置いておくことにしました。

// f_execute()の中身
    if (cmd != NULL)
	do_cmdline_cmd(cmd);
    else
    {
	listitem_T	*item;

	CHECK_LIST_MATERIALIZE(list);
	item = list->lv_first;
	do_cmdline(NULL, get_list_line, (void *)&item,
		      DOCMD_NOWAIT|DOCMD_VERBOSE|DOCMD_REPEAT|DOCMD_KEYTYPED);
	--list->lv_refcount;
    }

ここでexecute()と同じような動作をする:executeコマンドの存在を思い出したので、そちらの実装もチェックしてみることにします。
コマンドの定義はsrc/ex_cmds.hにあります。599行目を見るとex_execute()関数が実体だとわかりました。
関数自体はほどほどに長いですが、do_cmdline()関数周辺が重要だとわかっているので探してみると6798行目に見つかります。

	{
	    int save_sticky_cmdmod_flags = sticky_cmdmod_flags;

	    // "legacy exe cmd" and "vim9cmd exe cmd" applies to "cmd".
	    sticky_cmdmod_flags = cmdmod.cmod_flags
						& (CMOD_LEGACY | CMOD_VIM9CMD);
	    do_cmdline((char_u *)ga.ga_data,
		       eap->getline, eap->cookie, DOCMD_NOWAIT|DOCMD_VERBOSE);
	    sticky_cmdmod_flags = save_sticky_cmdmod_flags;
	}

!?

sticky_cmdmod_flags

CMOD_LEGACYCMOD_VIM9CMDなどの定義には次のように書いてあります。

/*
 * Command modifiers ":vertical", ":browse", ":confirm" and ":hide" set a flag.
 * This needs to be saved for recursive commands, put them in a structure for
 * easy manipulation.
 */
typedef struct
{
    int		cmod_flags;		// CMOD_ flags
#define CMOD_SANDBOX	    0x0001	// ":sandbox"
#define CMOD_SILENT	    0x0002	// ":silent"

どうやら再帰的に呼ばれるコマンドでは、あらかじめ値を保存してからフラグを立て関数を呼び出す必要があるようです。

修正

ex_execute()コマンドと同様にフラグを保存してから書き換えることで不具合を修正できました。

--- a/src/evalfunc.c
+++ b/src/evalfunc.c
@@ -3929,7 +3929,6 @@ execute_common(typval_T *argvars, typval_T *rettv, int arg_off)
     int		save_redir_off = redir_off;
     garray_T	save_ga;
     int		save_msg_col = msg_col;
+    int		save_sticky_cmdmod_flags = sticky_cmdmod_flags;
     int		echo_output = FALSE;
 
     rettv->vval.v_string = NULL;
@@ -3986,9 +3985,6 @@ execute_common(typval_T *argvars, typval_T *rettv, int arg_off)
     if (!echo_output)
 	msg_col = 0;  // prevent leading spaces
 
+    // For "legacy call execute('cmd')" and "vim9cmd execute('cmd')" apply the
+    // command modifiers to "cmd".
+    sticky_cmdmod_flags = cmdmod.cmod_flags & (CMOD_LEGACY | CMOD_VIM9CMD);
     if (cmd != NULL)
 	do_cmdline_cmd(cmd);
     else
@@ -4001,7 +3997,6 @@ execute_common(typval_T *argvars, typval_T *rettv, int arg_off)
 		     DOCMD_NOWAIT|DOCMD_VERBOSE|DOCMD_REPEAT|DOCMD_KEYTYPED);
 	--list->lv_refcount;
     }
+    sticky_cmdmod_flags = save_sticky_cmdmod_flags;
 
     // Need to append a NUL to the result.
     if (ga_grow(&redir_execute_ga, 1) == OK)

修正後のコードで下記再現コードを実行したところ、正常な動作を確認できたためvim/vimPRを起票します

" このコードは正常に動作することが期待されますが、エラーが出ます
vim9script
legacy call execute('let g:str = rand()')
legacy call execute(['let g:list1 = rand()', 'let g:list2 = rand()'])
echo g:str
echo g:list1
echo g:list2
" こちらも、やっぱり落ちます
let g:str = 0
let g:list1 = 0
let g:list2 = 0

vim9cmd execute('g:str = rand()')
vim9cmd execute(['g:list1 = rand()', 'g:list2 = rand()'])

echo g:str
echo g:list1
echo g:list2

PRの本文は以下のとおりです。

issue

Contents of execute function executed by :legacy call execute("let g:hoge = 'hugo'") or :vim9cmd in Vim9 script are processed > by unintended context.

cause

execute_common() does not refer to the CMOD_LEGACY and CMOD_VIM9CMD flag, so the return value of in_vim9script() is > referenced and the command is executed as wrong context

solution

CMOD_LEGACY and CMOD_VIM9CMD are now checked.
same as :execute (https://github.com/vim/vim/blob/master/src/eval.c#L6793)

test code

These scripts are expected to run but return an error

翌日Bram氏(Vimの作者・メンテナー)から「再現コードをテストケースに追加する必要がありそうだ。testdir/test_vim9_cmd.vimTest_cmdmod_execute()関数にあるlegacy executeと同じようなテストじゃないかな」と返信をいただきました。

テストコードを追加する

リポジトリトップで$ make testを使用するとテストを実行できます。最後に通過したテストの総数が出ればOKです。
環境によっては何故か失敗するテストもありますが、今回は気にしません。

テストファイルはsrc/testdirにあります。今回の修正はVim9 scriptに関わるものなので、test_vim9_*.vimの中から関連しそうなものを見つけます。

vim9.vimからimportされた便利なヘルパー関数があるため、それを利用しました。

関数 機能
CheckDefSuccess(lines) Vim9関数として実行できるか
CheckDefCompileSuccess(lines) コンパイルできるか
CheckDefFailure(lines, error, lnum = -3) 関数として失敗するか
CheckScriptSuccess(lines: list<string>) スクリプトとして実行できるか
CheckScriptFailure(lines: list<string>, error: string, lnum = -3) スクリプトとして失敗するか
CheckDefAndScriptSuccess(lines: list<string>) 関数、スクリプトとも実行できるか
CheckDefAndScriptFailure(lines: list<string>, error: any, lnum = -3) 関数スクリプトとも失敗するか

このようなテストを複数作成し、Pushしました。

# vim9cmd execute(cmd) executes code in vim9 script context
lines =<< trim END
  vim9cmd execute("g:vim9executetest = 'bar'")
  call assert_equal('bar', g:vim9executetest)
END
v9.CheckScriptSuccess(lines)
unlet g:vim9executetest

おわりに

最終的にこの変更はVimのバージョンv9.0.0140として取り込まれ、リリースされました
VimのソースコードはCファイルだけでも40万行近くあります。この修正(ともう一つ送ったPR)はそのうちの0.002%にも満たない非常にちいさなものですが、これだけ大規模で歴史のあるソフトウェアに自分の名前を残せたのはかなり嬉しかったです。

受験がひと段落ついたので、また楽しくパソコンしたいなと考えてます。

バグを見つけるキッカケになったvim-jetpackの作者であるtaniさん、アドバイスを下さったVim-jpのみなさんありがとうございました

参考文献

Discussion