Vim を debug する

ぺりー
23 min readDec 20, 2023

本記事はVim Advent Calendar 2023 20日目とVim駅伝 2023–12–20に寄稿しました。

こんにちは!ぺりーです。

読んでくださっている Vimmer の方々は、毎朝 Vim の HEAD を build して新鮮な(?) Vim を使っていると思います。

そんなみなさんは Vim を debug したことはありますか?

Vim で debug することはあっても、Vim を debug する機会はそう多くはないですよね(少なくとも私の場合はそうでした)

先日、ビルトイン関数 bufnr([{buf}[, {create}]])の動作に疑問を抱き、 Vim を debug して検証してみたので、手順についてまとめてみました。

2023年12月13日に開催されたゴリラ.vimで発表した内容についてまとめた記事になっています。

1. ビルトイン関数 bufnr() の動作

本章に入る前に私が検証したビルトイン関数 bufnr([{buf} [, {create}]]) について少し説明します

help によると、bufnr() はバッファ番号を返すビルトイン関数で、引数 {create} が与えられた場合を除いて、バッファが存在しない場合は -1 を返します

しかし、パスを引数に指定して Vim を起動した場合に、bufnr() に存在しないはずのバッファ名(例えば [update plugins])を渡して実行すると、-1 ではなく、バッファ番号が返ってくることがわかりました

https://youtu.be/X2vQs1n0hHQ

気づいた経緯

私の.vimrc では、プラグインの更新をする際に [update plugins] という名前で作成したバッファに更新差分を分割して表示するようにしていました

def! g:UpdatePackPlugins(): void
topleft split
edit `='[update plugins]'`
setlocal buftype=nofile

しかし、上記コードでは複数回 g:UpdatePackPlugins() を呼ぶと同じバッファが分割して開かれてしまいます

重複してバッファを開くのを防ぐため、同名のバッファが存在する場合はバッファを削除して再作成を行うように修正したのが下記コードです

def! g:UpdatePackPlugins(): void
var prevbuf = bufnr('[update plugins]')
if prevbuf != -1
# Needs `:` before Ex command with range.
execute ':' .. prevbuf .. 'bwipeout!'
endif
topleft split
edit `='[update plugins]'`
setlocal buftype=nofile

しかし、パスを引数に指定して Vim を起動後に :call UpdatePackPlugins() を実行すると、アクティブバッファが新規バッファに上書きされてしまうことに気づきました

ファイル ./test を指定してVimを起動後、 :call UpdatePackPlugins() を実行すると、アクティブバッファが ./test ではなく [No Name] つまり新規作成された無題のバッファに変更されています

調べてみると bufnr() 関数の返り値が期待と反しており、検証することになったというわけです。

こちらの検証結果が早く知りたいという方は 3. bufnr()の動作検証結果 を先にお読みください

さて、前置きが長くなってしまいましたが、 bufnr() を例にとって Vim を debug する手順を説明していきます

2. Vim を debug する手順

2.1. 再現させる最小構成を作成する

まずは発生原因を切り分けるために問題を再現させる最小構成を作成します
今回はパスを指定して Vim を起動後、 bufnr() を呼び出せば良いので下記のようになります

vim --clean --cmd "echo bufnr('[update plugins]')" -c quitall -- test

--clean をつけることで plugin, viminfo を読み込まないため、plugin, viminfo に問題がある可能性を排除することができます

また、 --cmd をつけて実行することで defaults.vim など初期設定の問題でもないことがわかります

以上で実行結果が出力されるため quitall して Vim を終了しています

今回はワンライナーで済みましたが、最小構成の設定ファイルを用意しても良いです。

2.2. 再現しないバージョンを探す

この手順は飛ばすこともできますが、再現しないバージョンが見つかれば、バージョン間のコード差分を比較できるため大きなヒントになります

Vim のバージョンは tag で管理されているため、checkout してビルドすることで再現しないバージョンを探すことができます

thinca さんの docker image を使用するとバージョンを探す手間が省け、ビルドも不要なので、とても便利でおすすめです

docker run --rm -it -v $HOME/.vimrc:/root/.vimrc thinca/vim:v8.2.5172 -u /root/.vimrc

様々なバージョンを試す場合は 2.1 で作成した最小構成をシェルスクリプトなどに起こして検証しやすくするのが良いでしょう

docker run --rm -it -v .vimrc:/.vimrc thinca/vim:v8.2.5172 -u /.vimrc

2.1 で作成した最小構成は bufnr() の返り値を標準出力に出している点、 Press ENTER or type command to continue が表示される点から、複数バージョンでの連続した検証には不向きです

そこで例えば 2.1 で作成した最小構成を以下のように変更します

vim --cmd "let nr=bufnr('[update plugins]')+99" -c "execute ':' .. nr .. 'cquit'" -- test

cquit を使用することで exit code を設定することができ、検証用のシェルスクリプトが書きやすく、複数のバージョンで検証しやすくなります

上記の例では cquit のデフォルトの返り値が1で、 bufnr() の返り値も 1 であることから、検証結果をわかりやすくするために bufnr() の結果を演算しています

この場合は98以外の exit code だった場合は再現しないバージョンであるといえます

今回は再現しないバージョンが存在しなかったため、意図した動作である可能性が高く、初期から存在するバグである可能性は限りなく低くなりました

2.3. Vim のコードを読む

実装都合のコメントやバグが潜んでそうな箇所を探します

関数であれば vim/src/evalfunc.c から探すと対応する Vim 内の関数を見つけることができます

    {"bufnr",  0, 2, FEARG_1,     arg2_buffer_bool,
ret_number, f_bufnr},

bufnr()vim/src/evalfunc.c:1789 から f_bufnr として定義されていることがわかり、grep してみると vim/src/evalbuffer.c:473 に記述されていることがわかりました。

/*
* "bufnr(expr)" function
*/
void
f_bufnr(typval_T *argvars, typval_T *rettv)
{

再現しないバージョンがある場合はコミット履歴の差分を比較することができます

2.4. debug_info, not stripped で build する

さて、コードを見てもわからない際はデバッガを使用します
GDB を使用しますが、debug build の Vim のバイナリを用意する必要があります

Localと Docker それぞれで検証環境を構築する場合を紹介します

私の Mac では依存関係のエラーで Vim を debug build できなかったことと、GDB を使う際に署名の証明書を用意するのが手間であることから Docker で検証環境を構築しました

2.4.1. Local 版

まずはお使いの Vim のバイナリが debug build かどうか確認します
といっても環境変数もしくは Makefile を書き換えて build する必要があるため、debug build ではない Vim を使用していることがほとんどだと思います

$ file `which vim` | grep stripped
/usr/local/bin/vim: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, BuildID[sha1]=eab29ac318d55650bd264ddc83c0f05bfeacf894, for GNU/Linux 3.7.0, stripped

末尾に strippedと表示される場合は debug build ではないため、GDB を使用してデバッグすることはほぼ不可能です
* debug buildでないとブレークポイントを張れない

ここで with debug_info, not stripped と表示されている場合は 2.5. GDB で debug するまで進んでください

2.4.1.1. Makefile を編集する

vim/src/Makefile を下記のように編集してください

diff --git a/src/Makefile b/src/Makefile
index c8a84065f..4c24fca09 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -587,7 +587,7 @@ CClink = $(CC)
# When using -g with some older versions of Linux you might get a
# statically linked executable.
# When not defined, configure will try to use -O2 for gcc and -O for others.
-#CFLAGS = -g
+CFLAGS = -g
#CFLAGS = -O

# Optimization limits - depends on the compiler. Automatic check in configure
@@ -1105,7 +1105,7 @@ INSTALL_DATA_R = cp -r

### Program to run on installed binary. Use the second one to disable strip.
#STRIP = strip
-#STRIP = /bin/true
+STRIP = /bin/true

### Permissions for binaries {{{1
BINMOD = 755

sedを使って書き換えてみます

sed -i -e "s?^#\(CFLAGS = -g$\)?\1?g" ./vim/src/Makefile
sed -i -e "s?^#\(STRIP = /bin/true$\)?\1?g" ./vim/src/Makefile

configureに環境変数を渡してもうまく行きました

# M1 Mac で build する時の環境変数
$ cd ./vim && ./configure CFLAGS="-g -I$(brew --prefix)/include" LDFLAGS="-L$(brew --prefix)/lib" STRIP="/bin/true" && cd -

2.4.1.2. buildする

cd ./vim && make distclean && make && cd -

buildしたあとは with debug_info, not stripped が付与されているかを確認します

$ file ./vim/src/vim | grep "not stripped"
./vim/src/vim: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, BuildID[sha1]=cca774c5a1c1a1bed1030fd3c27ddcebfefc948a, for GNU/Linux 3.7.0, with debug_info, not stripped

Mac は file コマンドで出力されなかったため、バージョン情報から -g オプションを使ってコンパイルされていることを確認します

$ file ./vim/src/vim
./vim/src/vim: Mach-O 64-bit executable arm64

$ ./vim/src/vim --version | grep Compilation
Compilation: gcc -c -I. -Iproto -DHAVE_CONFIG_H -DMACOS_X -DMACOS_X_DARWIN
-g -I/opt/homebrew/include -I/opt/homebrew/Cellar/libsodium/1.0.19/include -D_REENTRANT -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=1

2.4.2. Docker 版

Dockerfile を使用します

実行してみると./vim/src/vim が debug build になっていることが確認できました

$ docker build . -t ubuntu-vim:latest --no-cache

$ docker run --name ubuntu-vim -it --rm ubuntu-vim

root@ee86cab732b3:/# file `which vim` | grep "stripped"
/usr/local/bin/vim: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, BuildID[sha1]=eab29ac318d55650bd264ddc83c0f05bfeacf894, for GNU/Linux 3.7.0, stripped

root@ee86cab732b3:/# file ./vim/src/vim | grep "not stripped"
./vim/src/vim: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, BuildID[sha1]=cca774c5a1c1a1bed1030fd3c27ddcebfefc948a, for GNU/Linux 3.7.0, with debug_info, not stripped

2.5. GDB で debug する

いよいよ GDB で debug する準備が整いました

gdb コマンドを使用する方法はもちろん、Vim8 で導入された Terminal モードを使用して Vim の中で Vim を debug できる termdebug の使い方を紹介します

どちらも GDB を使用しており、アプローチが違うだけなのでどちらでも好みの方をお使いください

2.5.1. gdb

gdb コマンドを使用する場合は Vim を別プロセスで起動させ、アタッチする必要があります

gdb実行後のstartコマンドでオプションを渡して起動することもできますが、別プロセスの方が何かと取り回しがしやすいです

2.5.1.1. gdb を起動させるコンテナを起動する

まずは gdb を起動させるコンテナを立ち上げます

docker run --cap-add=SYS_PTRACE \
--security-opt="seccomp=unconfined" --name ubuntu-vim -it --rm ubuntu-vim

以下二つのオプションが必要です

2.5.1.2. debug build した Vim を起動させる

次に別タブで debug build した Vim を起動させます
debug build した Vim は /usr/local/bin/vim ではなく、 ./vim/src/vim であることに注意してください

* bufnr() の検証のため、Vim 起動時にファイルパスを渡しています

docker exec -i -t ubuntu-vim bash -c "./vim/src/vim --clean -- test"

2.5.1.3. GDB で 2.5.1.2 のプロセスにアタッチする

2.5.1.1 で起動した GDB のコンテナで 2.5.1.2 で起動した PID を調べてアタッチします

root@ee86cab732b3:/# gdb -p `pgrep -o vim` -q --tui

ここからは GDB の使い方の説明になってしまうため、詳細な説明は避けますが、ブレークポイントを貼ってから任意の Vim の操作を行ってください

https://youtu.be/4GZtXQUiYGM

2.5.2. termdebug

termdebug は Vim8 で導入された Terminal Mode を使用して Vim 内で ( Vim の)デバッグを行うことができる標準のプラグインです

Vim を起動後に下記コマンドを実行します
:Termdebug の引数には debug build した Vim の実行ファイルを指定することに注意してください

:packadd termdebug
:Termdebug ./vim/src/vim
:TermdebugCommand ./vim/src/vim option...

3つのウィンドウが開きます

  • source: ソースコードが表示されるウィンドウ
  • gdb: gdb コマンドを入力するウィンドウ
  • program: 実行したプログラムのウィンドウ

ウィンドウの行き来には下記コマンドが使用できます

 :Gdb      gdb ウィンドウに移動する
:Program デバッグ中のプログラムウィンドウに移動する
:Source ソースコードウィンドウにジャンプする
:Asm 逆アセンブルウィンドウにジャンプする
:Var ローカル変数と引数変数のあるウィンドウにジャンプする

gdb コマンドは gdb ウィンドウで入力しても、source ウィンドウで Ex コマンドを使用しても、マウスを使用してツールバーを選択しても実行できます

例えば下記のコマンドは同じ結果になります

# gdb ウィンドウ
b evalbuffer.c:474
# source ウィンドウ
:Break evalbuffer.c:474

source ウィンドウで gdb 操作できる Ex コマンドは以下です

 :Run [args]        [args] または以前の引数でプログラムを実行する
:Arguments   {args} 次の :Run のために引数を設定する
:Break   カーソル位置にブレークポイントを設定する。
:Break {position}  指定位置にブレークポイントを設定する。
:Clear   カーソル位置のブレークポイントを削除する
:Step   gdb の "step" コマンドを実行する
:Over   gdb の "next" コマンドを実行する
:Until   gdb の "until" コマンドを実行する
:Finish   gdb の "finish" コマンドを実行する
:Continue   gdb の "continue" コマンドを実行する
:Stop   プログラムを中断する
:Evaluate                   カーソルの下の式を評価する(gdb の "print" 相当)
                                       :Ev もしくは K でも同様
:Evaluate {expr}     {expr} を評価する
:'<,'>Evaluate         ビジュアル選択したテキストを評価する

source ウィンドウでマクロを実行したり、ソースコードを Vim キーバインドでスクロールしたり、K でカーソル下の評価をしたりできます

https://youtu.be/y-ZG8WOdWvI

3. bufnr() の動作検証結果

パスを引数に指定して Vim を起動した場合、bufnr() に存在しないはずのバッファ名(例えば [update plugins])を渡して実行すると、-1 ではなく、バッファ番号が返ってくることがわかりました

上記の動作結果を検証したところ、 bufnr() の返り値はバグではなく仕様でした

bufnr() の引数のファイル名は file-pattern で解釈されるため、 [update plugins][] 部分にエスケープがないことで u,p,d,a,t,e, ,l,g,i,n,s のいずれかにマッチした場合に、そのバッファ番号を返すようになっていました

再掲ですが、 ./test を開いている際に bufnr('[update plugins]') を実行した場合は e,t を含むため、マッチしてしまい、-1 ではなくバッファ番号が返っています

bufnr()help をよく読んでみると、{buf}の使い方は前述の bufname() を参照と記載されており、 bufname()helpfile-pattern を使用する記述がありました

{buf}が文字列ならば、バッファ名に対してファイル名マッチング file-pattern を行うパターンとなる。
このマッチングは常に、'magic' をセットし 'cpoptions'を空にした状態で行われる。
複数マッチしてしまった場合には空文字列が返される。

しっかり help を読んでいれば debug 不要でしたね。。。

しかし debug を通してわかったこともあります
file-pattern による検索で優先されるのは、ファイル名の完全一致ではなく、カレントファイル(%)ということです (実装: buffer.c:2666)

:ls!
1 %a + "test"
line 1
4 #h "[update plugins]"
line 1

つまり、上の状態で :echo bufnr('[update plugins]') を実行した場合はカレントバッファの 1 が返ります

def! g:UpdatePackPlugins(): void #{{{
# Needs escape not to use file-pattern.
var prevbuf: number = bufnr('\[update plugins]\')
if prevbuf != -1
# Needs `:` before Ex command with range.
execute ':' .. prevbuf .. 'bwipeout!'
endif
var nr: number = bufadd('[update plugins]')
bufload(nr)
execute ':' .. nr .. 'sb'
setlocal buftype=nofile

この検証を通して上記の通り書き直すことで、パスを引数に指定して Vim を起動後に :call UpdatePackPlugins() を実行しても、アクティブバッファが新規作成された無題のバッファに変更されてしまう問題を解決することができました

4. まとめ

Vim を GDB で debug するには gdb コマンドを直接使用する方法と termdebug を使用する方法があることを紹介しました

どちらを使うかは完全に好みですが、termdebug では gdb コマンドを使える上に Vim の馴染みある操作ができて debug 効率が良いので、私は termdebug をおすすめします

ちょっと不安定な時もあるので画面描画を修正できるようになってもっと使いやすくしたいです

また、Vim のテストファイルには Vim をテストするための tips がたくさん詰まっているので、今度はテストを書くことで Vim の動作検証ができるように頑張ります

参考

--

--

ぺりー

Satoru Kitaguchi. Backend Engineer at eureka, Inc.