コードを書いているとき、対応する括弧はとても大事です。エディターの中でカーソル下の括弧がどこと対応しているかが一目でわかると便利です。Vimの標準のプラグインにmatchparenというプラグインがあります (:h matchparen)。
私もずっとmatchparenのハイライトに依存してコードを書いてきました。しかし、だんだんこのプラグインのパフォーマンスが気になるようになってきました。標準プラグインなのですがわりと重い処理をやっていると思います。対応括弧をハイライトするプラグインによって余計な処理が行われて、コーディングの妨げになってはあまりよくありません。
最初はパッチを送ることも考えましたが、プロファイルを取った結果、どうしてもある機能を実現するために必要な処理が重くて時間がかかっていることに気が付きました (3日くらい前のことです)。その機能を落とすのは標準プラグインには受け入れられないので、大体同じような機能を持つ新たなプラグインを書き、それに乗り換えることにしました。
あまりいい名前が思いつかなかったので、デフォルトプラグインの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]
なぜ挿入モードで一つずらすかというと、閉じ括弧を打った時にその対応括弧がハイライトされてほしいからですね。
次のコードは、前回のハイライトを消す処理です。この変数がないかもしれないのでお行儀はよくありませんが、コードを短くするために変数チェックもせずに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
以上です。
おわりに
括弧の対応をハイライトする標準プラグインのパフォーマンスが気に入らなかったので、新しくプラグイン作りました。厳密なシンタックスのチェックはスキップしていますが、それなりに快適なはずです。
ここで、私が昔作ったプラグインをもう一つ紹介しておきます。今回作ったparenmatchと姉妹プラグインと言ってもよいかもしれません。 github.com これはカーソルの下の単語に下線をひくプラグインです。
コードを書いている時、カーソルの下の変数が気になるのはどの言語でも同じです。このcursorwordプラグインを入れると、カーソルの下の変数がどこで代入されたり使用されているかがすぐに分かります。しかも、下線なのでそこまで目立ちすぎず、コーディングの邪魔にもなりません。
let g:loaded_matchparen = 1 NeoBundle 'itchyny/vim-parenmatch' NeoBundle 'itchyny/vim-cursorword'
私はcursorwordのさりげない下線ハイライトに本当に助けられています。
parenmatchとcursorwordを入れて、快適なコーディングライフをお過ごしください。おしまい。