Vim の doautoall コマンドの問題と 8.2.2596 の挙動変更について

Vimdoautoallコマンドはいろいろな問題のあるコマンドで、私にとって悩みの多い機能でした。 しかし 8.2.2596 の変更によって問題のすべてが解決しました。 本記事ではこのコマンドの問題点とこのパッチの意味について解説します。 なおコミットメッセージは:doautocmdとなっていますが、:doautoallが正解です。

この記事は、バッファとウィンドウ、タブ(ページ)、イベントに関して理解していることを前提としています。 これらについてのヘルプは以下を参照してください。 vim-jp.org vim-jp.org


doautoallコマンドは、すべてのバッファに対して指定したイベントを発火するコマンドです。 例えばdoautoall FileTypeはすべてのバッファにFileTypeコマンドを発火します。

このコマンドの困る挙動の例を挙げましょう。 例えばWinEnterイベントで、ウィンドウ一覧を取得してそのバッファ名を (デバッグの場所として) statuslineに表示してみましょう。

autocmd WinEnter * let &statusline = join(
      \ map(
      \   range(1, winnr('$')),
      \   'v:val . ": " . bufname(winbufnr(v:val))'
      \ ), ' | ')

タブで他のファイルを開いたときの挙動を見てみましょう。

edit foo.txt | vnew bar.txt | tabe baz.txt | tabp
doautoall WinEnter

8.2.2596よりも古いVimでは、以下のようになるはずです。

1: baz.txt | 2: bar.txt | 3: foo.txt

現在のタブには2つしかウィンドウがありませんから、baz.txtが表示されるのは奇妙です。

もう一つ困る挙動の例を挙げましょう。 先程のコマンドを改善して、アクティブウィンドウがわかるようにしてみます。 現在のウィンドウ番号はwinnr()で取得できます。

autocmd WinEnter * let &statusline = '%t / ' . join(
      \ map(
      \   range(1, winnr('$')),
      \   'v:val . ": " . (v:val == winnr() ? "[" : "") .' .
      \   'bufname(winbufnr(v:val)) . (v:val == winnr() ? "]" : "")'
      \ ), ' | ')

今度は現在のタブで3つのウィンドウを開いてみます。

edit foo.txt | vnew bar.txt | vnew baz.txt | wincmd p
doautoall WinEnter

古いVimでは以下のようになるでしょう。

bar.txt / 1: [baz.txt] | 2: bar.txt | 3: foo.txt

今のバッファはbar.txtですから、baz.txtがアクティブに見えているのは奇妙です。

以上の挙動は、statuslineWinEnterに限った話ではありません。 doautoallコマンドは、発火できるイベントに制限がありませんから (例えばdoautoall WinEnterの意味を考えているとおかしなコマンドに思われますが)、実際にユーザーはどんなイベントでも発火することができます。 なので何らかのイベントのトリガーでwinnr()で現在のウィンドウを記録している、bufname()で現在のバッファ名を取得している、winrestcmd()winsaveview()を記録している場合など広いユースケースで問題になります。 意味のわからないコマンドだとしても、Vimが実行を許している以上はVimプラグインで考慮する必要があります。 echomsgではなくstatuslineを使ったのは、最終的なウィンドウの状態がユーザーから見たときの挙動を左右するからです。


doautoallの問題点を大きく分けるとautocommand window (用語は:h win_gettype()より) とバッファの発火順があります。 このコマンドがイベントを発火するときに、現在のバッファやウィンドウを変更し、イベントハンドラの中でどのバッファかわかるようになっています。 あるバッファが現在のタブのどのウィンドウにも表示されていない場合は、autocommand windowという一時的なウィンドウ (コード上はaucmd_win) にバッファを紐付けて、それをアクティブウィンドウとした上でイベントを発火しています。 多くのイベントハンドラはこの特殊なウィンドウで実行されることを意図していないでしょう。 Vim 8.2.0996 (正確には8.2.0991からですが返り値に統一感がないということで変更された) より win_gettype() という関数でautocommand windowかどうかを判別できるようになっています。 この特殊なウィンドウが、1つ目の困る挙動で裏のタブで開いているバッファ名が表示された理由です。

2つ目の問題は発火順です。 doautoallは素朴にバッファ番号順にイベントを発火していました。 イベントハンドラは、イベントがdoautoallで発火されたことを検知できません。 autocommand windowは特殊なウィンドウなので区別する手段が作られましたが、最後のバッファが現在のタブの非アクティブウィンドウに表示されている場合は、やはりアクティブウィンドウを勘違いしてしまいます。

この問題を解決するのが、8.2.2596で取り込まれた以下の修正です。 解決方法は簡単で、順番を入れ替えて、現在のウィンドウに表示されているバッファのイベントを最後に実行するというものです。 これによってdoautoallが引き起こすあらゆる問題が解決しました。 イベント発火の順序の入れ替えという少し不安になる変更でしたが、Bramはあっさりと受け入れてくれました。 パッチがmergeされてから一週間が経ち、特に問題になっていないようですので大丈夫でしょう。 github.com


私は lightline.vim というプラグインを作ってメンテナをしており、このdoautoallコマンドによってアクティブウィンドウのstatuslineが正しく表示されないという問題には随分悩まされてきました (#435, #444, #447, #448, #556)。 特にセッションファイルにdoautoall SessionLoadPostというコマンドが含まれていることから、セッションの文脈でバグ報告が上がることが多い現象です。 しかし問題の本質はdoautoallコマンドの挙動だったので、トリッキーなworkaroundを入れる (例: カーソル移動で更新とかタイマーで再チェックとか) ことなく根本から修正できてよかったです。

autocommand windowwin_gettype()によって検知できるようになりました (8.2.0996, 8.2.0991)。 現在のウィンドウに表示されているバッファのイベント発火は最後に行われるようになりました (8.2.2596)。 この2つの修正によってすべての悩みが解決しました。 以上がこのdoautoallコマンドの問題点とそれに対する修正に関するすべてになります。 この修正によってlightline.vimのみならず、他の多くのプラグインが助かっているかと思います。

実際に手でdoautoallを打つことはめったにないですが、Vimプラグインのメンテナをしているとそういう機能とも真面目に向き合う必要があります。 ソフトウェアの問題への修正の多くはincrementalであり、dirty workaroundを行うのが容易いことが多いでしょう。 しかし、起きている現象の本質を考えることで、ソフトウェアを本当に良いものにしていけるのだと思います。 本質を捉えるのは難しいこともありますが、これができるプログラマになりたいものです。