Vimの標準プラグインmatchparenが遅かったので8倍くらい速いプラグインを作りました

コードを書いているとき、対応する括弧はとても大事です。エディターの中でカーソル下の括弧がどこと対応しているかが一目でわかると便利です。Vimの標準のプラグインにmatchparenというプラグインがあります (:h matchparen)。

私もずっとmatchparenのハイライトに依存してコードを書いてきました。しかし、だんだんこのプラグインのパフォーマンスが気になるようになってきました。標準プラグインなのですがわりと重い処理をやっていると思います。対応括弧をハイライトするプラグインによって余計な処理が行われて、コーディングの妨げになってはあまりよくありません。

最初はパッチを送ることも考えましたが、プロファイルを取った結果、どうしてもある機能を実現するために必要な処理が重くて時間がかかっていることに気が付きました (3日くらい前のことです)。その機能を落とすのは標準プラグインには受け入れられないので、大体同じような機能を持つ新たなプラグインを書き、それに乗り換えることにしました。

github.com

f:id:itchyny:20160330222736g:plain あまりいい名前が思いつかなかったので、デフォルトプラグインのmatchparenをひっくり返してparenmatchにしました (雑…)。

インストールするときは、例えばNeoBundleのお使いの方は次のようにしてください。

let g:loaded_matchparen = 1

NeoBundle 'itchyny/vim-parenmatch'

一行目はこのプラグインの設定ではなくて、標準のmatchparenを使うのをやめる設定です。そうしないとパフォーマンス上の魅力はないです。

プロファイルと取った結果、parenmatchは標準のmatchparenプラグインと比べると8倍以上速いということが分かっています。 gist.github.com 適当なVim scriptのコードの上でカーソルを移動して回るスクリプトを動かした時のプロファイル結果です。このプロファイル結果から、matchparenのどこが重いのかが見えてきます。

シンタックスを取得して文字列などをきちんとスキップする

まず、次の三行が重そうです。

  535              0.006009   let s_skip = '!empty(filter(map(synstack(line("."), col(".")), ''synIDattr(v:val, "name")''), ' . '''v:val =~? "string\\|character\\|singlequote\\|escape\\|comment"''))'
  535              1.424980   execute 'if' s_skip '| let s_skip = 0 | endif'
  535              1.590589     let [m_lnum, m_col] = searchpairpos(c, '', c2, s_flags, s_skip, stopline, timeout)

これは何をやっているのでしょうか。例えば次のようなコードを考えてみましょう。

var x = [ [ "[  ]  [" ], [ "]  [  ]" ] ]

正確に括弧の対応を調べるには、文字列の中かどうか判定しなければなりません。文字列の外側の括弧は外側の括弧と対応するでしょうし、文字列の中の括弧は中同士で対応することが期待されます。それを正確に調べるには、synIDattrという関数を使ってシンタックスを調べて、文字列っぽいシンタックスならスキップしないといけません。

ところが、synIDattrシンタックスを取得したり、その結果を正規表現にマッチさせたりするのは比較的重い処理です。しかも、現実のコードで上記のようなコードがそう頻繁にあるわけではありません。決してないわけではありませんが、多くのコードでは問題ありません。そんな少ないケースのために、常に処理が重くなっていては良くないですよね。

私の作ったparenmatchプラグインでは、上のように文字列の中かどうかを判定しないと正確に対応括弧が取れないケースには対応していません。バッサリ切り捨てました。その代わり、このチェックをしないことによってかなり速くなっています。特殊ケース (というほど特殊とはいえませんが、コード上ではそんなにない) のために全体のパフォーマンスを下げるような処理は気に入らなかったのです。それでももし、やっぱり正確に文字列の中かちゃんと判定して欲しいという場合は、標準のmatchparenプラグインをお使い下さい。

カーソル下の文字を取得するのに正規表現を使っている

次に気になるのは以下の行です。

10001              0.484219   let matches = matchlist(text, '\(.\)\=\%'.c_col.'c\(.\=\)')

\%c という指定行にマッチする正規表現を使いつつ、カーソルの下と一つ左の文字を取得しています。それだけで、と思われるかもしれませんが、正規表現を作り、キャプチャーを作りながらマッチさせるという処理はやはり重いようです。

parenmatchでは普通に文字列のインデックスアクセスで取得しています。

matchpairsの分割を毎回行っている

三番目に気になったのは、この行です。

10001              0.391124   let plist = split(&matchpairs, '.\zs[:,]')

基本的に、matchpairsの値はそう頻繁に変わるものではありません。カーソルをちょっと動かしただけでコロコロ変わるものじゃないですよね。そういう値を、しかも正規表現で毎度分割するのは効率のよい処理ではありません。このオプションがよくセットされる場所としては、vimrcの中でグローバルな値がセットされているか、あるいは何らかのファイルタイプでローカルに変更されるケースでしょう。

parenmatchでは、予めmatchpairsの値を分割し、更に頻繁に呼ばれる関数の中で使いやすい形に整形してキャッシュしています。

parenmatchの実装

matchparenの重い場所は分かったので、次に私が今回作ってみたparenmatchのコードを見てみましょう。

括弧をカーソルが動く度にハイライトさせようと思ったら、どうしてもカーソルが動く度に呼ばれる関数が必要になります。

augroup parenmatch
  autocmd!
  autocmd CursorMoved,CursorMovedI * call parenmatch#update()
augroup END

この関数はとても頻繁に呼ばれるので、プロファイルを取りながら限界まで小さく実装します。この記事を書いてる時点でのコードは次のようになっています (今後変更される可能性はあります)。

let s:paren = {}
function! parenmatch#update() abort
  let i = mode() ==# 'i' || mode() ==# 'R'
  let c = getline('.')[col('.') - i - 1]
  silent! call matchdelete(w:parenmatch)
  if !has_key(s:paren, c) | return | endif
  let [open, closed, flags, stop] = s:paren[c]
  let q = [line('.'), col('.') - i]
  if i | let p = getcurpos() | call cursor(q) | endif
  let r = searchpairpos(open, '', closed, flags, '', line(stop), 10)
  if i | call setpos('.', p) | endif
  if r[0] > 0 | let w:parenmatch = matchaddpos('ParenMatch', [q, r]) | endif
endfunction

スクリプトローカルな変数 s:paren は次のようにして準備されます。

let s:matchpairs = ''
function! parenmatch#setup() abort
  if s:matchpairs ==# &l:matchpairs
    return
  endif
  let s:matchpairs = &l:matchpairs
  let s:paren = {}
  for [open, closed] in map(split(&l:matchpairs, ','), 'split(v:val, ":")')
    let s:paren[open] = [ escape(open, '[]'), escape(closed, '[]'), 'nW', 'w$' ]
    let s:paren[closed] = [ escape(open, '[]'), escape(closed, '[]'), 'bnW', 'w0' ]
  endfor
endfunction

例えば matchpairs=(:),{:},[:] の時、 s:paren は次のようになります。

{
  '(': ['(', ')', 'nW', 'w$'],
  ')': ['(', ')', 'bnW', 'w0'],
  '{': ['{', '}', 'nW', 'w$'],
  '}': ['{', '}', 'bnW', 'w0'],
  '[': ['\[', '\]', 'nW', 'w$'],
  ']': ['\[', '\]', 'bnW', 'w0']
}

parenmatch#update の処理を追ってみましょう。まず、挿入モードや置換モードかどうかチェックします。そして、カーソル下の文字、挿入モードならばカーソルの一つ左の文字を取得します。文字列のアクセスでインデックスがないときは空文字になります (そういう挙動をヘルプで確認しながらコードを削っていきます)。

  let i = mode() ==# 'i' || mode() ==# 'R'
  let c = getline('.')[col('.') - i - 1]

なぜ挿入モードで一つずらすかというと、閉じ括弧を打った時にその対応括弧がハイライトされてほしいからですね。 f:id:itchyny:20160330222750g:plain 次のコードは、前回のハイライトを消す処理です。この変数がないかもしれないのでお行儀はよくありませんが、コードを短くするために変数チェックもせずにmatchdeleteを呼び、silent!で握りつぶしています。

  silent! call matchdelete(w:parenmatch)

そして、さっきのキャッシュのキーかどうか判定しています。括弧文字でなければこの関数の処理は終わりです。

  if !has_key(s:paren, c) | return | endif

実際、コードを書いていてカーソル下の文字が括弧文字ではない場合は括弧文字である場合よりも多いので、この行までと後で呼ばれる回数はかなり差があります。プロファイルの結果をもう一度貼り付けておきます。

FUNCTION  parenmatch#update()
Called 10000 times
Total time:   0.660151
 Self time:   0.660151

count  total (s)   self (s)
10000              0.110140   let i = mode() ==# 'i' || mode() ==# 'R'
10000              0.149091   let c = getline('.')[col('.') - i - 1]
10000              0.151401   silent! call matchdelete(w:parenmatch)
10000              0.106627   if !has_key(s:paren, c) | return | endif
  535              0.005828   let [open, closed, flags, stop] = s:paren[c]
  535              0.006679   let q = [line('.'), col('.') - i]
  535              0.006086   if i | let p = getcurpos() | call cursor(q) | endif
  535              0.046504   let r = searchpairpos(open, '', closed, flags, '', line(stop), 10)
  535              0.005393   if i | call setpos('.', p) | endif
  535              0.037070   if r[0] > 0 | let w:parenmatch = matchaddpos('ParenMatch', [q, r]) | endif

もちろんコードの言語に依存しますが、90%以上の確率で4行目で終わるでしょう。 もし括弧文字の上にカーソルがある場合は、セットアップしたs:parenから情報を取り、

  let [open, closed, flags, stop] = s:paren[c]

対応括弧を探して、

  let r = searchpairpos(open, '', closed, flags, '', line(stop), 10)

もし見つかれば、ParenMatchというシンタックスで登録します。

  if r[0] > 0 | let w:parenmatch = matchaddpos('ParenMatch', [q, r]) | endif

以上がメインの処理です。途中三行ほど飛ばしました。挿入モードなら、カーソルの一つ左の括弧から検索 (searchpairpos) を開始する必要があります。getcurposでカーソル位置を覚えておき、一つずらし、対応括弧を探した後にカーソルを戻すという処理が入っています。

  let q = [line('.'), col('.') - i]
  if i | let p = getcurpos() | call cursor(q) | endif
  let r = searchpairpos(...
  if i | call setpos('.', p) | endif

以上です。

おわりに

括弧の対応をハイライトする標準プラグインのパフォーマンスが気に入らなかったので、新しくプラグイン作りました。厳密なシンタックスのチェックはスキップしていますが、それなりに快適なはずです。

github.com

ここで、私が昔作ったプラグインをもう一つ紹介しておきます。今回作ったparenmatchと姉妹プラグインと言ってもよいかもしれません。 github.com これはカーソルの下の単語に下線をひくプラグインです。 https://raw.githubusercontent.com/wiki/itchyny/vim-cursorword/image/image.gif

コードを書いている時、カーソルの下の変数が気になるのはどの言語でも同じです。このcursorwordプラグインを入れると、カーソルの下の変数がどこで代入されたり使用されているかがすぐに分かります。しかも、下線なのでそこまで目立ちすぎず、コーディングの邪魔にもなりません。

let g:loaded_matchparen = 1
NeoBundle 'itchyny/vim-parenmatch'
NeoBundle 'itchyny/vim-cursorword'

私はcursorwordのさりげない下線ハイライトに本当に助けられています。

parenmatchとcursorwordを入れて、快適なコーディングライフをお過ごしください。おしまい。