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のデバッグ講座まであるとは… 驚きですね)
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_LEGACY
やCMOD_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/vim
にPRを起票します。
" このコードは正常に動作することが期待されますが、エラーが出ます
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.vim
のTest_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のみなさんありがとうございました
参考文献
- VimのデバグにGDBを使う
- Vimにコントリビューションした過程のログ
- Vim本体のソースコードの読みはじめかた(仮)
- Reading Vim (Vimのソースコードを読んでみよう)
- スパルタンVim 4
- Vim のソースのいじり方(:terminal を作るまで)
- Vim にコントリビュートしたい | SpeakerDeck
Discussion